import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, In, Between, LessThanOrEqual, MoreThanOrEqual, DataSource } from 'typeorm';
import {
  AttendanceRecord,
  AttendanceSession,
  AttendanceDevice,
  AttendancePeriodConfig,
  DingAttendanceRaw,
  Class,
  Student,
  ClassSchedule,
  ClassStudent,
  ClassTeacher,
  TeacherRoleType,
  ScheduleType,
  StudentDingMapping,
} from '../entities';
import {
  BatchCreateAttendanceDto,
  AttendanceSummaryQueryDto,
  AttendanceCalendarQueryDto,
  QueryDingRawDto,
  MatchDingRecordDto,
  AttendanceReportQueryDto,
  UpdateAttendanceRecordDto,
  GenerateAttendanceFromSchedulesDto,
  GenerateFromSchedulesDto,
  SaveAttendancePeriodConfigsDto,
} from './dto/attendance.dto';

/** Keyed mutex serializing operations on the same attendance session. */
class SessionMutex {
  private queueTails = new Map<number, Promise<void>>();

  async runExclusive<T>(sessionId: number, fn: () => Promise<T>): Promise<T> {
    const tail = this.queueTails.get(sessionId) ?? Promise.resolve();
    let release!: () => void;
    const newTail = new Promise<void>((resolve) => { release = resolve; });
    this.queueTails.set(sessionId, newTail);
    await tail;
    try {
      return await fn();
    } finally {
      release();
      if (this.queueTails.get(sessionId) === newTail) {
        this.queueTails.delete(sessionId);
      }
    }
  }
}
@Injectable()
export class AttendanceService {
  constructor(
    @InjectRepository(AttendanceRecord)
    private attendanceRepo: Repository<AttendanceRecord>,
    @InjectRepository(DingAttendanceRaw)
    private dingRawRepo: Repository<DingAttendanceRaw>,
    @InjectRepository(Class)
    private classRepo: Repository<Class>,
    @InjectRepository(Student)
    private studentRepo: Repository<Student>,
    @InjectRepository(ClassSchedule)
    private scheduleRepo: Repository<ClassSchedule>,
    @InjectRepository(ClassStudent)
    private classStudentRepo: Repository<ClassStudent>,
    @InjectRepository(StudentDingMapping)
    private studentDingMappingRepo: Repository<StudentDingMapping>,
    @InjectRepository(ClassTeacher)
    private classTeacherRepo: Repository<ClassTeacher>,
    @InjectRepository(AttendanceSession)
    private attendanceSessionRepo: Repository<AttendanceSession>,
    @InjectRepository(AttendanceDevice)
    private attendanceDeviceRepo: Repository<AttendanceDevice>,
    @InjectRepository(AttendancePeriodConfig)
    private attendancePeriodConfigRepo: Repository<AttendancePeriodConfig>,
    private dataSource: DataSource,
  ) {}

  private sessionMutex = new SessionMutex();

  private readonly defaultAttendancePeriods = [
    { periodKey: 'morning_reading', label: '早自习', startTime: '07:30', endTime: '08:30', sortOrder: 1 },
    { periodKey: 'morning', label: '早课', startTime: '09:00', endTime: '12:00', sortOrder: 2 },
    { periodKey: 'afternoon', label: '晚课', startTime: '14:00', endTime: '17:00', sortOrder: 3 },
    { periodKey: 'evening_study', label: '晚自习', startTime: '18:30', endTime: '21:00', sortOrder: 4 },
  ] as const;

  private formatDeviceDetail(device: AttendanceDevice): string {
    const classroomName = device.classroom?.name;
    return classroomName ? `${device.deviceName} · ${classroomName}` : device.deviceName;
  }

  private async attachAttendanceDeviceMappings<T extends AttendanceRecord>(
    records: T[],
    classroomId?: number | null,
  ): Promise<T[]> {
    if (records.length === 0) return records;
    const sns = [...new Set(records.map((record) => record.punchDeviceId?.trim()).filter(Boolean) as string[])];
    const devicesBySn = new Map<string, AttendanceDevice>();
    if (sns.length > 0) {
      const devices = await this.attendanceDeviceRepo.find({
        where: { deviceSn: In(sns) },
        relations: ['classroom'],
      });
      for (const device of devices) devicesBySn.set(device.deviceSn, device);
    }

    const classroomIds = [...new Set([
      ...records.map((record) => record.classId).filter((id): id is number => id != null),
      ...(classroomId != null ? [classroomId] : []),
    ])];
    const devicesByClassroom = new Map<number, AttendanceDevice>();
    if (classroomIds.length > 0) {
      const devices = await this.attendanceDeviceRepo.find({
        where: { classroomId: In(classroomIds), status: 'active' },
        relations: ['classroom'],
        order: { id: 'ASC' },
      });
      for (const device of devices) {
        if (!devicesByClassroom.has(device.classroomId)) devicesByClassroom.set(device.classroomId, device);
      }
    }

    for (const record of records) {
      const sn = record.punchDeviceId?.trim();
      const mappedBySn = sn ? devicesBySn.get(sn) : undefined;
      if (mappedBySn) {
        record.punchDeviceName = this.formatDeviceDetail(mappedBySn);
        record.punchDeviceId = mappedBySn.deviceSn;
        continue;
      }
      const source = (record.punchSource || '').trim().toUpperCase();
      const isMachine = ['ATM', 'ATTENDANCE_MACHINE', 'MACHINE', 'DEVICE'].some(
        (value) => source === value || source.includes(value),
      );
      const fallbackClassroomId = record.classId ?? classroomId ?? undefined;
      const mappedByClassroom = fallbackClassroomId ? devicesByClassroom.get(fallbackClassroomId) : undefined;
      if (isMachine && mappedByClassroom && !record.punchDeviceName) {
        record.punchDeviceName = this.formatDeviceDetail(mappedByClassroom);
        record.punchDeviceId = record.punchDeviceId || mappedByClassroom.deviceSn;
      }
    }
    return records;
  }

  async getAccessibleClassIds(userId: number, canManageAll = false): Promise<number[] | undefined> {
    if (canManageAll) return undefined;
    const assignments = await this.classTeacherRepo.find({ where: { userId } });
    return [...new Set(assignments.map((assignment) => assignment.classId))];
  }

  async assertClassAccess(userId: number, classId: number, canManageAll = false): Promise<void> {
    if (canManageAll) return;
    const assignment = await this.classTeacherRepo.findOne({ where: { userId, classId } });
    if (!assignment) throw new BadRequestException('只能访问自己任教班级的考勤');
  }

  private isClassStudentActiveOnDate(classStudent: Pick<ClassStudent, 'joinDate' | 'leaveDate' | 'status'>, lessonDate: string): boolean {
    const status = classStudent.status ?? 'active';
    if (!['active', 'left'].includes(status)) return false;
    if (classStudent.joinDate && classStudent.joinDate > lessonDate) return false;
    if (classStudent.leaveDate && classStudent.leaveDate < lessonDate) return false;
    return true;
  }

  private async getClassStudentsForLesson(
    classId: number,
    lessonDate: string,
    relations: string[] = [],
  ): Promise<ClassStudent[]> {
    const classStudents = await this.classStudentRepo.find({
      where: { classId, status: In(['active', 'left']) },
      relations,
    });
    return classStudents.filter((classStudent) =>
      this.isClassStudentActiveOnDate(classStudent, lessonDate),
    );
  }

  /** List classes the current user may select for DingTalk attendance import. */
  async getImportableClasses(userId: number, isSuperAdmin = false) {
    if (isSuperAdmin) {
      const classes = await this.classRepo.find({
        where: { isArchived: false },
        order: { name: 'ASC' },
      });
      return classes.map((item) => ({ classId: item.id, className: item.name }));
    }

    const assignments = await this.classTeacherRepo.find({
      where: { userId },
      relations: ['class'],
    });
    const classes = new Map<number, string>();
    for (const assignment of assignments) {
      if (assignment.class && !assignment.class.isArchived) {
        classes.set(assignment.classId, assignment.class.name);
      }
    }
    return [...classes.entries()]
      .map(([classId, className]) => ({ classId, className }))
      .sort((left, right) => left.className.localeCompare(right.className, 'zh-CN'));
  }

  /** Resolve the DingTalk users a teacher may import for one assigned class. */
  async getTeacherClassDingUserIds(
    userId: number,
    classId: number,
    isSuperAdmin = false,
    lessonDate?: string,
  ): Promise<string[]> {
    if (!isSuperAdmin) {
      const assignment = await this.classTeacherRepo.findOne({
        where: { userId, classId },
      });
      if (!assignment) {
        throw new BadRequestException('只能拉取自己任教班级的考勤记录');
      }
    } else {
      const cls = await this.classRepo.findOne({ where: { id: classId } });
      if (!cls) throw new NotFoundException(`Class ${classId} not found`);
    }

    const classStudents = lessonDate
      ? await this.getClassStudentsForLesson(classId, lessonDate)
      : await this.classStudentRepo.find({
          where: { classId, status: 'active' },
        });
    const studentIds = [...new Set(classStudents.map((item) => item.studentId))];
    if (studentIds.length === 0) {
      throw new BadRequestException('该班级暂无在读学生');
    }

    const mappings = await this.studentDingMappingRepo.find({
      where: { studentId: In(studentIds) },
    });
    const userIds = [...new Set(mappings.map((mapping) => mapping.dingUserId).filter(Boolean))];
    if (userIds.length === 0) {
      throw new BadRequestException('该班级学生尚未同步钉钉账号');
    }
    return userIds.sort();
  }

  private async getScheduleOccurrence(scheduleId: number, lessonDate: string) {
    const schedule = await this.scheduleRepo.findOne({ where: { id: scheduleId } });
    if (!schedule) throw new NotFoundException('排课记录不存在');
    if (schedule.scheduleType !== ScheduleType.INTERNAL || schedule.status !== 'active') {
      throw new BadRequestException('该排课不能进行课程考勤');
    }
    if (schedule.classId == null) throw new BadRequestException('该排课未关联班级');
    if (lessonDate < schedule.startDate || lessonDate > schedule.endDate) {
      throw new BadRequestException('所选日期不在排课有效期内');
    }
    const date = new Date(`${lessonDate}T00:00:00`);
    const weekDay = date.getDay() === 0 ? 7 : date.getDay();
    if (weekDay !== schedule.weekDay) throw new BadRequestException('所选日期不是该课程的上课日');
    return schedule;
  }

  async getLessonAttendance(scheduleId: number, lessonDate: string) {
    const schedule = await this.getScheduleOccurrence(scheduleId, lessonDate);
    const session = await this.attendanceSessionRepo.findOne({
      where: { scheduleId, lessonDate },
    });
    const records = session
      ? await this.attendanceRepo.find({
          where: { attendanceSessionId: session.id },
          relations: ['student'],
          order: { studentId: 'ASC' },
        })
      : [];
    return { schedule, session, records: await this.attachAttendanceDeviceMappings(records, schedule.classId) };
  }

  private getLessonAttendanceWindow(
    schedule: Pick<ClassSchedule, 'startTime' | 'endTime' | 'attendanceAdvanceMinutes'>,
    lessonDate: string,
  ): { start: number; end: number; dateFrom: string; dateTo: string } {
    const startMinuteOfDay = this.toMinutes(schedule.startTime);
    const endMinuteOfDay = this.toMinutes(schedule.endTime);
    const advanceMinutes = Math.max(0, schedule.attendanceAdvanceMinutes ?? 30);
    const lessonStart = new Date(`${lessonDate}T${schedule.startTime}:00+08:00`).getTime();
    let lessonEnd = new Date(`${lessonDate}T${schedule.endTime}:00+08:00`).getTime();
    const overnight = endMinuteOfDay <= startMinuteOfDay;
    if (overnight) lessonEnd += 24 * 60 * 60 * 1000;

    return {
      start: lessonStart - advanceMinutes * 60 * 1000,
      end: lessonEnd,
      dateFrom: advanceMinutes > startMinuteOfDay ? this.shiftDate(lessonDate, -1) : lessonDate,
      dateTo: overnight ? this.shiftDate(lessonDate, 1) : lessonDate,
    };
  }

  getLessonAttendanceImportDateRange(
    schedule: Pick<ClassSchedule, 'startTime' | 'endTime' | 'attendanceAdvanceMinutes'>,
    lessonDate: string,
  ): { startDate: string; endDate: string } {
    const window = this.getLessonAttendanceWindow(schedule, lessonDate);
    return { startDate: window.dateFrom, endDate: window.dateTo };
  }

  private selectDingTalkRecordsForLesson(
    records: DingAttendanceRaw[],
    schedule: Pick<ClassSchedule, 'startTime' | 'endTime' | 'attendanceAdvanceMinutes'>,
    lessonDate: string,
  ): DingAttendanceRaw[] {
    const window = this.getLessonAttendanceWindow(schedule, lessonDate);
    return records.filter((record) => {
      // 上班、下班打卡都有效，按原始记录中实际存在的时间判断。
      const time = record.checkInTime ?? record.checkOutTime;
      return time && time.getTime() >= window.start && time.getTime() <= window.end;
    });
  }

  private mapDingTalkStatus(records: DingAttendanceRaw[], finalize = false): string {
    const hasPunch = records.some((record) => record.checkInTime || record.checkOutTime);
    if (hasPunch) return 'present';
    return finalize ? 'absent' : 'pending';
  }

  private getLessonPunchMetadata(
    records: DingAttendanceRaw[],
    lessonDate: string,
    startTime: string,
  ): Pick<AttendanceRecord, 'punchTime' | 'punchSource' | 'punchDeviceName' | 'punchDeviceId'> {
    const punches = records
      .map((record) => ({ record, time: record.checkInTime ?? record.checkOutTime }))
      .filter((item): item is { record: DingAttendanceRaw; time: Date } => !!item.time);
    if (punches.length === 0) {
      return {
        punchTime: null,
        punchSource: null,
        punchDeviceName: null,
        punchDeviceId: null,
      };
    }

    const lessonStart = new Date(`${lessonDate}T${startTime}:00+08:00`).getTime();
    punches.sort(
      (left, right) =>
        Math.abs(left.time.getTime() - lessonStart) - Math.abs(right.time.getTime() - lessonStart),
    );
    const primary = punches[0];
    const metadataRecord = [...punches]
      .filter(({ record }) =>
        !!(record.punchSource || record.punchDeviceName || record.punchDeviceId) ||
        !['OnDuty', 'OffDuty'].includes(record.attendanceType),
      )
      .sort(
        (left, right) =>
          Math.abs(left.time.getTime() - primary.time.getTime()) -
          Math.abs(right.time.getTime() - primary.time.getTime()),
      )[0]?.record;
    const source =
      metadataRecord?.punchSource ||
      (metadataRecord && !['OnDuty', 'OffDuty'].includes(metadataRecord.attendanceType)
        ? metadataRecord.attendanceType
        : primary.record.punchSource);

    return {
      punchTime: primary.time,
      punchSource: source || null,
      punchDeviceName: metadataRecord?.punchDeviceName || primary.record.punchDeviceName || null,
      punchDeviceId: metadataRecord?.punchDeviceId || primary.record.punchDeviceId || null,
    };
  }
  async createLessonAttendanceFromDingTalk(
    scheduleId: number,
    lessonDate: string,
    userId: number,
    finalize = false,
  ) {
    const schedule = await this.getScheduleOccurrence(scheduleId, lessonDate);
    const now = new Date();
    const courseClock = this.getCourseClock(now);
    const today = courseClock.date;
    if (lessonDate > today) throw new BadRequestException('课程尚未开始，不能拉取考勤');
    if (lessonDate === today) {
      const [hour, minute] = schedule.startTime.split(':').map(Number);
      const startMinute = hour * 60 + minute;
      const currentMinute = courseClock.minutes;
      if (currentMinute < startMinute) {
        throw new BadRequestException('课程尚未开始，不能拉取考勤');
      }
    }

    const existing = await this.attendanceSessionRepo.findOne({
      where: { scheduleId, lessonDate },
    });

    if (existing) {
      if (
        existing.status !== 'in_progress' &&
        existing.status !== 'completed' &&
        !(finalize && existing.status === 'settling')
      ) {
        throw new BadRequestException('课程考勤正在结算');
      }

      // Refresh latest DingTalk data even after automatic settlement; late-arriving punches
      // may legitimately change a DingTalk-generated absence to present.
      return this.dataSource.transaction(async (manager) => {
        const sessionRepo = manager.getRepository(AttendanceSession);
        const recordRepo = manager.getRepository(AttendanceRecord);
        const rawByStudent = await this.fetchDingTalkRawByStudent(schedule.classId!, schedule, lessonDate);
        const effectiveFinalize = finalize || existing.status === 'completed';
        const existingRecords = await recordRepo.find({
          where: { attendanceSessionId: existing.id },
          order: { studentId: 'ASC' },
        });
        const classStudents = await this.getClassStudentsForLesson(
          schedule.classId!,
          lessonDate,
          ['student'],
        );
        const studentsById = new Map(
          classStudents.map((classStudent) => [classStudent.studentId, classStudent.student]),
        );
        const existingStudentIds = new Set(existingRecords.map((record) => record.studentId));

        const lessonSessionKey = this.mapLessonScheduleTimeToSession(schedule.startTime);
        const updatedRecords = existingRecords.map((record) => {
          record.student = studentsById.get(record.studentId)!;
          // Preserve manual corrections only while the lesson is still in progress.
          if (!finalize && record.source !== 'dingtalk') return record;

          const raw = this.selectDingTalkRecordsForLesson(
            rawByStudent.get(record.studentId) ?? [],
            schedule,
            lessonDate,
          );
          record.status = this.mapDingTalkStatus(raw, effectiveFinalize);
          Object.assign(record, this.getLessonPunchMetadata(
              raw,
              lessonDate,
              schedule.startTime,
            ));
          record.remark = raw.some((item) => item.checkInTime || item.checkOutTime)
            ? null
            : effectiveFinalize
              ? '课程截止仍未打卡'
              : '未获取到钉钉打卡结果';
          return record;
        });
        for (const classStudent of classStudents) {
          if (existingStudentIds.has(classStudent.studentId)) continue;
          const raw = this.selectDingTalkRecordsForLesson(
            rawByStudent.get(classStudent.studentId) ?? [],
            schedule,
            lessonDate,
          );
          updatedRecords.push(
            recordRepo.create({
              studentId: classStudent.studentId,
              student: classStudent.student,
              classId: schedule.classId!,
              scheduleId,
              attendanceSessionId: existing.id,
              attendanceDate: lessonDate,
              session: lessonSessionKey,
              status: this.mapDingTalkStatus(raw, effectiveFinalize),
              source: 'dingtalk',
              ...this.getLessonPunchMetadata(
                raw,
                lessonDate,
                schedule.startTime,
              ),
              remark: raw.some((item) => item.checkInTime || item.checkOutTime)
                ? undefined
                : effectiveFinalize
                  ? '课程截止仍未打卡'
                  : '未获取到钉钉打卡结果',
            }),
          );
        }

        const saved = await recordRepo.save(updatedRecords);
        if (finalize) {
          existing.status = 'completed';
          existing.completedBy = userId;
          existing.completedAt = new Date();
          await sessionRepo.save(existing);
        }
        return { schedule, session: existing, records: await this.attachAttendanceDeviceMappings(saved, schedule.classId) };
      });
    }

    // First pull: create session and records atomically
    return this.dataSource.transaction(async (manager) => {
      const sessionRepo = manager.getRepository(AttendanceSession);
      const recordRepo = manager.getRepository(AttendanceRecord);
      const rawByStudent = await this.fetchDingTalkRawByStudent(schedule.classId!, schedule, lessonDate);

      const classStudents = await this.getClassStudentsForLesson(
        schedule.classId!,
        lessonDate,
        ['student'],
      );
      if (classStudents.length === 0) throw new BadRequestException('该班级暂无在读学生');

      let session: AttendanceSession;
      try {
        session = await sessionRepo.save(
          sessionRepo.create({
            scheduleId,
            classId: schedule.classId!,
            lessonDate,
            status: 'in_progress',
            startedBy: userId,
            startedAt: new Date(),
          }),
        );
      } catch (err: unknown) {
        const code = (err as Record<string, unknown>).code;
        const errno = (err as Record<string, unknown>).errno;
        // MySQL: ER_DUP_ENTRY or errno 1062; SQLite: SQLITE_CONSTRAINT
        if (code === 'ER_DUP_ENTRY' || errno === 1062 || code === 'SQLITE_CONSTRAINT') {
          const existing = await sessionRepo.findOne({
            where: { scheduleId, lessonDate },
          });
          if (existing) {
            session = existing;
            const existingRecords = await recordRepo.find({
              where: { attendanceSessionId: session.id },
              relations: ['student'],
              order: { studentId: 'ASC' },
            });
            return { schedule, session, records: await this.attachAttendanceDeviceMappings(existingRecords, schedule.classId) };
          }
        }
        throw err;
      }

      const lessonSessionKey = this.mapLessonScheduleTimeToSession(schedule.startTime);
      const records = classStudents.map((classStudent) => {
        const raw = this.selectDingTalkRecordsForLesson(
          rawByStudent.get(classStudent.studentId) ?? [],
          schedule,
          lessonDate,
        );
        return recordRepo.create({
          studentId: classStudent.studentId,
          student: classStudent.student,
          classId: schedule.classId!,
          scheduleId,
          attendanceSessionId: session.id,
          attendanceDate: lessonDate,
          session: lessonSessionKey,
          status: this.mapDingTalkStatus(raw, finalize),
          source: 'dingtalk',
          ...this.getLessonPunchMetadata(
            raw,
            lessonDate,
            schedule.startTime,
          ),
          remark: raw.some((item) => item.checkInTime || item.checkOutTime)
            ? undefined
            : finalize
              ? '课程截止仍未打卡'
              : '未获取到钉钉打卡结果',
        });
      });
      const saved = await recordRepo.save(records);
      if (finalize) {
        session.status = 'completed';
        session.completedBy = userId;
        session.completedAt = new Date();
        session = await sessionRepo.save(session);
      }
      return { schedule, session, records: await this.attachAttendanceDeviceMappings(saved, schedule.classId) };
    });
  }

  private async fetchDingTalkRawByStudent(
    classId: number,
    schedule: Pick<ClassSchedule, 'startTime' | 'endTime' | 'attendanceAdvanceMinutes'>,
    lessonDate: string,
  ): Promise<Map<number, DingAttendanceRaw[]>> {
    const classStudents = await this.getClassStudentsForLesson(classId, lessonDate);
    if (classStudents.length === 0) return new Map();
    const studentIds = classStudents.map((cs) => cs.studentId);
    const window = this.getLessonAttendanceWindow(schedule, lessonDate);
    const rawRecords = await this.dingRawRepo.find({
      where: {
        attendanceDate: Between(window.dateFrom, window.dateTo),
        matchedStudentId: In(studentIds),
      },
    });
    const rawByStudent = new Map<number, DingAttendanceRaw[]>();
    for (const raw of rawRecords) {
      if (raw.matchedStudentId == null) continue;
      const arr = rawByStudent.get(raw.matchedStudentId) ?? [];
      arr.push(raw);
      rawByStudent.set(raw.matchedStudentId, arr);
    }
    return rawByStudent;
  }

  async completeLessonAttendance(sessionId: number, userId: number) {
    return this.sessionMutex.runExclusive(sessionId, () =>
      this.dataSource.transaction(async (manager) => {
        const sessionRepo = manager.getRepository(AttendanceSession);
        const recordRepo = manager.getRepository(AttendanceRecord);

        const session = await sessionRepo.findOne({ where: { id: sessionId } });
        if (!session) throw new NotFoundException('课程考勤场次不存在');

        // Re-check under lock: if already completed, return current state idempotently
        if (session.status === 'completed') {
          const records = await recordRepo.find({
            where: { attendanceSessionId: sessionId },
            relations: ['student'],
            order: { studentId: 'ASC' },
          });
          return { session, records: await this.attachAttendanceDeviceMappings(records, session.classId) };
        }

        const pendingRecords = await recordRepo.count({
          where: { attendanceSessionId: sessionId, status: 'pending' },
        });
        if (pendingRecords > 0) {
          throw new BadRequestException('存在未处理的考勤记录，无法完成考勤');
        }

        session.status = 'completed';
        session.completedBy = userId;
        session.completedAt = new Date();
        const savedSession = await sessionRepo.save(session);
        const records = await recordRepo.find({
          where: { attendanceSessionId: sessionId },
          relations: ['student'],
          order: { studentId: 'ASC' },
        });
        return { session: savedSession, records: await this.attachAttendanceDeviceMappings(records, session.classId) };
      }),
    );
  }

  async findAttendanceSession(id: number) {
    const session = await this.attendanceSessionRepo.findOne({ where: { id } });
    if (!session) throw new NotFoundException('课程考勤场次不存在');
    return session;
  }

  // ── Batch create attendance records ──
  async batchCreate(dto: BatchCreateAttendanceDto) {
    if (!dto.records || dto.records.length === 0) {
      throw new BadRequestException('records array must not be empty');
    }

    const entities = dto.records.map((r) => {
      const entity = this.attendanceRepo.create({
        studentId: r.studentId,
        classId: r.classId ?? undefined,
        attendanceDate: r.attendanceDate,
        session: r.session,
        status: r.status,
        remark: r.remark,
        source: r.source || 'manual',
      });
      return entity;
    });

    const saved = await this.attendanceRepo.save(entities);
    return { count: saved.length, records: saved };
  }

  // ── Generate attendance records from class schedules ──
  async generateAttendanceFromSchedules(dto: GenerateAttendanceFromSchedulesDto) {
    const { classId, dateFrom, dateTo } = dto;

    if (dateFrom > dateTo) {
      throw new BadRequestException('dateFrom must not be later than dateTo');
    }

    const cls = await this.classRepo.findOne({ where: { id: classId } });
    if (!cls) {
      throw new NotFoundException(`Class ${classId} not found`);
    }

    const schedules = await this.scheduleRepo.find({
      where: {
        classId,
        scheduleType: ScheduleType.INTERNAL,
        status: 'active',
        startDate: LessThanOrEqual(dateTo),
        endDate: MoreThanOrEqual(dateFrom),
      },
    });

    const classStudents = await this.classStudentRepo.find({
      where: { classId, status: In(['active', 'left']) },
      relations: ['student'],
    });

    if (schedules.length === 0 || classStudents.length === 0) {
      return { count: 0, records: [] };
    }

    const existingRecords = await this.attendanceRepo.find({
      where: { classId, attendanceDate: Between(dateFrom, dateTo) },
    });
    const existingKeys = new Set(
      existingRecords.map((r) => `${r.studentId}|${r.attendanceDate}|${r.session}`),
    );

    const entities: AttendanceRecord[] = [];
    const end = new Date(dateTo);
    for (let d = new Date(dateFrom); d <= end; d.setDate(d.getDate() + 1)) {
      const dateStr = d.toISOString().slice(0, 10);
      const weekDay = d.getDay() === 0 ? 7 : d.getDay();

      for (const sched of schedules) {
        if (sched.weekDay !== weekDay) continue;
        if (dateStr < sched.startDate || dateStr > sched.endDate) continue;

        const session = await this.mapScheduleTimeToSession(sched.startTime);
        const classStudentsForDate = classStudents.filter((cs) =>
          this.isClassStudentActiveOnDate(cs, dateStr),
        );
        for (const cs of classStudentsForDate) {
          const key = `${cs.studentId}|${dateStr}|${session}`;
          if (existingKeys.has(key)) continue;

          const entity = this.attendanceRepo.create({
            studentId: cs.studentId,
            classId,
            attendanceDate: dateStr,
            session,
            status: 'pending',
            source: 'schedule',
          });
          entities.push(entity);
          existingKeys.add(key);
        }
      }
    }

    const saved = await this.attendanceRepo.save(entities);
    return { count: saved.length, records: saved };
  }

  // ── Generate attendance records from schedules (optional date range, defaults to current week) ──
  async generateFromSchedules(
    dto: GenerateFromSchedulesDto,
  ): Promise<{ count: number; records: AttendanceRecord[] }> {
    const { classId, startDate, endDate } = dto;

    // Default to current week (Monday–Sunday)
    const now = new Date();
    const dayOfWeek = now.getDay();
    const mondayOffset = dayOfWeek === 0 ? -6 : 1 - dayOfWeek;
    const monday = new Date(now);
    monday.setDate(now.getDate() + mondayOffset);
    monday.setHours(0, 0, 0, 0);
    const sunday = new Date(monday);
    sunday.setDate(monday.getDate() + 6);
    sunday.setHours(23, 59, 59, 999);

    const dateFrom = startDate ?? monday.toISOString().slice(0, 10);
    const dateTo = endDate ?? sunday.toISOString().slice(0, 10);

    return this.generateAttendanceFromSchedules({
      classId,
      dateFrom,
      dateTo,
    });
  }

  private toMinutes(time: string): number {
    const [hour, minute] = time.split(':').map(Number);
    return hour * 60 + minute;
  }

  private getCourseClock(date: Date): { date: string; minutes: number } {
    const parts = Object.fromEntries(
      new Intl.DateTimeFormat('en-CA', {
        timeZone: 'Asia/Shanghai',
        year: 'numeric', month: '2-digit', day: '2-digit',
        hour: '2-digit', minute: '2-digit', hourCycle: 'h23',
      }).formatToParts(date).filter((part) => part.type !== 'literal').map((part) => [part.type, part.value]),
    );
    return {
      date: `${parts.year}-${parts.month}-${parts.day}`,
      minutes: Number(parts.hour) * 60 + Number(parts.minute),
    };
  }

  private shiftDate(date: string, days: number): string {
    const shifted = new Date(`${date}T00:00:00.000Z`);
    shifted.setUTCDate(shifted.getUTCDate() + days);
    return shifted.toISOString().slice(0, 10);
  }

  private async ensureAttendancePeriodConfigs() {
    const count = await this.attendancePeriodConfigRepo.count();
    if (count === 0) {
      await this.attendancePeriodConfigRepo.save(
        this.defaultAttendancePeriods.map((period) => this.attendancePeriodConfigRepo.create({
          ...period,
          enabled: true,
        })),
      );
    }
    return this.attendancePeriodConfigRepo.find({ order: { sortOrder: 'ASC', id: 'ASC' } });
  }

  async getAttendancePeriodConfigs() {
    return this.ensureAttendancePeriodConfigs();
  }

  async getRefreshableSchedules(date: string, classId?: number, session?: string, accessibleClassIds?: number[]) {
    const parsedDate = new Date(`${date}T00:00:00`);
    if (Number.isNaN(parsedDate.getTime())) throw new BadRequestException('无效日期');
    const weekDay = parsedDate.getDay() === 0 ? 7 : parsedDate.getDay();
    const qb = this.scheduleRepo
      .createQueryBuilder('schedule')
      .where('schedule.scheduleType = :scheduleType', { scheduleType: ScheduleType.INTERNAL })
      .andWhere('schedule.status = :status', { status: 'active' })
      .andWhere('schedule.classId IS NOT NULL')
      .andWhere('schedule.weekDay = :weekDay', { weekDay })
      .andWhere('schedule.startDate <= :date', { date })
      .andWhere('schedule.endDate >= :date', { date });

    if (classId) {
      qb.andWhere('schedule.classId = :classId', { classId });
    } else if (accessibleClassIds) {
      if (accessibleClassIds.length === 0) return [];
      qb.andWhere('schedule.classId IN (:...accessibleClassIds)', { accessibleClassIds });
    }

    const schedules = await qb.orderBy('schedule.startTime', 'ASC').getMany();
    if (!session) return schedules;

    const matchedSchedules: ClassSchedule[] = [];
    for (const schedule of schedules) {
      if ((await this.mapScheduleTimeToSession(schedule.startTime)) === session) {
        matchedSchedules.push(schedule);
      }
    }
    return matchedSchedules;
  }

  async saveAttendancePeriodConfigs(dto: SaveAttendancePeriodConfigsDto) {
    const seen = new Set<string>();
    const normalized = dto.periods.map((period, index) => {
      const periodKey = period.periodKey.trim();
      const label = period.label.trim();
      if (!periodKey || !label) throw new BadRequestException('时段标识和名称不能为空');
      if (seen.has(periodKey)) throw new BadRequestException(`时段标识 ${periodKey} 重复`);
      seen.add(periodKey);
      if (this.toMinutes(period.endTime) <= this.toMinutes(period.startTime)) {
        throw new BadRequestException(`${label} 的结束时间必须晚于开始时间`);
      }
      return {
        periodKey,
        label,
        startTime: period.startTime,
        endTime: period.endTime,
        sortOrder: period.sortOrder ?? index + 1,
        enabled: period.enabled ?? true,
      };
    }).sort((left, right) => left.sortOrder - right.sortOrder);

    // 按开始时间排序后再检查重叠，避免 sortOrder 与时间顺序不一致时漏检
    const sortedByTime = [...normalized].sort(
      (left, right) => this.toMinutes(left.startTime) - this.toMinutes(right.startTime),
    );
    for (let index = 1; index < sortedByTime.length; index += 1) {
      const previous = sortedByTime[index - 1];
      const current = sortedByTime[index];
      if (previous.enabled && current.enabled && this.toMinutes(current.startTime) < this.toMinutes(previous.endTime)) {
        throw new BadRequestException(`${previous.label} 和 ${current.label} 时间段不能重叠`);
      }
    }

    await this.attendancePeriodConfigRepo.clear();
    await this.attendancePeriodConfigRepo.save(
      normalized.map((period) => this.attendancePeriodConfigRepo.create(period)),
    );
    return this.getAttendancePeriodConfigs();
  }

  async resetAttendancePeriodConfigs() {
    await this.attendancePeriodConfigRepo.clear();
    return this.ensureAttendancePeriodConfigs();
  }

  private mapLessonScheduleTimeToSession(startTime: string): string {
    const hour = parseInt(startTime.slice(0, 2), 10);
    if (hour < 8) return 'morning_reading';
    if (hour < 12) return 'morning';
    if (hour < 17) return 'afternoon';
    if (hour < 20) return 'evening_study';
    return 'night_check';
  }

  private async mapScheduleTimeToSession(startTime: string): Promise<string> {
    const startMinutes = this.toMinutes(startTime);
    const periods = (await this.ensureAttendancePeriodConfigs()).filter((period) => period.enabled);
    const matched = periods.find((period) => {
      const periodStart = this.toMinutes(period.startTime);
      const periodEnd = this.toMinutes(period.endTime);
      return startMinutes >= periodStart && startMinutes < periodEnd;
    });
    if (matched) return matched.periodKey;
    throw new BadRequestException(`课程开始时间 ${startTime} 未匹配到考勤时段，请先配置考勤时段`);
  }


  // ── Attendance summary ──
  async getSummary(query: AttendanceSummaryQueryDto, accessibleClassIds?: number[]) {
    const qb = this.attendanceRepo.createQueryBuilder('ar');
    if (query.classId) {
      qb.andWhere('ar.classId = :classId', { classId: query.classId });
    }
    if (query.scheduleId) {
      qb.andWhere('ar.scheduleId = :scheduleId', { scheduleId: query.scheduleId });
    }
    if (!query.classId && accessibleClassIds) {
      if (accessibleClassIds.length === 0)
        return { total: 0, present: 0, late: 0, absent: 0, leave: 0, pending: 0, presentRate: 0 };
      qb.andWhere('ar.classId IN (:...accessibleClassIds)', { accessibleClassIds });
    }
    if (query.dateFrom) {
      qb.andWhere('ar.attendanceDate >= :dateFrom', { dateFrom: query.dateFrom });
    }
    if (query.dateTo) {
      qb.andWhere('ar.attendanceDate <= :dateTo', { dateTo: query.dateTo });
    }
    if (query.session) {
      qb.andWhere('ar.session = :session', { session: query.session });
    }

    const rows = await qb.getMany();

    const total = rows.length;
    const present = rows.filter((r) => r.status === 'present').length;
    const late = rows.filter((r) => r.status === 'late').length;
    const absent = rows.filter((r) => r.status === 'absent').length;
    const leave = rows.filter((r) => r.status === 'leave').length;
    const pending = rows.filter((r) => r.status === 'pending').length;
    const presentRate = total > 0 ? Number(((present / total) * 100).toFixed(1)) : 0;

    return { total, present, late, absent, leave, pending, presentRate };
  }

  // ── Attendance calendar ──
  async getCalendar(query: AttendanceCalendarQueryDto) {
    const { classId, weekStart } = query;

    if (!weekStart) {
      // Default to the Monday of the current week
      const now = new Date();
      const day = now.getDay();
      const diff = day === 0 ? -6 : 1 - day; // Monday offset
      const monday = new Date(now);
      monday.setDate(now.getDate() + diff);
      const mondayStr = monday.toISOString().slice(0, 10);

      return this.buildCalendar(classId, mondayStr);
    }

    return this.buildCalendar(classId, weekStart);
  }

  private getWeekDayForDate(date: string): number {
    const day = new Date(`${date}T00:00:00+08:00`).getUTCDay();
    return day === 0 ? 7 : day;
  }

  async getScheduleOptionsForAttendance(classId: number, date: string) {
    const weekDay = this.getWeekDayForDate(date);
    const { entities, raw } = await this.scheduleRepo
      .createQueryBuilder('cs')
      .leftJoin('cs.teacher', 'teacher')
      .addSelect('cs.id', 'scheduleIdForTeacherMap')
      .addSelect('teacher.username', 'teacherUsername')
      .addSelect('teacher.name', 'teacherName')
      .where('cs.classId = :classId', { classId })
      .andWhere('cs.weekDay = :weekDay', { weekDay })
      .andWhere('cs.startDate <= :date', { date })
      .andWhere('cs.endDate >= :date', { date })
      .andWhere('cs.status = :status', { status: 'active' })
      .orderBy('cs.startTime', 'ASC')
      .addOrderBy('cs.subject', 'ASC')
      .getRawAndEntities();

    const teacherByScheduleId = new Map(
      raw.map((row) => [
        Number(row.scheduleIdForTeacherMap),
        {
          teacherName: row.teacherName || null,
          teacherUsername: row.teacherUsername || null,
        },
      ]),
    );

    return entities.map((schedule) => {
      const teacher = teacherByScheduleId.get(schedule.id) ?? {
        teacherName: null,
        teacherUsername: null,
      };
      return { ...schedule, ...teacher };
    });
  }

  private async buildCalendar(classId: number, weekStart: string) {
    // Compute weekEnd (Sunday = weekStart + 6 days)
    const start = new Date(weekStart);
    const end = new Date(start);
    end.setDate(start.getDate() + 6);
    const endStr = end.toISOString().slice(0, 10);

    // Fetch attendance records for the week
    const records = await this.attendanceRepo.find({
      where: {
        classId,
        attendanceDate: Between(weekStart, endStr),
      },
      relations: ['student'],
      order: { attendanceDate: 'ASC', session: 'ASC' },
    });

    // Group by studentId
    const studentMap = new Map<
      number,
      {
        studentId: number;
        studentName: string;
        days: Array<{ date: string; session: string; status: string }>;
      }
    >();

    for (const r of records) {
      if (!studentMap.has(r.studentId)) {
        studentMap.set(r.studentId, {
          studentId: r.studentId,
          studentName: r.student?.name ?? `Student#${r.studentId}`,
          days: [],
        });
      }
      studentMap.get(r.studentId)!.days.push({
        date: r.attendanceDate,
        session: r.session,
        status: r.status,
      });
    }

    return Array.from(studentMap.values());
  }

  // ── List attendance records with filters ──
  async findAll(
    query: {
      classId?: number;
      scheduleId?: number;
      dateFrom?: string;
      dateTo?: string;
      session?: string;
      status?: string;
      source?: string;
      page?: number;
      pageSize?: number;
    },
    accessibleClassIds?: number[],
  ) {
    const page = query.page || 1;
    const pageSize = query.pageSize || 20;

    const qb = this.attendanceRepo.createQueryBuilder('ar');

    qb.leftJoinAndSelect('ar.student', 'student').leftJoinAndSelect('ar.class', 'class');
    if (query.scheduleId) {
      qb.andWhere('ar.scheduleId = :scheduleId', { scheduleId: query.scheduleId });
    }
    if (query.classId) {
      qb.andWhere('ar.classId = :classId', { classId: query.classId });
    } else if (accessibleClassIds) {
      if (accessibleClassIds.length === 0) return { list: [], total: 0, page, pageSize };
      qb.andWhere('ar.classId IN (:...accessibleClassIds)', { accessibleClassIds });
    }
    if (query.dateFrom) {
      qb.andWhere('ar.attendanceDate >= :dateFrom', { dateFrom: query.dateFrom });
    }
    if (query.dateTo) {
      qb.andWhere('ar.attendanceDate <= :dateTo', { dateTo: query.dateTo });
    }
    if (query.session) {
      qb.andWhere('ar.session = :session', { session: query.session });
    }
    if (query.status) {
      qb.andWhere('ar.status = :status', { status: query.status });
    }
    if (query.source) {
      qb.andWhere('ar.source = :source', { source: query.source });
    }

    qb.orderBy('ar.attendanceDate', 'DESC').addOrderBy('ar.createdAt', 'DESC');
    qb.skip((page - 1) * pageSize).take(pageSize);

    const [list, total] = await qb.getManyAndCount();
    return { list: await this.attachAttendanceDeviceMappings(list), total, page, pageSize };
  }

  // ── Get distinct classes with attendance records ──
  async getClasses(accessibleClassIds?: number[]) {
    const qb = this.attendanceRepo
      .createQueryBuilder('ar')
      .select('DISTINCT ar.classId', 'classId')
      .where('ar.classId IS NOT NULL');

    const rows = accessibleClassIds
      ? accessibleClassIds.map((classId) => ({ classId }))
      : await qb.orderBy('ar.classId', 'ASC').getRawMany();

    const classIds = [...new Set(rows.map((r) => Number(r.classId)).filter(Boolean))];
    if (classIds.length === 0) return [];

    const where = { id: In(classIds) };
    const [classes, teachers] = await Promise.all([
      this.classRepo.find({ where }),
      this.classTeacherRepo.find({
        where: {
          classId: In(classIds),
          roleType: In([
            TeacherRoleType.HEAD_TEACHER,
            TeacherRoleType.LIFE_TEACHER,
            TeacherRoleType.SUBJECT_TEACHER,
          ]),
        },
        relations: ['user'],
        order: { roleType: 'ASC', id: 'ASC' },
      }),
    ]);
    const nameMap = new Map(classes.map((c) => [c.id, c.name]));
    const teacherMap = new Map<
      number,
      Array<{
        userId: number;
        username: string | null;
        name: string | null;
        roleType: string;
        subject: string | null;
      }>
    >();

    for (const teacher of teachers) {
      const user = teacher.user as { username?: string | null; name?: string | null } | undefined;
      const items = teacherMap.get(teacher.classId) ?? [];
      items.push({
        userId: teacher.userId,
        username: user?.username || null,
        name: user?.name || null,
        roleType: teacher.roleType,
        subject: teacher.subject || null,
      });
      teacherMap.set(teacher.classId, items);
    }

    return classIds.map((id) => ({
      classId: id,
      className: nameMap.get(id) || `班级${id}`,
      teachers: teacherMap.get(id) ?? [],
    }));
  }

  // ── DingAttendance raw records ──
  async getDingRaw(query: QueryDingRawDto, accessibleClassIds?: number[]) {
    const page = query.page || 1;
    const pageSize = query.pageSize || 20;
    const qb = this.dingRawRepo.createQueryBuilder('ar');

    qb.leftJoinAndSelect('ar.matchedStudent', 'matchedStudent');
    if (query.matchStatus) {
      qb.andWhere('ar.matchStatus = :matchStatus', { matchStatus: query.matchStatus });
    }
    if (query.dateFrom) {
      qb.andWhere('ar.attendanceDate >= :dateFrom', { dateFrom: query.dateFrom });
    }
    if (query.dateTo) {
      qb.andWhere('ar.attendanceDate <= :dateTo', { dateTo: query.dateTo });
    }
    const scopedClassIds = query.classId ? [query.classId] : accessibleClassIds;
    if (scopedClassIds) {
      if (scopedClassIds.length === 0) return { list: [], total: 0, page, pageSize };
      const classStudents = await this.classStudentRepo.find({
        where: { classId: In(scopedClassIds), status: 'active' },
      });
      const studentIds = [...new Set(classStudents.map((item) => item.studentId))];
      if (studentIds.length === 0) return { list: [], total: 0, page, pageSize };
      const mappings = await this.studentDingMappingRepo.find({
        where: { studentId: In(studentIds) },
      });
      const dingUserIds = [
        ...new Set(mappings.map((mapping) => mapping.dingUserId).filter(Boolean)),
      ];
      if (dingUserIds.length === 0) return { list: [], total: 0, page, pageSize };
      qb.andWhere('ar.dingUserId IN (:...dingUserIds)', { dingUserIds });
    }

    qb.orderBy('ar.attendanceDate', 'DESC')
      .addOrderBy('ar.checkInTime', 'ASC')
      .skip((page - 1) * pageSize)
      .take(pageSize);

    const [list, total] = await qb.getManyAndCount();
    return { list, total, page, pageSize };
  }

  // ── Match a dingtalk record to a student ──
  async matchDingRecord(id: number, dto: MatchDingRecordDto) {
    const record = await this.dingRawRepo.findOne({ where: { id } });
    if (!record) {
      throw new NotFoundException(`DingAttendanceRaw ${id} not found`);
    }

    record.matchedStudentId = dto.studentId;
    record.matchStatus = 'matched';
    return this.dingRawRepo.save(record);
  }

  // ── Auto-match unmatched dingtalk records via dingUserId → userId mapping chain ──
  async autoMatchDingRecords(): Promise<{ matched: number; total: number }> {
    const unmatched = await this.dingRawRepo.find({
      where: { matchStatus: 'unmatched' },
    });

    if (unmatched.length === 0) return { matched: 0, total: 0 };

    // Build dingUserId → studentId map from the mapping table
    const mappings = await this.studentDingMappingRepo.find();
    const dingToStudentId = new Map<string, number>();
    for (const m of mappings) {
      dingToStudentId.set(m.dingUserId, m.studentId);
    }

    let matched = 0;
    for (const record of unmatched) {
      const studentId = dingToStudentId.get(record.dingUserId);
      if (studentId == null) continue;

      record.matchedStudentId = studentId;
      record.matchStatus = 'matched';
      await this.dingRawRepo.save(record);
      matched++;
    }

    return { matched, total: unmatched.length };
  }

  // ── Export all attendance records with filters (no pagination) ──
  async findAllForExport(
    query: {
      classId?: number;
      scheduleId?: number;
      dateFrom?: string;
      dateTo?: string;
      session?: string;
      status?: string;
      source?: string;
    },
    accessibleClassIds?: number[],
  ) {
    const qb = this.attendanceRepo.createQueryBuilder('ar');

    qb.leftJoinAndSelect('ar.student', 'student').leftJoinAndSelect('ar.class', 'class');
    if (query.scheduleId) {
      qb.andWhere('ar.scheduleId = :scheduleId', { scheduleId: query.scheduleId });
    }
    if (query.classId) {
      qb.andWhere('ar.classId = :classId', { classId: query.classId });
    } else if (accessibleClassIds) {
      if (accessibleClassIds.length === 0) return [];
      qb.andWhere('ar.classId IN (:...accessibleClassIds)', { accessibleClassIds });
    }
    if (query.dateFrom) {
      qb.andWhere('ar.attendanceDate >= :dateFrom', { dateFrom: query.dateFrom });
    }
    if (query.dateTo) {
      qb.andWhere('ar.attendanceDate <= :dateTo', { dateTo: query.dateTo });
    }
    if (query.session) {
      qb.andWhere('ar.session = :session', { session: query.session });
    }
    if (query.status) {
      qb.andWhere('ar.status = :status', { status: query.status });
    }
    if (query.source) {
      qb.andWhere('ar.source = :source', { source: query.source });
    }

    qb.orderBy('ar.attendanceDate', 'DESC').addOrderBy('ar.createdAt', 'DESC');

    const records = await qb.getMany();
    return this.attachAttendanceDeviceMappings(records);
  }

  async findAttendanceRecord(id: number) {
    const record = await this.attendanceRepo.findOne({ where: { id } });
    if (!record) {
      throw new NotFoundException(`AttendanceRecord ${id} not found`);
    }
    return record;
  }

  // ── Update a single attendance record ──
  async update(id: number, dto: UpdateAttendanceRecordDto) {
    const record = await this.findAttendanceRecord(id);

    // Records without a lesson session keep original behaviour
    if (record.attendanceSessionId == null) {
      if (dto.status !== undefined) {
        record.status = dto.status;
        record.source = 'manual';
        record.punchTime = null;
        record.punchSource = null;
        record.punchDeviceName = null;
        record.punchDeviceId = null;
      }
      if (dto.remark !== undefined) {
        record.remark = dto.remark;
        record.source = 'manual';
      }
      return this.attendanceRepo.save(record);
    }

    return this.sessionMutex.runExclusive(record.attendanceSessionId, () =>
      this.dataSource.transaction(async (manager) => {
        const recordRepo = manager.getRepository(AttendanceRecord);
        const sessionRepo = manager.getRepository(AttendanceSession);

        // Re-check session status inside the transaction while holding the lock
        const session = await sessionRepo.findOne({ where: { id: record.attendanceSessionId! } });
        if (!session || session.status === 'completed') {
          throw new BadRequestException('已完成考勤的记录不允许修改或删除');
        }

        const freshRecord = await recordRepo.findOne({ where: { id } });
        if (!freshRecord) throw new NotFoundException(`AttendanceRecord ${id} not found`);

        if (dto.status !== undefined) {
          freshRecord.status = dto.status;
          freshRecord.source = 'manual';
          freshRecord.punchTime = null;
          freshRecord.punchSource = null;
          freshRecord.punchDeviceName = null;
          freshRecord.punchDeviceId = null;
        }
        if (dto.remark !== undefined) {
          freshRecord.remark = dto.remark;
          freshRecord.source = 'manual';
        }
        return recordRepo.save(freshRecord);
      }),
    );
  }
  // ── Delete a single attendance record ──
  async remove(id: number) {
    const record = await this.findAttendanceRecord(id);

    // Records without a lesson session keep original behaviour
    if (record.attendanceSessionId == null) {
      await this.attendanceRepo.remove(record);
      return { deleted: true };
    }

    return this.sessionMutex.runExclusive(record.attendanceSessionId, () =>
      this.dataSource.transaction(async (manager) => {
        const recordRepo = manager.getRepository(AttendanceRecord);
        const sessionRepo = manager.getRepository(AttendanceSession);

        // Re-check session status inside the transaction while holding the lock
        const session = await sessionRepo.findOne({ where: { id: record.attendanceSessionId! } });
        if (!session || session.status === 'completed') {
          throw new BadRequestException('已完成考勤的记录不允许修改或删除');
        }

        const freshRecord = await recordRepo.findOne({ where: { id } });
        if (!freshRecord) throw new NotFoundException(`AttendanceRecord ${id} not found`);

        await recordRepo.remove(freshRecord);
        return { deleted: true };
      }),
    );
  }

  // ── Class-based attendance report ──
  async getReport(query: AttendanceReportQueryDto, accessibleClassIds?: number[]) {
    const qb = this.attendanceRepo.createQueryBuilder('ar');

    qb.leftJoin('ar.class', 'class')
      .select('class.id', 'classId')
      .addSelect('class.name', 'className')
      .addSelect('ar.status', 'status')
      .addSelect('COUNT(*)', 'count');
    if (query.classId) {
      qb.andWhere('ar.classId = :classId', { classId: query.classId });
    } else if (accessibleClassIds) {
      if (accessibleClassIds.length === 0) return [];
      qb.andWhere('ar.classId IN (:...accessibleClassIds)', { accessibleClassIds });
    }
    if (query.dateFrom) {
      qb.andWhere('ar.attendanceDate >= :dateFrom', { dateFrom: query.dateFrom });
    }
    if (query.dateTo) {
      qb.andWhere('ar.attendanceDate <= :dateTo', { dateTo: query.dateTo });
    }

    qb.groupBy('class.id').addGroupBy('class.name').addGroupBy('ar.status');
    const rawRows = await qb.getRawMany();

    // Aggregate by class
    const classMap = new Map<
      number,
      {
        classId: number;
        className: string;
        present: number;
        absent: number;
        late: number;
        leave: number;
      }
    >();

    for (const row of rawRows) {
      if (!row.classId) continue;
      if (!classMap.has(row.classId)) {
        classMap.set(row.classId, {
          classId: row.classId,
          className: row.className || `班级#${row.classId}`,
          present: 0,
          absent: 0,
          late: 0,
          leave: 0,
        });
      }
      const entry = classMap.get(row.classId)!;
      const count = parseInt(row.count, 10);
      if (row.status === 'present') entry.present += count;
      else if (row.status === 'absent') entry.absent += count;
      else if (row.status === 'late') entry.late += count;
      else if (row.status === 'leave') entry.leave += count;
    }

    return Array.from(classMap.values()).map((entry) => {
      const total = entry.present + entry.absent + entry.late + entry.leave;
      return {
        ...entry,
        total,
        presentRate: total > 0 ? ((entry.present / total) * 100).toFixed(1) : '0.0',
        absentRate: total > 0 ? ((entry.absent / total) * 100).toFixed(1) : '0.0',
        lateRate: total > 0 ? ((entry.late / total) * 100).toFixed(1) : '0.0',
        leaveRate: total > 0 ? ((entry.leave / total) * 100).toFixed(1) : '0.0',
      };
    });
  }
  // ── Attendance alerts: detect consecutive absences/late ──
  async getAlerts(days: number = 14, threshold: number = 3, accessibleClassIds?: number[]) {
    const cutoff = new Date();
    cutoff.setDate(cutoff.getDate() - days);
    const cutoffStr = cutoff.toISOString().slice(0, 10);

    const qb = this.attendanceRepo
      .createQueryBuilder('a')
      .leftJoinAndSelect('a.student', 'student')
      .leftJoinAndSelect('a.class', 'class');

    qb.where('a.attendanceDate >= :cutoff', { cutoff: cutoffStr }).andWhere(
      'a.status IN (:...statuses)',
      { statuses: ['absent', 'late'] },
    );
    if (accessibleClassIds) {
      if (accessibleClassIds.length === 0) return [];
      qb.andWhere('a.classId IN (:...accessibleClassIds)', { accessibleClassIds });
    }
    const records = await qb
      .orderBy('a.studentId', 'ASC')
      .addOrderBy('a.attendanceDate', 'DESC')
      .getMany();

    const alerts: Array<{
      studentId: number;
      studentName: string;
      className: string;
      type: string;
      count: number;
      lastDate: string;
    }> = [];

    let current: (typeof alerts)[0] | null = null;
    for (const r of records) {
      const name = (r.student as any)?.name || '';
      const className = (r.class as any)?.name || '';
      const status = r.status === 'absent' ? '缺勤' : '迟到';
      if (current && current.studentId === r.studentId && current.type === status) {
        current.count++;
        if (r.attendanceDate > current.lastDate) current.lastDate = r.attendanceDate;
      } else {
        if (current && current.count >= threshold) alerts.push({ ...current });
        current = {
          studentId: r.studentId,
          studentName: name,
          className,
          type: status,
          count: 1,
          lastDate: r.attendanceDate,
        };
      }
    }
    if (current && current.count >= threshold) alerts.push(current);
    return alerts;
  }
}
