using System.ComponentModel.DataAnnotations;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using ClosedXML.Excel;
using Jiaowu.Api.Contracts;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Excel;
using Jiaowu.Api.Infrastructure.Persistence;
using Jiaowu.Api.Infrastructure.Teaching;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;

namespace Jiaowu.Api.Controllers;

[ApiController]
[Authorize]
[Route("api/attendance")]
public sealed class AttendanceController(
    AppDbContext db,
    ICurrentUserDataScope currentUserDataScope) : ControllerBase
{
    private const string AttendanceRoles =
        SystemRoles.SuperAdmin + "," +
        SystemRoles.AcademicAdmin + "," +
        SystemRoles.CollegeAdmin + "," +
        SystemRoles.Counselor + "," +
        SystemRoles.Teacher;

    [HttpGet("my-tasks")]
    [Authorize(Roles = AttendanceRoles)]
    public async Task<ActionResult> GetMyTasks(
        Guid? academicTermId,
        string? keyword = null,
        int page = 1,
        int pageSize = 20,
        CancellationToken cancellationToken = default)
    {
        page = Math.Max(1, page);
        pageSize = Math.Clamp(pageSize, 10, 50);
        var tasks = AccessibleTasks().AsNoTracking()
            .Where(x => x.Status == TeachingTaskStatus.Published);
        if (academicTermId.HasValue)
            tasks = tasks.Where(x => x.AcademicTermId == academicTermId);
        if (!string.IsNullOrWhiteSpace(keyword))
        {
            keyword = keyword.Trim();
            tasks = tasks.Where(x =>
                x.TaskNumber.Contains(keyword) ||
                x.Name.Contains(keyword) ||
                x.Course!.Code.Contains(keyword) ||
                x.Course.Name.Contains(keyword));
        }
        var total = await tasks.CountAsync(cancellationToken);
        var result = await tasks
            .OrderBy(x => x.Course!.Code)
            .ThenBy(x => x.TaskNumber)
            .Skip((page - 1) * pageSize)
            .Take(pageSize)
            .Select(x => new
            {
                x.Id,
                x.TaskNumber,
                x.Name,
                x.AcademicTermId,
                TermName = x.AcademicTerm!.Name,
                CourseCode = x.Course!.Code,
                CourseName = x.Course!.Name,
                StudentCount = db.Students.Count(student =>
                    (student.Status == StudentStatus.Active &&
                     x.Classes.Any(assignment =>
                         assignment.AdministrativeClassId ==
                         student.AdministrativeClassId)) ||
                    db.CourseEnrollments.Any(enrollment =>
                        enrollment.StudentId == student.Id &&
                        enrollment.Status == CourseEnrollmentStatus.Enrolled &&
                        enrollment.CourseSelectionOffering!.TeachingTaskId == x.Id)),
                SheetCount = db.AttendanceSheets.Count(sheet =>
                    sheet.TeachingTaskId == x.Id)
            })
            .ToListAsync(cancellationToken);
        return Ok(new PagedResult<object>(result, total, page, pageSize));
    }

    [HttpGet("sheets")]
    [Authorize(Roles = AttendanceRoles)]
    public async Task<ActionResult> GetSheets(
        Guid teachingTaskId,
        string? keyword = null,
        AttendanceSheetStatus? status = null,
        AttendanceCheckInMethod? checkInMethod = null,
        int page = 1,
        int pageSize = 20,
        CancellationToken cancellationToken = default)
    {
        page = Math.Max(1, page);
        pageSize = Math.Clamp(pageSize, 10, 50);
        var task = await AccessibleTasks().AsNoTracking()
            .FirstOrDefaultAsync(x => x.Id == teachingTaskId, cancellationToken);
        if (task is null) return NotFound();

        var source = db.AttendanceSheets.AsNoTracking()
            .Where(x => x.TeachingTaskId == teachingTaskId);
        if (!string.IsNullOrWhiteSpace(keyword))
        {
            keyword = keyword.Trim();
            source = source.Where(x => x.Name.Contains(keyword) || x.Notes!.Contains(keyword));
        }
        if (status.HasValue)
            source = source.Where(x => x.Status == status.Value);
        if (checkInMethod.HasValue)
            source = source.Where(x => x.CheckInMethod == checkInMethod.Value);

        var total = await source.CountAsync(cancellationToken);
        var sheets = await source
            .OrderByDescending(x => x.AttendanceDate)
            .ThenByDescending(x => x.Id)
            .Skip((page - 1) * pageSize)
            .Take(pageSize)
            .Select(x => new
            {
                x.Id,
                x.Name,
                x.AttendanceDate,
                x.Status,
                x.CheckInMethod,
                x.CheckInStartsAt,
                x.CheckInEndsAt,
                x.Notes,
                x.SubmittedAt,
                CheckedInCount = x.Records.Count(r => r.CheckInAt != null),
                PresentCount = x.Records.Count(r => r.Status == AttendanceStatus.Present),
                AbsentCount = x.Records.Count(r => r.Status == AttendanceStatus.Absent),
                LateCount = x.Records.Count(r => r.Status == AttendanceStatus.Late),
                LeaveCount = x.Records.Count(r => r.Status == AttendanceStatus.Leave),
                ExcusedCount = x.Records.Count(r => r.Status == AttendanceStatus.Excused),
                TotalCount = x.Records.Count
            })
            .ToListAsync(cancellationToken);
        return Ok(new PagedResult<object>(sheets, total, page, pageSize));
    }

    [HttpPost("sheets")]
    [Authorize(Roles = AttendanceRoles)]
    public async Task<ActionResult> CreateSheet(
        AttendanceSheetRequest request,
        CancellationToken cancellationToken)
    {
        var task = await AccessibleTasks().AsNoTracking()
            .FirstOrDefaultAsync(x => x.Id == request.TeachingTaskId, cancellationToken);
        if (task is null) return NotFound();

        var studentIds = await TeachingTaskRosterQuery
            .ForTask(db, request.TeachingTaskId)
            .AsNoTracking()
            .Select(x => x.Id)
            .ToListAsync(cancellationToken);
        if (studentIds.Count == 0)
            return ConflictProblem("该教学班没有有效学生。");

        var checkInMethod = request.CheckInMethod ?? AttendanceCheckInMethod.Manual;
        if (!Enum.IsDefined(checkInMethod))
            return ConflictProblem("不支持该签到方式。");

        var now = DateTime.UtcNow;
        DateTime? checkInEndsAt = null;
        string? checkInToken = null;
        if (checkInMethod != AttendanceCheckInMethod.Manual)
        {
            if (request.CheckInDurationMinutes is < 1 or > 180)
                return ConflictProblem("签到时长应为 1 至 180 分钟。");
            checkInEndsAt = now.AddMinutes(request.CheckInDurationMinutes!.Value);
        }
        if (checkInMethod == AttendanceCheckInMethod.QrCode)
            checkInToken = Convert.ToHexString(RandomNumberGenerator.GetBytes(24));
        if (checkInMethod == AttendanceCheckInMethod.Location)
        {
            if (request.TargetLatitude is < -90 or > 90 ||
                request.TargetLongitude is < -180 or > 180 ||
                request.TargetLatitude is null ||
                request.TargetLongitude is null)
                return ConflictProblem("请获取有效的签到位置。");
            if (request.LocationRadiusMeters is < 20 or > 1000)
                return ConflictProblem("定位签到范围应为 20 至 1000 米。");
        }

        var sheet = new AttendanceSheet
        {
            TeachingTaskId = request.TeachingTaskId,
            Name = request.Name.Trim(),
            AttendanceDate = request.AttendanceDate,
            CheckInMethod = checkInMethod,
            CheckInToken = checkInToken,
            CheckInStartsAt = checkInMethod == AttendanceCheckInMethod.Manual
                ? null
                : now,
            CheckInEndsAt = checkInEndsAt,
            TargetLatitude = checkInMethod == AttendanceCheckInMethod.Location
                ? request.TargetLatitude
                : null,
            TargetLongitude = checkInMethod == AttendanceCheckInMethod.Location
                ? request.TargetLongitude
                : null,
            LocationRadiusMeters = checkInMethod == AttendanceCheckInMethod.Location
                ? request.LocationRadiusMeters
                : null,
            Notes = Normalize(request.Notes),
            Records = studentIds.Select(studentId => new AttendanceRecord
            {
                StudentId = studentId,
                Status = checkInMethod == AttendanceCheckInMethod.Manual
                    ? AttendanceStatus.Present
                    : AttendanceStatus.Absent
            }).ToList()
        };
        db.AttendanceSheets.Add(sheet);
        await db.SaveChangesAsync(cancellationToken);
        return Created(string.Empty, new
        {
            sheet.Id,
            sheet.CheckInMethod,
            sheet.CheckInStartsAt,
            sheet.CheckInEndsAt
        });
    }

    [HttpGet("sheets/{id:guid}")]
    [Authorize(Roles = AttendanceRoles)]
    public async Task<ActionResult> GetSheet(Guid id, CancellationToken cancellationToken)
    {
        var sheet = await db.AttendanceSheets.AsNoTracking()
            .Where(x => x.Id == id)
            .Select(x => new
            {
                x.Id,
                x.TeachingTaskId,
                x.Name,
                x.AttendanceDate,
                x.Status,
                x.CheckInMethod,
                x.CheckInStartsAt,
                x.CheckInEndsAt,
                x.TargetLatitude,
                x.TargetLongitude,
                x.LocationRadiusMeters,
                x.Notes,
                x.SubmittedAt,
                TaskNumber = x.TeachingTask!.TaskNumber,
                TaskName = x.TeachingTask.Name,
                CourseCode = x.TeachingTask.Course!.Code,
                CourseName = x.TeachingTask.Course.Name,
                Records = x.Records
                    .OrderBy(r => r.Student!.StudentNumber)
                    .Select(r => new
                    {
                        r.StudentId,
                        r.Student!.StudentNumber,
                        r.Student.Name,
                        ClassName = r.Student.AdministrativeClass!.Name,
                        r.Status,
                        r.Notes,
                        r.CheckInAt,
                        r.CheckedInMethod,
                        r.CheckInAccuracyMeters,
                        r.CheckInDistanceMeters
                    })
            })
            .FirstOrDefaultAsync(cancellationToken);
        if (sheet is null) return NotFound();
        if (!await AccessibleTasks().AsNoTracking()
                .AnyAsync(x => x.Id == sheet.TeachingTaskId, cancellationToken))
            return NotFound();

        // Load exemption/deferred in separate query to avoid SQL APPLY (unsupported on SQLite)
        var exemptStudentIds = await db.CourseExemptions
            .Where(e => e.TeachingTaskId == sheet.TeachingTaskId && e.Status == ApprovalStatus.Approved)
            .Select(e => e.StudentId).ToHashSetAsync(cancellationToken);
        var deferredStudentIds = await db.DeferredExams
            .Where(e => e.TeachingTaskId == sheet.TeachingTaskId && e.Status == ApprovalStatus.Approved)
            .Select(e => e.StudentId).ToHashSetAsync(cancellationToken);

        var canManage = await CanManageTaskAsync(
            sheet.TeachingTaskId,
            cancellationToken);
        var canEdit = sheet.Status == AttendanceSheetStatus.Draft && canManage;
        var now = DateTime.UtcNow;
        var attempts = await db.AttendanceCheckInAttempts.AsNoTracking()
            .Where(x => x.AttendanceSheetId == id)
            .Select(x => new
            {
                x.StudentId,
                x.CreatedAt,
                x.IsSuccessful,
                x.FailureCode,
                x.DeviceIdentifierHash,
                x.DevicePlatform,
                x.IpAddress,
                x.RiskFlags
            })
            .ToListAsync(cancellationToken);
        var attemptsByStudent = attempts
            .GroupBy(x => x.StudentId)
            .ToDictionary(x => x.Key, x => x.OrderByDescending(a => a.CreatedAt).ToList());
        var deviceHashes = attempts
            .Where(x => x.IsSuccessful && x.DeviceIdentifierHash != null)
            .Select(x => x.DeviceIdentifierHash!)
            .Distinct(StringComparer.Ordinal)
            .ToList();
        var deviceReuseCounts = new Dictionary<string, int>(StringComparer.Ordinal);
        if (deviceHashes.Count > 0)
        {
            var reuseRows = await db.AttendanceCheckInAttempts.AsNoTracking()
                .Where(x =>
                    x.IsSuccessful &&
                    x.CreatedAt >= now.AddHours(-24) &&
                    x.DeviceIdentifierHash != null)
                .WhereIn(deviceHashes, x => x.DeviceIdentifierHash!)
                .GroupBy(x => x.DeviceIdentifierHash!)
                .Select(x => new
                {
                    DeviceIdentifierHash = x.Key,
                    StudentCount = x.Select(a => a.StudentId).Distinct().Count()
                })
                .ToListAsync(cancellationToken);
            deviceReuseCounts = reuseRows.ToDictionary(
                x => x.DeviceIdentifierHash,
                x => x.StudentCount,
                StringComparer.Ordinal);
        }

        var responseRecords = sheet.Records.Select(r =>
        {
            var studentAttempts = attemptsByStudent.GetValueOrDefault(r.StudentId) ?? [];
            var latestSuccess = studentAttempts.FirstOrDefault(x => x.IsSuccessful);
            var riskFlags = studentAttempts
                .SelectMany(x => ParseRiskFlags(x.RiskFlags))
                .ToHashSet(StringComparer.Ordinal);
            if (studentAttempts.Count(x => !x.IsSuccessful) >= 3)
                riskFlags.Add("RepeatedFailures");
            if (studentAttempts.Count(x => x.CreatedAt >= now.AddMinutes(-2)) >= 6)
                riskFlags.Add("HighFrequency");
            var sharedDeviceStudentCount = latestSuccess?.DeviceIdentifierHash is { } deviceHash
                ? deviceReuseCounts.GetValueOrDefault(deviceHash)
                : 0;
            if (sharedDeviceStudentCount > 1)
                riskFlags.Add("SharedDevice");
            var sameIpStudentCount = latestSuccess?.IpAddress is { } ipAddress
                ? attempts
                    .Where(x => x.IsSuccessful && x.IpAddress == ipAddress)
                    .Select(x => x.StudentId)
                    .Distinct()
                    .Count()
                : 0;

            return new
            {
                r.StudentId,
                r.StudentNumber,
                r.Name,
                r.ClassName,
                r.Status,
                r.Notes,
                r.CheckInAt,
                r.CheckedInMethod,
                r.CheckInAccuracyMeters,
                r.CheckInDistanceMeters,
                IsExempt = exemptStudentIds.Contains(r.StudentId),
                IsDeferred = deferredStudentIds.Contains(r.StudentId),
                CheckInAudit = latestSuccess is null
                    ? null
                    : new
                    {
                        latestSuccess.IpAddress,
                        latestSuccess.DevicePlatform,
                        DeviceCode = latestSuccess.DeviceIdentifierHash?[..8],
                        SharedDeviceStudentCount = sharedDeviceStudentCount,
                        SameIpStudentCount = sameIpStudentCount
                    },
                AttemptCount = studentAttempts.Count,
                FailedAttemptCount = studentAttempts.Count(x => !x.IsSuccessful),
                RiskFlags = riskFlags.OrderBy(x => x).ToArray()
            };
        }).ToList();
        return Ok(new
        {
            Sheet = new
            {
                sheet.Id,
                sheet.TeachingTaskId,
                sheet.Name,
                sheet.AttendanceDate,
                sheet.Status,
                sheet.CheckInMethod,
                sheet.CheckInStartsAt,
                sheet.CheckInEndsAt,
                sheet.TargetLatitude,
                sheet.TargetLongitude,
                sheet.LocationRadiusMeters,
                IsCheckInOpen = IsCheckInOpen(
                    sheet.Status,
                    sheet.CheckInMethod,
                    sheet.CheckInStartsAt,
                    sheet.CheckInEndsAt,
                    now),
                CheckedInCount = sheet.Records.Count(r => r.CheckInAt != null),
                sheet.Notes,
                sheet.SubmittedAt,
                sheet.TaskNumber,
                sheet.TaskName,
                sheet.CourseCode,
                sheet.CourseName,
                Records = responseRecords,
                RiskSummary = new
                {
                    RiskStudentCount = responseRecords.Count(x => x.RiskFlags.Length > 0),
                    SharedDeviceStudentCount = responseRecords.Count(
                        x => x.RiskFlags.Contains("SharedDevice")),
                    FrequentAttemptStudentCount = responseRecords.Count(
                        x => x.RiskFlags.Contains("HighFrequency") ||
                             x.RiskFlags.Contains("RepeatedFailures"))
                }
            },
            CanEdit = canEdit
        });
    }

    [HttpPut("sheets/{id:guid}/records")]
    [Authorize(Roles = AttendanceRoles)]
    public async Task<ActionResult> UpdateRecords(
        Guid id,
        AttendanceRecordsRequest request,
        CancellationToken cancellationToken)
    {
        var sheet = await db.AttendanceSheets
            .Include(x => x.Records)
            .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 (!CanManageSheet(sheet)) return Forbid();
        if (sheet.Status != AttendanceSheetStatus.Draft)
            return ConflictProblem("考勤表已提交，不能修改。");

        var recordMap = sheet.Records.ToDictionary(r => r.StudentId);
        foreach (var item in request.Records)
        {
            if (recordMap.TryGetValue(item.StudentId, out var record))
            {
                record.Status = item.Status;
                record.Notes = Normalize(item.Notes);
            }
        }
        await db.SaveChangesAsync(cancellationToken);
        return NoContent();
    }

    [HttpPost("sheets/{id:guid}/import")]
    [Authorize(Roles = AttendanceRoles)]
    public async Task<ActionResult> Import(
        Guid id,
        IFormFile file,
        CancellationToken cancellationToken)
    {
        var sheet = await db.AttendanceSheets
            .Include(x => x.Records)
            .ThenInclude(x => x.Student)
            .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 (!CanManageSheet(sheet)) return Forbid();
        if (sheet.Status != AttendanceSheetStatus.Draft)
            return ConflictProblem("考勤表已提交，不能导入。");

        var rows = await ExcelWorkbookHelper.ReadAsync(
            file,
            new HashSet<string> { "学号", "考勤状态" },
            cancellationToken);
        var studentNumberMap = sheet.Records
            .ToDictionary(r => r.Student!.StudentNumber, r => r);
        var statusMap = new Dictionary<string, AttendanceStatus>(StringComparer.OrdinalIgnoreCase)
        {
            ["出勤"] = AttendanceStatus.Present,
            ["缺勤"] = AttendanceStatus.Absent,
            ["迟到"] = AttendanceStatus.Late,
            ["请假"] = AttendanceStatus.Leave,
            ["免修"] = AttendanceStatus.Excused
        };

        var updated = 0;
        foreach (var row in rows)
        {
            var studentNumber = row["学号"];
            var statusText = row["考勤状态"];
            if (string.IsNullOrEmpty(studentNumber)) continue;
            if (!studentNumberMap.TryGetValue(studentNumber, out var record)) continue;
            if (statusMap.TryGetValue(statusText, out var status))
            {
                record.Status = status;
                updated++;
            }
        }
        await db.SaveChangesAsync(cancellationToken);
        return Ok(new { Updated = updated });
    }

    [HttpGet("sheets/{id:guid}/export.xlsx")]
    [Authorize(Roles = AttendanceRoles)]
    public async Task<ActionResult> Export(Guid id, CancellationToken cancellationToken)
    {
        var sheetData = await db.AttendanceSheets.AsNoTracking()
            .Where(x => x.Id == id)
            .Select(x => new
            {
                x.TeachingTaskId,
                x.Name,
                x.AttendanceDate,
                TaskNumber = x.TeachingTask!.TaskNumber,
                CourseName = x.TeachingTask.Course!.Name,
                Records = x.Records
                    .OrderBy(r => r.Student!.StudentNumber)
                    .Select(r => new
                    {
                        r.Student!.StudentNumber,
                        r.Student.Name,
                        ClassName = r.Student.AdministrativeClass!.Name,
                        r.Status,
                        r.Notes
                    })
                    .ToList()
            })
            .FirstOrDefaultAsync(cancellationToken);
        if (sheetData is null) return NotFound();
        if (!await AccessibleTasks().AsNoTracking()
                .AnyAsync(x => x.Id == sheetData.TeachingTaskId, cancellationToken))
            return NotFound();

        var statusLabels = new Dictionary<AttendanceStatus, string>
        {
            [AttendanceStatus.Present] = "出勤",
            [AttendanceStatus.Absent] = "缺勤",
            [AttendanceStatus.Late] = "迟到",
            [AttendanceStatus.Leave] = "请假",
            [AttendanceStatus.Excused] = "免修"
        };
        var bytes = ExcelWorkbookHelper.Create(
            "考勤表",
            ["学号", "姓名", "班级", "考勤状态", "备注"],
            sheetData.Records.Select(r => new List<object?>
            {
                r.StudentNumber, r.Name, r.ClassName,
                statusLabels.GetValueOrDefault(r.Status, "出勤"), r.Notes
            }).ToList<IReadOnlyList<object?>>());
        return File(bytes, ExcelWorkbookHelper.ContentType,
            $"考勤表-{sheetData.TaskNumber}-{sheetData.AttendanceDate:yyyyMMdd}.xlsx");
    }

    [HttpGet("tasks/{teachingTaskId:guid}/statistics")]
    [Authorize(Roles = AttendanceRoles)]
    public async Task<ActionResult> GetTaskStatistics(
        Guid teachingTaskId,
        CancellationToken cancellationToken)
    {
        var statistics = await LoadTaskStatisticsAsync(
            teachingTaskId,
            cancellationToken);
        return statistics is null ? NotFound() : Ok(statistics);
    }

    [HttpGet("tasks/{teachingTaskId:guid}/statistics.xlsx")]
    [Authorize(Roles = AttendanceRoles)]
    public async Task<ActionResult> ExportTaskStatistics(
        Guid teachingTaskId,
        CancellationToken cancellationToken)
    {
        var statistics = await LoadTaskStatisticsAsync(
            teachingTaskId,
            cancellationToken);
        if (statistics is null) return NotFound();

        var bytes = CreateStatisticsWorkbook(statistics);
        return File(
            bytes,
            ExcelWorkbookHelper.ContentType,
            $"课程考勤统计-{statistics.Course.TaskNumber}.xlsx");
    }

    [HttpPost("sheets/{id:guid}/submit")]
    [Authorize(Roles = AttendanceRoles)]
    public async Task<ActionResult> Submit(Guid id, CancellationToken cancellationToken)
    {
        var sheet = await db.AttendanceSheets
            .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 (!CanManageSheet(sheet)) return Forbid();
        if (sheet.Status != AttendanceSheetStatus.Draft)
            return ConflictProblem("考勤表已提交。");
        sheet.Status = AttendanceSheetStatus.Submitted;
        sheet.SubmittedAt = DateTime.UtcNow;
        await db.SaveChangesAsync(cancellationToken);
        return NoContent();
    }

    [HttpPost("sheets/{id:guid}/close-check-in")]
    [Authorize(Roles = AttendanceRoles)]
    public async Task<ActionResult> CloseCheckIn(
        Guid id,
        CancellationToken cancellationToken)
    {
        var sheet = await db.AttendanceSheets
            .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 (!CanManageSheet(sheet)) return Forbid();
        if (sheet.Status != AttendanceSheetStatus.Draft)
            return ConflictProblem("考勤表已提交，签到活动已经结束。");
        if (sheet.CheckInMethod == AttendanceCheckInMethod.Manual)
            return ConflictProblem("普通点名没有在线签到活动。");

        var now = DateTime.UtcNow;
        if (sheet.CheckInEndsAt is null || sheet.CheckInEndsAt > now)
            sheet.CheckInEndsAt = now;
        await db.SaveChangesAsync(cancellationToken);
        return NoContent();
    }

    [HttpGet("sheets/{id:guid}/qr-challenge")]
    [Authorize(Roles = AttendanceRoles)]
    public async Task<ActionResult> GetQrChallenge(
        Guid id,
        CancellationToken cancellationToken)
    {
        var sheet = await db.AttendanceSheets
            .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 (!CanManageSheet(sheet)) return Forbid();
        if (sheet.CheckInMethod != AttendanceCheckInMethod.QrCode ||
            string.IsNullOrWhiteSpace(sheet.CheckInToken))
            return ConflictProblem("该考勤表不是扫码签到。");

        var now = DateTime.UtcNow;
        if (!IsCheckInOpen(
                sheet.Status,
                sheet.CheckInMethod,
                sheet.CheckInStartsAt,
                sheet.CheckInEndsAt,
                now))
            return ConflictProblem("签到尚未开始或已经结束。");

        var challenge = AttendanceCheckInChallenge.Create(
            sheet.Id,
            sheet.CheckInToken,
            now);
        return Ok(challenge);
    }

    // ═══════════════ Student endpoints ═══════════════

    [HttpGet("check-in-info")]
    [Authorize(Roles = SystemRoles.Student)]
    public async Task<ActionResult> GetCheckInInfo(
        [FromQuery, MaxLength(64)] string token,
        CancellationToken cancellationToken)
    {
        var studentId = await GetCurrentStudentIdAsync(cancellationToken);
        if (!studentId.HasValue)
            return ConflictProblem("当前账号未关联学生档案。");
        if (string.IsNullOrWhiteSpace(token))
            return NotFound();
        if (!AttendanceCheckInChallenge.TryReadSheetId(token, out var sheetId))
            return NotFound();

        var activity = await db.AttendanceRecords.AsNoTracking()
            .Where(x =>
                x.StudentId == studentId.Value &&
                x.AttendanceSheetId == sheetId)
            .Select(x => new
            {
                SheetId = x.AttendanceSheetId,
                SheetName = x.AttendanceSheet!.Name,
                x.AttendanceSheet.AttendanceDate,
                x.AttendanceSheet.Status,
                x.AttendanceSheet.CheckInMethod,
                x.AttendanceSheet.CheckInStartsAt,
                x.AttendanceSheet.CheckInEndsAt,
                x.AttendanceSheet.CheckInToken,
                CourseCode = x.AttendanceSheet.TeachingTask!.Course!.Code,
                CourseName = x.AttendanceSheet.TeachingTask.Course.Name,
                TaskNumber = x.AttendanceSheet.TeachingTask.TaskNumber,
                x.CheckInAt
            })
            .FirstOrDefaultAsync(cancellationToken);
        if (activity is null) return NotFound();

        var now = DateTime.UtcNow;
        if (activity.CheckInMethod != AttendanceCheckInMethod.QrCode ||
            !AttendanceCheckInChallenge.IsValid(
                token,
                activity.SheetId,
                activity.CheckInToken,
                now))
            return NotFound();
        return Ok(new
        {
            activity.SheetId,
            activity.SheetName,
            activity.AttendanceDate,
            activity.CheckInMethod,
            activity.CheckInStartsAt,
            activity.CheckInEndsAt,
            activity.CourseCode,
            activity.CourseName,
            activity.TaskNumber,
            activity.CheckInAt,
            IsOpen = IsCheckInOpen(
                activity.Status,
                activity.CheckInMethod,
                activity.CheckInStartsAt,
                activity.CheckInEndsAt,
                now)
        });
    }

    [HttpGet("open-check-ins")]
    [Authorize(Roles = SystemRoles.Student)]
    public async Task<ActionResult> GetOpenCheckIns(
        CancellationToken cancellationToken)
    {
        var studentId = await GetCurrentStudentIdAsync(cancellationToken);
        if (!studentId.HasValue)
            return ConflictProblem("当前账号未关联学生档案。");

        var now = DateTime.UtcNow;
        return Ok(await db.AttendanceRecords.AsNoTracking()
            .Where(x =>
                x.StudentId == studentId.Value &&
                x.AttendanceSheet!.Status == AttendanceSheetStatus.Draft &&
                x.AttendanceSheet.CheckInMethod == AttendanceCheckInMethod.Location &&
                x.AttendanceSheet.CheckInStartsAt <= now &&
                x.AttendanceSheet.CheckInEndsAt >= now)
            .OrderBy(x => x.AttendanceSheet!.CheckInEndsAt)
            .Select(x => new
            {
                SheetId = x.AttendanceSheetId,
                SheetName = x.AttendanceSheet!.Name,
                x.AttendanceSheet.AttendanceDate,
                x.AttendanceSheet.CheckInMethod,
                x.AttendanceSheet.CheckInStartsAt,
                x.AttendanceSheet.CheckInEndsAt,
                x.AttendanceSheet.LocationRadiusMeters,
                CourseCode = x.AttendanceSheet.TeachingTask!.Course!.Code,
                CourseName = x.AttendanceSheet.TeachingTask.Course.Name,
                TaskNumber = x.AttendanceSheet.TeachingTask.TaskNumber,
                x.CheckInAt
            })
            .ToListAsync(cancellationToken));
    }

    [HttpPost("check-in")]
    [Authorize(Roles = SystemRoles.Student)]
    public async Task<ActionResult> CheckIn(
        AttendanceCheckInRequest request,
        CancellationToken cancellationToken)
    {
        var studentId = await GetCurrentStudentIdAsync(cancellationToken);
        if (!studentId.HasValue)
            return ConflictProblem("当前账号未关联学生档案。");

        var source = db.AttendanceRecords
            .Include(x => x.AttendanceSheet)
            .Where(x => x.StudentId == studentId.Value);
        if (!string.IsNullOrWhiteSpace(request.Token))
        {
            if (!AttendanceCheckInChallenge.TryReadSheetId(
                    request.Token.Trim(),
                    out var tokenSheetId))
                return NotFound();
            source = source.Where(x => x.AttendanceSheetId == tokenSheetId);
        }
        else if (request.AttendanceSheetId.HasValue)
        {
            source = source.Where(x =>
                x.AttendanceSheetId == request.AttendanceSheetId.Value);
        }
        else
        {
            return ConflictProblem("缺少签到活动信息。");
        }

        var record = await source.FirstOrDefaultAsync(cancellationToken);
        if (record?.AttendanceSheet is null) return NotFound();
        var sheet = record.AttendanceSheet;
        var now = DateTime.UtcNow;

        async Task<ActionResult> RejectAttemptAsync(
            string failureCode,
            string detail,
            double? distanceMeters = null)
        {
            await AddCheckInAttemptAsync(
                sheet,
                studentId.Value,
                request,
                false,
                failureCode,
                distanceMeters,
                now,
                cancellationToken);
            await db.SaveChangesAsync(cancellationToken);
            return ConflictProblem(detail);
        }

        if (sheet.CheckInMethod == AttendanceCheckInMethod.QrCode &&
            !AttendanceCheckInChallenge.IsValid(
                request.Token,
                sheet.Id,
                sheet.CheckInToken,
                now))
            return await RejectAttemptAsync(
                "InvalidQrChallenge",
                "签到二维码已失效，请重新扫描教师当前展示的二维码。");
        if (!IsCheckInOpen(
                sheet.Status,
                sheet.CheckInMethod,
                sheet.CheckInStartsAt,
                sheet.CheckInEndsAt,
                now))
            return await RejectAttemptAsync(
                "CheckInClosed",
                "签到尚未开始或已经结束。");
        if (sheet.CheckInMethod == AttendanceCheckInMethod.Manual)
            return await RejectAttemptAsync(
                "ManualSheet",
                "该考勤表不支持学生在线签到。");
        if (record.CheckInAt.HasValue)
        {
            await AddCheckInAttemptAsync(
                sheet,
                studentId.Value,
                request,
                true,
                null,
                record.CheckInDistanceMeters,
                now,
                cancellationToken);
            await db.SaveChangesAsync(cancellationToken);
            return Ok(new
            {
                AlreadyCheckedIn = true,
                record.CheckInAt,
                record.CheckInDistanceMeters
            });
        }

        double? distanceMeters = null;
        if (sheet.CheckInMethod == AttendanceCheckInMethod.Location)
        {
            if (request.Latitude is < -90 or > 90 ||
                request.Longitude is < -180 or > 180 ||
                request.Latitude is null ||
                request.Longitude is null)
                return await RejectAttemptAsync(
                    "InvalidLocation",
                    "未获取到有效的当前位置。");
            if (sheet.TargetLatitude is null ||
                sheet.TargetLongitude is null ||
                sheet.LocationRadiusMeters is null)
                return await RejectAttemptAsync(
                    "LocationNotConfigured",
                    "签到活动没有配置有效的位置范围。");
            var maximumAllowedAccuracyMeters = Math.Min(
                100d,
                sheet.LocationRadiusMeters.Value);
            if (request.AccuracyMeters is null ||
                !double.IsFinite(request.AccuracyMeters.Value) ||
                request.AccuracyMeters <= 0 ||
                request.AccuracyMeters > maximumAllowedAccuracyMeters)
            {
                return await RejectAttemptAsync(
                    "InsufficientAccuracy",
                    $"当前定位精度不足，请在精度达到 {Math.Round(maximumAllowedAccuracyMeters)} 米以内后重试。");
            }

            distanceMeters = CalculateDistanceMeters(
                (double)sheet.TargetLatitude.Value,
                (double)sheet.TargetLongitude.Value,
                (double)request.Latitude.Value,
                (double)request.Longitude.Value);
            if (distanceMeters > sheet.LocationRadiusMeters.Value)
            {
                return await RejectAttemptAsync(
                    "OutsideGeofence",
                    $"当前位置距签到点约 {Math.Round(distanceMeters.Value)} 米，超出 {sheet.LocationRadiusMeters.Value} 米签到范围。",
                    distanceMeters);
            }
        }

        record.Status = AttendanceStatus.Present;
        record.CheckInAt = now;
        record.CheckedInMethod = sheet.CheckInMethod;
        record.CheckInLatitude = sheet.CheckInMethod == AttendanceCheckInMethod.Location
            ? request.Latitude
            : null;
        record.CheckInLongitude = sheet.CheckInMethod == AttendanceCheckInMethod.Location
            ? request.Longitude
            : null;
        record.CheckInAccuracyMeters = sheet.CheckInMethod == AttendanceCheckInMethod.Location
            ? request.AccuracyMeters
            : null;
        record.CheckInDistanceMeters = distanceMeters;
        await AddCheckInAttemptAsync(
            sheet,
            studentId.Value,
            request,
            true,
            null,
            distanceMeters,
            now,
            cancellationToken);
        await db.SaveChangesAsync(cancellationToken);

        return Ok(new
        {
            AlreadyCheckedIn = false,
            record.CheckInAt,
            record.CheckInDistanceMeters
        });
    }

    [HttpGet("my-records")]
    [Authorize(Roles = SystemRoles.Student)]
    public async Task<ActionResult> GetMyRecords(
        Guid? academicTermId,
        CancellationToken cancellationToken)
    {
        var userId = currentUserDataScope.Current.UserId;
        var studentId = await db.Students
            .Where(s => s.UserId == userId)
            .Select(s => (Guid?)s.Id)
            .FirstOrDefaultAsync(cancellationToken);
        if (!studentId.HasValue)
            return ConflictProblem("当前账号未关联学生档案。");

        var source = db.AttendanceRecords.AsNoTracking()
            .Where(r => r.StudentId == studentId.Value &&
                        r.AttendanceSheet!.Status == AttendanceSheetStatus.Submitted);
        if (academicTermId.HasValue)
            source = source.Where(r =>
                r.AttendanceSheet!.TeachingTask!.AcademicTermId == academicTermId);

        return Ok(await source
            .OrderByDescending(r => r.AttendanceSheet!.AttendanceDate)
            .Select(r => new
            {
                r.AttendanceSheetId,
                r.AttendanceSheet!.TeachingTaskId,
                SheetName = r.AttendanceSheet!.Name,
                r.AttendanceSheet.AttendanceDate,
                TaskNumber = r.AttendanceSheet.TeachingTask!.TaskNumber,
                CourseCode = r.AttendanceSheet.TeachingTask.Course!.Code,
                CourseName = r.AttendanceSheet.TeachingTask.Course.Name,
                TeacherNames = r.AttendanceSheet.TeachingTask.Teachers
                    .OrderByDescending(t => t.IsPrimary)
                    .Select(t => t.Teacher!.Name),
                r.Status,
                r.Notes,
                r.AppealStatus,
                r.AppealReason,
                r.AppealSubmittedAt,
                r.AppealReviewComment,
                r.AppealReviewedAt
            })
            .ToListAsync(cancellationToken));
    }

    [HttpPost("records/appeal")]
    [Authorize(Roles = SystemRoles.Student)]
    public async Task<ActionResult> SubmitAppeal(
        AttendanceAppealRequest request,
        CancellationToken cancellationToken)
    {
        var userId = currentUserDataScope.Current.UserId;
        var studentId = await db.Students
            .Where(s => s.UserId == userId)
            .Select(s => (Guid?)s.Id)
            .FirstOrDefaultAsync(cancellationToken);
        if (!studentId.HasValue)
            return ConflictProblem("当前账号未关联学生档案。");

        var record = await db.AttendanceRecords
            .FirstOrDefaultAsync(r =>
                r.AttendanceSheetId == request.AttendanceSheetId &&
                r.StudentId == studentId.Value,
                cancellationToken);
        if (record is null) return NotFound();
        if (record.AppealStatus == AttendanceAppealStatus.Pending)
            return ConflictProblem("已有申诉正在处理中。");
        if (record.AppealStatus == AttendanceAppealStatus.Approved)
            return ConflictProblem("该考勤记录申诉已通过。");

        record.AppealStatus = AttendanceAppealStatus.Pending;
        record.AppealReason = request.Reason.Trim();
        record.AppealSubmittedAt = DateTime.UtcNow;
        record.AppealReviewComment = null;
        record.AppealReviewedAt = null;
        await db.SaveChangesAsync(cancellationToken);

        // Notify the course teacher
        var teacherUserIds = await db.TeachingTaskTeachers
            .Where(x => x.TeachingTaskId == record.AttendanceSheet!.TeachingTaskId)
            .Select(x => x.Teacher!.UserId)
            .Where(id => id != null)
            .Select(id => id!.Value)
            .ToListAsync(cancellationToken);
        if (teacherUserIds.Count > 0)
        {
            var studentName = await db.Students
                .Where(s => s.Id == studentId.Value)
                .Select(s => s.Name)
                .FirstOrDefaultAsync(cancellationToken);
            var courseName = record.AttendanceSheet!.TeachingTask?.Course?.Name ?? "";
            await NotificationService.SendToUserIdsAsync(db, teacherUserIds,
                "考勤申诉待处理",
                $"学生 {studentName} 对《{courseName}》考勤记录提出申诉。",
                "/teacher-attendance", cancellationToken,
                NotificationCategory.Attendance);
        }

        return NoContent();
    }

    // ═══════════════ Counselor endpoints ═══════════════

    [HttpGet("counselor-records")]
    [Authorize(Roles = SystemRoles.Counselor)]
    public async Task<ActionResult> GetCounselorRecords(
        Guid? academicTermId,
        Guid? classId,
        bool? withAppeal,
        CancellationToken cancellationToken)
    {
        var userId = currentUserDataScope.Current.UserId;
        var classIds = await db.AdministrativeClasses
            .Where(c => c.CounselorUserId == userId)
            .Select(c => c.Id)
            .ToListAsync(cancellationToken);
        if (classIds.Count == 0)
            return ConflictProblem("当前账号未关联任何班级。");

        if (classId.HasValue && !classIds.Contains(classId.Value))
            return ConflictProblem("您不是该班级的辅导员。");

        var targetClassIds = classId.HasValue
            ? [classId.Value]
            : classIds;

        var source = db.AttendanceRecords.AsNoTracking()
            .Where(r =>
                r.AttendanceSheet!.Status == AttendanceSheetStatus.Submitted)
            .WhereIn(targetClassIds, r => r.Student!.AdministrativeClassId);
        if (academicTermId.HasValue)
            source = source.Where(r =>
                r.AttendanceSheet!.TeachingTask!.AcademicTermId == academicTermId);
        if (withAppeal == true)
            source = source.Where(r => r.AppealStatus == AttendanceAppealStatus.Pending);

        return Ok(await source
            .OrderByDescending(r => r.AttendanceSheet!.AttendanceDate)
            .Select(r => new
            {
                r.AttendanceSheetId,
                SheetName = r.AttendanceSheet!.Name,
                r.AttendanceSheet.AttendanceDate,
                TaskNumber = r.AttendanceSheet.TeachingTask!.TaskNumber,
                CourseCode = r.AttendanceSheet.TeachingTask.Course!.Code,
                CourseName = r.AttendanceSheet.TeachingTask.Course.Name,
                StudentNumber = r.Student!.StudentNumber,
                StudentName = r.Student.Name,
                ClassName = r.Student.AdministrativeClass!.Name,
                r.Status,
                r.Notes,
                r.AppealStatus,
                r.AppealReason,
                r.AppealSubmittedAt,
                r.AppealReviewComment
            })
            .ToListAsync(cancellationToken));
    }

    [HttpPost("appeals/{attendanceSheetId:guid}/{studentId:guid}/review")]
    [Authorize(Roles = AttendanceRoles)]
    public async Task<ActionResult> ReviewAppeal(
        Guid attendanceSheetId,
        Guid studentId,
        AttendanceAppealReviewRequest request,
        CancellationToken cancellationToken)
    {
        var record = await db.AttendanceRecords
            .Include(r => r.AttendanceSheet)
            .ThenInclude(s => s!.TeachingTask)
            .ThenInclude(t => t!.Teachers)
            .ThenInclude(x => x.Teacher)
            .FirstOrDefaultAsync(r =>
                r.AttendanceSheetId == attendanceSheetId &&
                r.StudentId == studentId,
                cancellationToken);
        if (record is null) return NotFound();
        if (!CanManageSheet(record.AttendanceSheet!))
            return Forbid();
        if (record.AppealStatus != AttendanceAppealStatus.Pending)
            return ConflictProblem("该申诉不在待处理状态。");

        record.AppealStatus = request.Approve
            ? AttendanceAppealStatus.Approved
            : AttendanceAppealStatus.Rejected;
        record.AppealReviewComment = request.Comment?.Trim();
        record.AppealReviewedAt = DateTime.UtcNow;

        // Update record status on approved appeal
        if (request.Approve)
            record.Status = AttendanceStatus.Excused;

        await db.SaveChangesAsync(cancellationToken);

        // Notify student
        var studentUserId = await db.Students
            .Where(s => s.Id == studentId)
            .Select(s => s.UserId)
            .FirstOrDefaultAsync(cancellationToken);
        if (studentUserId.HasValue)
        {
            var result = request.Approve ? "已通过" : "已驳回";
            await NotificationService.SendAsync(db, studentUserId.Value,
                $"考勤申诉{result}",
                request.Comment is not null
                    ? $"您的考勤申诉{result}。意见：{request.Comment}"
                    : $"您的考勤申诉{result}。",
                null, cancellationToken, NotificationCategory.Attendance);
        }

        return NoContent();
    }

    [HttpDelete("sheets/{id:guid}")]
    [Authorize(Roles = AttendanceRoles)]
    public async Task<ActionResult> Delete(Guid id, CancellationToken cancellationToken)
    {
        var sheet = await db.AttendanceSheets
            .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 (!CanManageSheet(sheet)) return Forbid();
        db.AttendanceSheets.Remove(sheet);
        await db.SaveChangesAsync(cancellationToken);
        return NoContent();
    }

    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))
        {
            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 bool CanManageSheet(AttendanceSheet sheet) =>
        currentUserDataScope.Current.IsInRole(SystemRoles.SuperAdmin) ||
        currentUserDataScope.Current.IsInRole(SystemRoles.AcademicAdmin) ||
        sheet.TeachingTask!.Teachers.Any(x =>
            x.Teacher?.UserId == currentUserDataScope.Current.UserId);

    private async Task<bool> CanManageTaskAsync(
        Guid teachingTaskId,
        CancellationToken cancellationToken)
    {
        var scope = currentUserDataScope.Current;
        if (scope.IsInRole(SystemRoles.SuperAdmin) ||
            scope.IsInRole(SystemRoles.AcademicAdmin))
            return true;

        return await db.TeachingTaskTeachers.AsNoTracking()
            .AnyAsync(
                x => x.TeachingTaskId == teachingTaskId &&
                     x.Teacher!.UserId == scope.UserId,
            cancellationToken);
    }

    private async Task<Guid?> GetCurrentStudentIdAsync(
        CancellationToken cancellationToken)
    {
        var userId = currentUserDataScope.Current.UserId;
        return await db.Students.AsNoTracking()
            .Where(x => x.UserId == userId)
            .Select(x => (Guid?)x.Id)
            .FirstOrDefaultAsync(cancellationToken);
    }

    private async Task AddCheckInAttemptAsync(
        AttendanceSheet sheet,
        Guid studentId,
        AttendanceCheckInRequest request,
        bool isSuccessful,
        string? failureCode,
        double? distanceMeters,
        DateTime now,
        CancellationToken cancellationToken)
    {
        var deviceIdentifierHash = HashDeviceIdentifier(request.DeviceId);
        var riskFlags = new HashSet<string>(StringComparer.Ordinal);
        if (deviceIdentifierHash is null)
        {
            riskFlags.Add("MissingDeviceId");
        }
        else if (await db.AttendanceCheckInAttempts.AsNoTracking().AnyAsync(
                     x =>
                         x.IsSuccessful &&
                         x.StudentId != studentId &&
                         x.DeviceIdentifierHash == deviceIdentifierHash &&
                         x.CreatedAt >= now.AddHours(-24),
                     cancellationToken))
        {
            riskFlags.Add("SharedDevice");
        }

        var recentAttemptCount = await db.AttendanceCheckInAttempts.AsNoTracking()
            .CountAsync(
                x => x.StudentId == studentId &&
                     x.CreatedAt >= now.AddMinutes(-2),
                cancellationToken);
        if (recentAttemptCount >= 5)
            riskFlags.Add("HighFrequency");

        if (!isSuccessful)
        {
            var recentFailureCount = await db.AttendanceCheckInAttempts.AsNoTracking()
                .CountAsync(
                    x => x.StudentId == studentId &&
                         !x.IsSuccessful &&
                         x.CreatedAt >= now.AddMinutes(-5),
                    cancellationToken);
            if (recentFailureCount >= 2)
                riskFlags.Add("RepeatedFailures");
        }

        var context = ControllerContext.HttpContext;
        db.AttendanceCheckInAttempts.Add(new AttendanceCheckInAttempt
        {
            AttendanceSheetId = sheet.Id,
            StudentId = studentId,
            CheckInMethod = sheet.CheckInMethod,
            IsSuccessful = isSuccessful,
            FailureCode = failureCode,
            DeviceIdentifierHash = deviceIdentifierHash,
            DevicePlatform = Limit(Normalize(request.DevicePlatform), 32),
            IpAddress = Limit(
                context?.Connection.RemoteIpAddress?.ToString(),
                64),
            UserAgent = Limit(
                context?.Request.Headers.UserAgent.ToString(),
                500),
            RiskFlags = riskFlags.Count == 0
                ? null
                : string.Join(',', riskFlags.OrderBy(x => x)),
            Latitude = request.Latitude,
            Longitude = request.Longitude,
            AccuracyMeters = request.AccuracyMeters,
            DistanceMeters = distanceMeters,
            CreatedAt = now,
            UpdatedAt = now
        });
    }

    private static string? HashDeviceIdentifier(string? deviceId)
    {
        var normalized = Normalize(deviceId)?.ToLowerInvariant();
        return normalized is null
            ? null
            : Convert.ToHexString(
                SHA256.HashData(Encoding.UTF8.GetBytes(normalized)));
    }

    private static IEnumerable<string> ParseRiskFlags(string? value) =>
        string.IsNullOrWhiteSpace(value)
            ? []
            : value.Split(
                ',',
                StringSplitOptions.RemoveEmptyEntries |
                StringSplitOptions.TrimEntries);

    private static string? Limit(string? value, int maximumLength) =>
        value is null || value.Length <= maximumLength
            ? value
            : value[..maximumLength];

    private static bool IsCheckInOpen(
        AttendanceSheetStatus status,
        AttendanceCheckInMethod method,
        DateTime? startsAt,
        DateTime? endsAt,
        DateTime now) =>
        status == AttendanceSheetStatus.Draft &&
        method != AttendanceCheckInMethod.Manual &&
        startsAt.HasValue &&
        endsAt.HasValue &&
        startsAt.Value <= now &&
        endsAt.Value >= now;

    internal static double CalculateDistanceMeters(
        double latitude1,
        double longitude1,
        double latitude2,
        double longitude2)
    {
        const double earthRadiusMeters = 6_371_000;
        var latitudeDelta = DegreesToRadians(latitude2 - latitude1);
        var longitudeDelta = DegreesToRadians(longitude2 - longitude1);
        var startLatitude = DegreesToRadians(latitude1);
        var endLatitude = DegreesToRadians(latitude2);
        var haversine =
            Math.Sin(latitudeDelta / 2) * Math.Sin(latitudeDelta / 2) +
            Math.Cos(startLatitude) * Math.Cos(endLatitude) *
            Math.Sin(longitudeDelta / 2) * Math.Sin(longitudeDelta / 2);
        return earthRadiusMeters * 2 *
               Math.Atan2(Math.Sqrt(haversine), Math.Sqrt(1 - haversine));
    }

    private static double DegreesToRadians(double degrees) =>
        degrees * Math.PI / 180;

    private async Task<AttendanceCourseStatistics?> LoadTaskStatisticsAsync(
        Guid teachingTaskId,
        CancellationToken cancellationToken)
    {
        var course = await AccessibleTasks().AsNoTracking()
            .Where(x => x.Id == teachingTaskId)
            .Select(x => new AttendanceStatisticsCourse(
                x.Id,
                x.TaskNumber,
                x.Name,
                x.Course!.Code,
                x.Course.Name,
                x.AcademicTerm!.Name))
            .FirstOrDefaultAsync(cancellationToken);
        if (course is null) return null;

        var enrolledStudents = await TeachingTaskRosterQuery
            .ForTask(db, teachingTaskId)
            .AsNoTracking()
            .Select(x => new AttendanceStatisticsStudentIdentity(
                x.Id,
                x.StudentNumber,
                x.Name,
                x.AdministrativeClass!.Name))
            .ToListAsync(cancellationToken);

        var submittedSheets = await db.AttendanceSheets.AsNoTracking()
            .Where(x =>
                x.TeachingTaskId == teachingTaskId &&
                x.Status == AttendanceSheetStatus.Submitted)
            .OrderBy(x => x.AttendanceDate)
            .ThenBy(x => x.Name)
            .Select(x => new AttendanceStatisticsSheet(
                x.Id,
                x.Name,
                x.AttendanceDate))
            .ToListAsync(cancellationToken);

        var records = await db.AttendanceRecords.AsNoTracking()
            .Where(x =>
                x.AttendanceSheet!.TeachingTaskId == teachingTaskId &&
                x.AttendanceSheet.Status == AttendanceSheetStatus.Submitted)
            .Select(x => new AttendanceStatisticsRecord(
                x.AttendanceSheetId,
                x.StudentId,
                x.Student!.StudentNumber,
                x.Student.Name,
                x.Student.AdministrativeClass!.Name,
                x.Status))
            .ToListAsync(cancellationToken);

        var identities = enrolledStudents
            .Concat(records.Select(x => new AttendanceStatisticsStudentIdentity(
                x.StudentId,
                x.StudentNumber,
                x.StudentName,
                x.ClassName)))
            .GroupBy(x => x.StudentId)
            .Select(x => x.First())
            .ToList();

        var recordsByStudent = records
            .GroupBy(x => x.StudentId)
            .ToDictionary(x => x.Key, x => x.ToList());
        var students = identities.Select(identity =>
        {
            var studentRecords = recordsByStudent.GetValueOrDefault(identity.StudentId) ?? [];
            var present = studentRecords.Count(x => x.Status == AttendanceStatus.Present);
            var absent = studentRecords.Count(x => x.Status == AttendanceStatus.Absent);
            var late = studentRecords.Count(x => x.Status == AttendanceStatus.Late);
            var leave = studentRecords.Count(x => x.Status == AttendanceStatus.Leave);
            var excused = studentRecords.Count(x => x.Status == AttendanceStatus.Excused);
            var required = studentRecords.Count - excused;
            return new AttendanceStudentStatistics(
                identity.StudentId,
                identity.StudentNumber,
                identity.StudentName,
                identity.ClassName,
                studentRecords.Count,
                required,
                present,
                absent,
                late,
                leave,
                excused,
                CalculateAttendanceRate(present, late, required));
        })
        .OrderBy(x => x.AttendanceRate ?? decimal.MaxValue)
        .ThenBy(x => x.StudentNumber)
        .ToList();

        var recordsBySheet = records
            .GroupBy(x => x.AttendanceSheetId)
            .ToDictionary(x => x.Key, x => x.ToList());
        var sessions = submittedSheets.Select(sheet =>
        {
            var sheetRecords = recordsBySheet.GetValueOrDefault(sheet.Id) ?? [];
            var present = sheetRecords.Count(x => x.Status == AttendanceStatus.Present);
            var absent = sheetRecords.Count(x => x.Status == AttendanceStatus.Absent);
            var late = sheetRecords.Count(x => x.Status == AttendanceStatus.Late);
            var leave = sheetRecords.Count(x => x.Status == AttendanceStatus.Leave);
            var excused = sheetRecords.Count(x => x.Status == AttendanceStatus.Excused);
            var required = sheetRecords.Count - excused;
            return new AttendanceSessionStatistics(
                sheet.Id,
                sheet.Name,
                sheet.AttendanceDate,
                sheetRecords.Count,
                required,
                present,
                absent,
                late,
                leave,
                excused,
                CalculateAttendanceRate(present, late, required));
        }).ToList();

        var totalPresent = records.Count(x => x.Status == AttendanceStatus.Present);
        var totalAbsent = records.Count(x => x.Status == AttendanceStatus.Absent);
        var totalLate = records.Count(x => x.Status == AttendanceStatus.Late);
        var totalLeave = records.Count(x => x.Status == AttendanceStatus.Leave);
        var totalExcused = records.Count(x => x.Status == AttendanceStatus.Excused);
        var totalRequired = records.Count - totalExcused;
        var summary = new AttendanceStatisticsSummary(
            students.Count,
            sessions.Count,
            records.Count,
            totalRequired,
            CalculateAttendanceRate(totalPresent, totalLate, totalRequired),
            students.Count(x =>
                x.RequiredCount > 0 &&
                x.PresentCount == x.RequiredCount),
            students.Count(x => x.AbsentCount > 0 || x.LateCount > 0));
        var distribution = new[]
        {
            new AttendanceStatusStatistics(
                AttendanceStatus.Present,
                "出勤",
                totalPresent),
            new AttendanceStatusStatistics(
                AttendanceStatus.Absent,
                "缺勤",
                totalAbsent),
            new AttendanceStatusStatistics(
                AttendanceStatus.Late,
                "迟到",
                totalLate),
            new AttendanceStatusStatistics(
                AttendanceStatus.Leave,
                "请假",
                totalLeave),
            new AttendanceStatusStatistics(
                AttendanceStatus.Excused,
                "免修",
                totalExcused)
        };

        return new AttendanceCourseStatistics(
            course,
            summary,
            distribution,
            sessions,
            students);
    }

    private static decimal? CalculateAttendanceRate(
        int present,
        int late,
        int required)
    {
        if (required <= 0) return null;
        return Math.Round(
            (present + late) * 100m / required,
            1,
            MidpointRounding.AwayFromZero);
    }

    private static byte[] CreateStatisticsWorkbook(
        AttendanceCourseStatistics statistics)
    {
        using var workbook = new XLWorkbook();
        var overview = workbook.Worksheets.Add("课程概览");
        overview.Cell("A1").Value = "课程考勤统计";
        overview.Cell("A1").Style.Font.Bold = true;
        overview.Cell("A1").Style.Font.FontSize = 16;
        overview.Cell("A1").Style.Font.FontColor = XLColor.FromHtml("#1F3A6D");
        var overviewRows = new (string Label, object? Value)[]
        {
            ("课程", $"{statistics.Course.CourseCode} {statistics.Course.CourseName}"),
            ("教学任务", statistics.Course.TaskNumber),
            ("学期", statistics.Course.TermName),
            ("学生人数", statistics.Summary.StudentCount),
            ("已提交点名", statistics.Summary.SubmittedSheetCount),
            ("考勤记录", statistics.Summary.RecordCount),
            ("课程总体出勤率", statistics.Summary.OverallAttendanceRate),
            ("全勤人数", statistics.Summary.PerfectAttendanceCount),
            ("存在缺勤或迟到人数", statistics.Summary.AbnormalStudentCount),
            ("统计口径", "仅统计已提交点名；出勤和迟到计为到课，免修不计入应到次数。")
        };
        for (var index = 0; index < overviewRows.Length; index++)
        {
            var row = index + 3;
            overview.Cell(row, 1).Value = overviewRows[index].Label;
            overview.Cell(row, 1).Style.Font.Bold = true;
            if (overviewRows[index].Value is decimal rate)
            {
                overview.Cell(row, 2).Value = rate / 100m;
                overview.Cell(row, 2).Style.NumberFormat.Format = "0.0%";
            }
            else
            {
                overview.Cell(row, 2).Value =
                    overviewRows[index].Value?.ToString() ?? "暂无";
            }
        }
        overview.Column(1).Width = 22;
        overview.Column(2).Width = 58;
        overview.Style.Alignment.Vertical = XLAlignmentVerticalValues.Center;

        var studentSheet = workbook.Worksheets.Add("学生出勤明细");
        var studentHeaders = new[]
        {
            "学号", "姓名", "行政班", "统计次数", "应到次数",
            "出勤", "缺勤", "迟到", "请假", "免修", "出勤率"
        };
        WriteHeader(studentSheet, studentHeaders);
        for (var index = 0; index < statistics.Students.Count; index++)
        {
            var student = statistics.Students[index];
            var row = index + 2;
            studentSheet.Cell(row, 1).Value = student.StudentNumber;
            studentSheet.Cell(row, 2).Value = student.StudentName;
            studentSheet.Cell(row, 3).Value = student.ClassName;
            studentSheet.Cell(row, 4).Value = student.TotalCount;
            studentSheet.Cell(row, 5).Value = student.RequiredCount;
            studentSheet.Cell(row, 6).Value = student.PresentCount;
            studentSheet.Cell(row, 7).Value = student.AbsentCount;
            studentSheet.Cell(row, 8).Value = student.LateCount;
            studentSheet.Cell(row, 9).Value = student.LeaveCount;
            studentSheet.Cell(row, 10).Value = student.ExcusedCount;
            if (student.AttendanceRate.HasValue)
            {
                studentSheet.Cell(row, 11).Value =
                    student.AttendanceRate.Value / 100m;
                studentSheet.Cell(row, 11).Style.NumberFormat.Format = "0.0%";
            }
            else
            {
                studentSheet.Cell(row, 11).Value = "暂无";
            }
        }
        FinishDataSheet(studentSheet, studentHeaders.Length);

        var sessionSheet = workbook.Worksheets.Add("历次点名趋势");
        var sessionHeaders = new[]
        {
            "日期", "点名名称", "记录数", "应到人数",
            "出勤", "缺勤", "迟到", "请假", "免修", "出勤率"
        };
        WriteHeader(sessionSheet, sessionHeaders);
        for (var index = 0; index < statistics.Sessions.Count; index++)
        {
            var session = statistics.Sessions[index];
            var row = index + 2;
            sessionSheet.Cell(row, 1).Value = session.AttendanceDate;
            sessionSheet.Cell(row, 1).Style.DateFormat.Format = "yyyy-mm-dd";
            sessionSheet.Cell(row, 2).Value = session.Name;
            sessionSheet.Cell(row, 3).Value = session.TotalCount;
            sessionSheet.Cell(row, 4).Value = session.RequiredCount;
            sessionSheet.Cell(row, 5).Value = session.PresentCount;
            sessionSheet.Cell(row, 6).Value = session.AbsentCount;
            sessionSheet.Cell(row, 7).Value = session.LateCount;
            sessionSheet.Cell(row, 8).Value = session.LeaveCount;
            sessionSheet.Cell(row, 9).Value = session.ExcusedCount;
            if (session.AttendanceRate.HasValue)
            {
                sessionSheet.Cell(row, 10).Value =
                    session.AttendanceRate.Value / 100m;
                sessionSheet.Cell(row, 10).Style.NumberFormat.Format = "0.0%";
            }
            else
            {
                sessionSheet.Cell(row, 10).Value = "暂无";
            }
        }
        FinishDataSheet(sessionSheet, sessionHeaders.Length);

        using var stream = new MemoryStream();
        workbook.SaveAs(stream);
        return stream.ToArray();
    }

    private static void WriteHeader(
        IXLWorksheet sheet,
        IReadOnlyList<string> headers)
    {
        for (var index = 0; index < headers.Count; index++)
        {
            var cell = sheet.Cell(1, index + 1);
            cell.Value = headers[index];
            cell.Style.Font.Bold = true;
            cell.Style.Font.FontColor = XLColor.White;
            cell.Style.Fill.BackgroundColor = XLColor.FromHtml("#1F3A6D");
            cell.Style.Alignment.Horizontal = XLAlignmentHorizontalValues.Center;
        }
    }

    private static void FinishDataSheet(IXLWorksheet sheet, int columnCount)
    {
        sheet.SheetView.FreezeRows(1);
        sheet.RangeUsed()?.SetAutoFilter();
        sheet.Columns(1, columnCount).AdjustToContents(10, 28);
        sheet.Style.Alignment.Vertical = XLAlignmentVerticalValues.Center;
    }

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

public sealed record AttendanceSheetRequest(
    Guid TeachingTaskId,
    [MaxLength(120)] string Name,
    DateTime AttendanceDate,
    [MaxLength(500)] string? Notes,
    AttendanceCheckInMethod? CheckInMethod,
    [Range(1, 180)] int? CheckInDurationMinutes,
    [Range(-90, 90)] decimal? TargetLatitude,
    [Range(-180, 180)] decimal? TargetLongitude,
    [Range(20, 1000)] int? LocationRadiusMeters);

public sealed record AttendanceRecordsRequest(
    IReadOnlyCollection<AttendanceRecordRequest> Records);

public sealed record AttendanceRecordRequest(
    Guid StudentId,
    AttendanceStatus Status,
    [MaxLength(300)] string? Notes);

public sealed record AttendanceAppealRequest(
    Guid AttendanceSheetId,
    [Required, MaxLength(500)] string Reason);

public sealed record AttendanceAppealReviewRequest(
    bool Approve,
    [MaxLength(300)] string? Comment);

public sealed record AttendanceCheckInRequest(
    Guid? AttendanceSheetId,
    [MaxLength(64)] string? Token,
    [Range(-90, 90)] decimal? Latitude,
    [Range(-180, 180)] decimal? Longitude,
    [Range(0, 5000)] double? AccuracyMeters,
    [MaxLength(128)] string? DeviceId = null,
    [MaxLength(32)] string? DevicePlatform = null);

public sealed record AttendanceCourseStatistics(
    AttendanceStatisticsCourse Course,
    AttendanceStatisticsSummary Summary,
    IReadOnlyList<AttendanceStatusStatistics> StatusDistribution,
    IReadOnlyList<AttendanceSessionStatistics> Sessions,
    IReadOnlyList<AttendanceStudentStatistics> Students);

public sealed record AttendanceStatisticsCourse(
    Guid Id,
    string TaskNumber,
    string TaskName,
    string CourseCode,
    string CourseName,
    string TermName);

public sealed record AttendanceStatisticsSummary(
    int StudentCount,
    int SubmittedSheetCount,
    int RecordCount,
    int RequiredCount,
    decimal? OverallAttendanceRate,
    int PerfectAttendanceCount,
    int AbnormalStudentCount);

public sealed record AttendanceStatusStatistics(
    AttendanceStatus Status,
    string Label,
    int Count);

public sealed record AttendanceSessionStatistics(
    Guid Id,
    string Name,
    DateTime AttendanceDate,
    int TotalCount,
    int RequiredCount,
    int PresentCount,
    int AbsentCount,
    int LateCount,
    int LeaveCount,
    int ExcusedCount,
    decimal? AttendanceRate);

public sealed record AttendanceStudentStatistics(
    Guid StudentId,
    string StudentNumber,
    string StudentName,
    string ClassName,
    int TotalCount,
    int RequiredCount,
    int PresentCount,
    int AbsentCount,
    int LateCount,
    int LeaveCount,
    int ExcusedCount,
    decimal? AttendanceRate);

internal sealed record AttendanceStatisticsStudentIdentity(
    Guid StudentId,
    string StudentNumber,
    string StudentName,
    string ClassName);

internal sealed record AttendanceStatisticsSheet(
    Guid Id,
    string Name,
    DateTime AttendanceDate);

internal sealed record AttendanceStatisticsRecord(
    Guid AttendanceSheetId,
    Guid StudentId,
    string StudentNumber,
    string StudentName,
    string ClassName,
    AttendanceStatus Status);
