import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, Like, Not, In, FindOptionsWhere, IsNull } from 'typeorm';
import { Student } from '../entities/student.entity';
import { Class } from '../entities/class.entity';
import { ClassStudent } from '../entities/class-student.entity';
import { ClassTeacher } from '../entities/class-teacher.entity';
import { AttendanceRecord } from '../entities/attendance-record.entity';
import { Organization } from '../entities/organization.entity';
import { StudentProfile } from '../entities/student-profile.entity';
import { StudentEnrollment } from '../entities/student-enrollment.entity';
import { ExamScore } from '../entities/exam-score.entity';
import { LearningRecord } from '../entities/learning-record.entity';
import { ResultArchive } from '../entities/result-archive.entity';
import { CreateStudentDto, UpdateStudentDto } from './dto/student.dto';
import type {
  ExamScoreImportRow,
  LearningRecordImportRow,
  StudentEnrollmentImportRow,
  StudentImportRow,
  StudentWorkbookImport,
} from './student-import';
import type { StudentAccessScope } from './student-access-scope';

@Injectable()
export class StudentsService {
  constructor(
    @InjectRepository(Student) private repo: Repository<Student>,
    @InjectRepository(ClassStudent) private classStudentRepo: Repository<ClassStudent>,
    @InjectRepository(Class) private classRepo: Repository<Class>,
    @InjectRepository(AttendanceRecord) private attendanceRepo: Repository<AttendanceRecord>,
    @InjectRepository(ClassTeacher) private classTeacherRepo: Repository<ClassTeacher>,
    @InjectRepository(Organization) private organizationRepo: Repository<Organization>,
    @InjectRepository(StudentProfile) private profileRepo: Repository<StudentProfile>,
    @InjectRepository(StudentEnrollment) private enrollmentRepo: Repository<StudentEnrollment>,
    @InjectRepository(ExamScore) private examScoreRepo: Repository<ExamScore>,
    @InjectRepository(LearningRecord) private learningRecordRepo: Repository<LearningRecord>,
    @InjectRepository(ResultArchive) private resultRepo: Repository<ResultArchive>,
  ) {}

  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 getBasicLookups() {
    return this.repo.find({
      select: ['id', 'name', 'studentNo', 'gender', 'phone', 'status'],
      where: { status: 'active' },
      order: { name: 'ASC' },
    });
  }

  async getArchiveExportMaps(studentIds: number[]) {
    if (studentIds.length === 0) {
      return {
        profiles: new Map<number, StudentProfile>(),
        results: new Map<number, ResultArchive>(),
      };
    }
    const [profiles, results] = await Promise.all([
      this.profileRepo.find({ where: { studentId: In(studentIds) } }),
      this.resultRepo.find({ where: { studentId: In(studentIds) } }),
    ]);
    return {
      profiles: new Map(profiles.map((profile) => [profile.studentId, profile])),
      results: new Map(results.map((result) => [result.studentId, result])),
    };
  }

  async findAll(
    query?: {
      name?: string;
      status?: string;
      includeArchived?: boolean;
      organizationId?: number | string;
      classId?: number | string;
      teacherId?: number | string;
    },
    accessibleClassIds?: number[],
  ) {
    const where: FindOptionsWhere<Student> = {};
    if (query?.name) where.name = Like(`%${query.name}%`);
    if (query?.organizationId) where.organizationId = Number(query.organizationId);
    if (query?.status) {
      where.status = query.status;
    } else if (!query?.includeArchived) {
      where.status = Not(In(['archived', 'staff']));
    }

    let scopedClassIds = accessibleClassIds ? [...accessibleClassIds] : undefined;
    if (query?.teacherId) {
      const teacherAssignments = await this.classTeacherRepo.find({
        where: { userId: Number(query.teacherId) },
      });
      const teacherClassIds = [...new Set(teacherAssignments.map((item) => item.classId))];
      scopedClassIds = scopedClassIds
        ? scopedClassIds.filter((classId) => teacherClassIds.includes(classId))
        : teacherClassIds;
    }
    if (query?.classId) {
      const classId = Number(query.classId);
      scopedClassIds = scopedClassIds
        ? scopedClassIds.filter((accessibleClassId) => accessibleClassId === classId)
        : [classId];
    }

    if (scopedClassIds) {
      if (scopedClassIds.length === 0) return [];
      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 [];
      where.id = In(studentIds);
    }
    return this.repo.find({ where, order: { createdAt: 'DESC' }, relations: ['organization'] });
  }

  async getFilterLookups(accessibleClassIds?: number[]) {
    if (accessibleClassIds && accessibleClassIds.length === 0) {
      return { classes: [], teachers: [] };
    }

    const classWhere = accessibleClassIds
      ? { id: In(accessibleClassIds), isArchived: false }
      : { isArchived: false };
    const classes = await this.classRepo.find({
      select: ['id', 'name', 'code'],
      where: classWhere,
      order: { name: 'ASC' },
    });

    const teacherWhere = accessibleClassIds
      ? { classId: In(accessibleClassIds) }
      : { classId: In(classes.map((item) => item.id)), userId: Not(IsNull()) };
    const assignments = classes.length
      ? await this.classTeacherRepo.find({ where: teacherWhere, relations: ['user'] })
      : [];
    const teacherMap = new Map<number, { id: number; name: string; username: string }>();
    for (const assignment of assignments) {
      if (!assignment.user || !assignment.user.isActive || assignment.user.isArchived) continue;
      teacherMap.set(assignment.userId, {
        id: assignment.userId,
        name: assignment.user.name || assignment.user.username,
        username: assignment.user.username,
      });
    }

    return {
      classes: classes.map((item) => ({ id: item.id, name: item.name, code: item.code })),
      teachers: [...teacherMap.values()].sort((a, b) => a.name.localeCompare(b.name, 'zh-CN')),
    };
  }

  async findOne(id: number) {
    const student = await this.repo.findOne({
      where: { id },
      relations: ['occupancies', 'occupancies.room'],
    });
    if (!student) throw new NotFoundException('学生不存在');
    return student;
  }

  async create(dto: CreateStudentDto) {
    await this.assertActiveOrganization(dto.organizationId);
    return this.repo.save(this.repo.create(dto));
  }

  async update(id: number, dto: UpdateStudentDto) {
    await this.findOne(id);
    if (dto.organizationId) await this.assertActiveOrganization(dto.organizationId);
    await this.repo.update(id, dto);
    return this.repo.findOne({ where: { id } });
  }

  async remove(id: number) {
    const student = await this.findOne(id);
    if (student.status === 'archived') {
      throw new BadRequestException('该学生已归档');
    }
    await this.repo.update(id, { status: 'archived' });
    return { message: '已归档（数据已保留，可随时恢复）' };
  }

  async batchRemove(ids: number[]) {
    if (!ids || ids.length === 0) throw new BadRequestException('请选择要归档的学生');
    const students = await this.repo.find({ where: { id: In(ids) } });
    const skipped: string[] = [];
    const targetIds: number[] = [];
    for (const s of students) {
      if (s.status === 'archived') skipped.push(s.name);
      else targetIds.push(s.id);
    }
    let affected = 0;
    if (targetIds.length > 0) {
      const result = await this.repo
        .createQueryBuilder()
        .update()
        .set({ status: 'archived' })
        .where('id IN (:...ids)', { ids: targetIds })
        .execute();
      affected = result.affected || 0;
    }
    const message =
      skipped.length > 0
        ? `成功归档 ${affected} 人；${skipped.length} 人已是归档状态被跳过（${skipped.slice(0, 5).join('、')}${skipped.length > 5 ? '…' : ''}）`
        : `已批量归档 ${affected} 人（数据已保留，可随时恢复）`;
    return { message, archived: affected, skipped: skipped.length };
  }

  async restore(id: number) {
    const student = await this.findOne(id);
    if (student.status !== 'archived') {
      throw new BadRequestException('该学生未被归档');
    }
    await this.repo.update(id, { status: 'active' });
    return { message: '已恢复' };
  }

  async batchImport(importData: StudentWorkbookImport | StudentImportRow[]) {
    const data = this.normalizeImportData(importData);
    let imported = 0;
    let skipped = 0;
    let archiveImported = 0;
    for (const row of data.students) {
      if (!row.name || !row.name.trim()) {
        skipped++;
        continue;
      }
      const exists = await this.repo.findOne({ where: { name: row.name.trim() } });
      if (exists) {
        skipped++;
        continue;
      }
      const student = await this.repo.save(
        this.repo.create({
          name: row.name.trim(),
          studentNo: row.studentNo?.trim() || undefined,
          phone: row.phone?.trim() || undefined,
          idNumber: row.idNumber?.trim() || undefined,
          gender: row.gender || undefined,
          ethnicity: row.ethnicity || undefined,
          emergencyContact: row.emergencyContact || undefined,
          emergencyPhone: row.emergencyPhone || undefined,
          supervisor: row.supervisor || undefined,
          organizationId: row.organizationId || (await this.getHostOrganizationId()),
        }),
      );
      archiveImported += await this.importArchiveData(student.id, row, data);
      imported++;
    }
    return {
      message: `成功导入 ${imported} 名学生，导入档案相关记录 ${archiveImported} 条，跳过 ${skipped} 条（重复或空行）`,
      imported,
      archiveImported,
      skipped,
    };
  }

  async matchImport(importData: StudentWorkbookImport | StudentImportRow[]) {
    const data = this.normalizeImportData(importData);
    let matched = 0;
    let skipped = 0;
    let archiveImported = 0;
    for (const row of data.students) {
      // Match by phone first, then idNumber
      let student = row.phone?.trim()
        ? await this.repo.findOne({ where: { phone: row.phone.trim() } })
        : null;
      if (!student && row.idNumber?.trim()) {
        student = await this.repo.findOne({ where: { idNumber: row.idNumber.trim() } });
      }
      if (!student) {
        skipped++;
        continue;
      }
      // Update matched student with non-empty imported fields
      const updates: Partial<
        Pick<
          Student,
          | 'name'
          | 'studentNo'
          | 'phone'
          | 'idNumber'
          | 'gender'
          | 'ethnicity'
          | 'emergencyContact'
          | 'emergencyPhone'
          | 'supervisor'
          | 'organizationId'
        >
      > = {};
      if (row.name?.trim()) updates.name = row.name.trim();
      if (row.studentNo?.trim()) updates.studentNo = row.studentNo.trim();
      if (row.phone?.trim()) updates.phone = row.phone.trim();
      if (row.idNumber?.trim()) updates.idNumber = row.idNumber.trim();
      if (row.gender) updates.gender = row.gender;
      if (row.ethnicity) updates.ethnicity = row.ethnicity;
      if (row.emergencyContact) updates.emergencyContact = row.emergencyContact;
      if (row.emergencyPhone) updates.emergencyPhone = row.emergencyPhone;
      if (row.supervisor) updates.supervisor = row.supervisor;
      if (row.organizationId) updates.organizationId = row.organizationId;
      await this.repo.update(student.id, updates);
      archiveImported += await this.importArchiveData(student.id, row, data);
      matched++;
    }
    return {
      message: `更新已有学生资料 ${matched} 人，导入档案相关记录 ${archiveImported} 条，跳过 ${skipped} 条（无匹配）`,
      matched,
      archiveImported,
      skipped,
    };
  }

  private normalizeImportData(importData: StudentWorkbookImport | StudentImportRow[]): StudentWorkbookImport {
    if (Array.isArray(importData)) {
      return { students: importData, enrollments: [], examScores: [], learningRecords: [] };
    }
    return importData;
  }

  private normalizePhone(phone?: string) {
    return phone?.trim() || '';
  }

  private sameValue(left?: string | number | null, right?: string | number | null) {
    return String(left ?? '').trim() === String(right ?? '').trim();
  }

  private hasProfileData(row: StudentImportRow) {
    return [
      row.targetCollege,
      row.targetMajor,
      row.collegeSchool,
      row.collegeMajor,
      row.subjectDirection,
      row.grade,
      row.profileDate,
      row.notes,
    ].some((value) => value !== undefined && String(value).trim() !== '');
  }

  private hasResultData(row: StudentImportRow) {
    return [
      row.cultureFinalScore,
      row.professionalFinalScore,
      row.admissionStatus,
      row.admittedCollege,
      row.admittedMajor,
    ].some((value) => value !== undefined && String(value).trim() !== '');
  }

  private async importArchiveData(
    studentId: number,
    row: StudentImportRow,
    data: StudentWorkbookImport,
  ) {
    const phone = this.normalizePhone(row.phone);
    let imported = 0;
    if (this.hasProfileData(row)) {
      await this.upsertProfileFromImport(studentId, row);
      imported++;
    }
    if (this.hasResultData(row)) {
      await this.upsertResultFromImport(studentId, row);
      imported++;
    }
    if (!phone) return imported;

    const enrollmentByClassName = new Map<string, StudentEnrollment>();
    for (const enrollmentRow of data.enrollments.filter((item) => this.normalizePhone(item.phone) === phone)) {
      const enrollment = await this.upsertEnrollmentFromImport(studentId, enrollmentRow);
      if (!enrollment) continue;
      if (enrollment.className) enrollmentByClassName.set(enrollment.className, enrollment);
      imported++;
    }
    for (const examRow of data.examScores.filter((item) => this.normalizePhone(item.phone) === phone)) {
      if (await this.upsertExamScoreFromImport(studentId, examRow, enrollmentByClassName)) {
        imported++;
      }
    }
    for (const learningRow of data.learningRecords.filter((item) => this.normalizePhone(item.phone) === phone)) {
      if (await this.upsertLearningRecordFromImport(studentId, learningRow)) {
        imported++;
      }
    }
    return imported;
  }

  private async upsertProfileFromImport(studentId: number, row: StudentImportRow) {
    const entity = (await this.profileRepo.findOne({ where: { studentId } })) || this.profileRepo.create({ studentId });
    if (row.targetCollege?.trim()) entity.targetCollege = row.targetCollege.trim();
    if (row.targetMajor?.trim()) entity.targetMajor = row.targetMajor.trim();
    if (row.collegeSchool?.trim()) entity.collegeSchool = row.collegeSchool.trim();
    if (row.collegeMajor?.trim()) entity.collegeMajor = row.collegeMajor.trim();
    if (row.subjectDirection?.trim()) entity.subjectDirection = row.subjectDirection.trim();
    if (row.grade?.trim()) entity.grade = row.grade.trim();
    if (row.profileDate?.trim()) entity.profileDate = row.profileDate.trim();
    if (row.notes?.trim()) entity.notes = row.notes.trim();
    await this.profileRepo.save(entity);
  }

  private async upsertResultFromImport(studentId: number, row: StudentImportRow) {
    const entity = (await this.resultRepo.findOne({ where: { studentId } })) || this.resultRepo.create({ studentId });
    if (row.cultureFinalScore !== undefined) entity.cultureFinalScore = row.cultureFinalScore;
    if (row.professionalFinalScore !== undefined) entity.professionalFinalScore = row.professionalFinalScore;
    if (row.admissionStatus?.trim()) entity.admissionStatus = row.admissionStatus.trim();
    if (row.admittedCollege?.trim()) entity.admittedCollege = row.admittedCollege.trim();
    if (row.admittedMajor?.trim()) entity.admittedMajor = row.admittedMajor.trim();
    await this.resultRepo.save(entity);
  }

  private async upsertEnrollmentFromImport(studentId: number, row: StudentEnrollmentImportRow) {
    if (!row.courseCategory?.trim() || !row.classType?.trim()) {
      return null;
    }
    const existing = await this.enrollmentRepo.find({ where: { studentId } });
    const entity =
      existing.find(
        (item) =>
          this.sameValue(item.courseCategory, row.courseCategory) &&
          this.sameValue(item.classType, row.classType) &&
          this.sameValue(item.className, row.className) &&
          this.sameValue(item.startDate, row.startDate),
      ) || this.enrollmentRepo.create({ studentId });
    entity.courseCategory = row.courseCategory.trim();
    entity.classType = row.classType.trim();
    if (row.className?.trim()) entity.className = row.className.trim();
    if (row.headTeacher?.trim()) entity.headTeacher = row.headTeacher.trim();
    if (row.subjectTeacher?.trim()) entity.subjectTeacher = row.subjectTeacher.trim();
    if (row.startDate?.trim()) entity.startDate = row.startDate.trim();
    if (row.endDate?.trim()) entity.endDate = row.endDate.trim();
    if (row.status?.trim()) entity.status = row.status.trim();
    else if (!entity.status) entity.status = 'active';
    return this.enrollmentRepo.save(entity);
  }

  private async upsertExamScoreFromImport(
    studentId: number,
    row: ExamScoreImportRow,
    enrollmentByClassName: Map<string, StudentEnrollment>,
  ) {
    if (!row.examType?.trim() || !row.subject?.trim() || row.score === undefined) return false;
    const existing = await this.examScoreRepo.find({ where: { studentId } });
    const entity =
      existing.find(
        (item) =>
          this.sameValue(item.examType, row.examType) &&
          this.sameValue(item.examName, row.examName) &&
          this.sameValue(item.subject, row.subject) &&
          this.sameValue(item.examDate, row.examDate),
      ) || this.examScoreRepo.create({ studentId });
    entity.examType = row.examType.trim();
    entity.subject = row.subject.trim();
    entity.score = row.score;
    if (row.examName?.trim()) entity.examName = row.examName.trim();
    if (row.classAvg !== undefined) entity.classAvg = row.classAvg;
    if (row.rank !== undefined) entity.rank = row.rank;
    if (row.examDate?.trim()) entity.examDate = row.examDate.trim();
    if (row.enrollmentName?.trim()) {
      const enrollment = enrollmentByClassName.get(row.enrollmentName.trim());
      if (enrollment) entity.enrollmentId = enrollment.id;
    }
    if (!entity.status) entity.status = 'active';
    await this.examScoreRepo.save(entity);
    return true;
  }

  private async upsertLearningRecordFromImport(studentId: number, row: LearningRecordImportRow) {
    if (!row.recordDate?.trim() || !row.recordType?.trim() || !row.content?.trim()) return false;
    const existing = await this.learningRecordRepo.find({ where: { studentId } });
    const entity =
      existing.find(
        (item) =>
          this.sameValue(item.recordDate, row.recordDate) &&
          this.sameValue(item.recordType, row.recordType) &&
          this.sameValue(item.content, row.content),
      ) || this.learningRecordRepo.create({ studentId });
    entity.recordDate = row.recordDate.trim();
    entity.recordType = row.recordType.trim();
    entity.content = row.content.trim();
    if (row.followUpMethod?.trim()) entity.followUpMethod = row.followUpMethod.trim();
    if (row.nextStep?.trim()) entity.nextStep = row.nextStep.trim();
    if (!entity.status) entity.status = 'active';
    await this.learningRecordRepo.save(entity);
    return true;
  }

  private async assertActiveOrganization(id: number) {
    const organization = await this.organizationRepo.findOne({ where: { id, status: 'active' } });
    if (!organization) throw new BadRequestException('所属机构不存在或已归档');
  }

  private async getHostOrganizationId() {
    const organization = await this.organizationRepo.findOne({
      where: { isHost: true, status: 'active' },
    });
    if (!organization) throw new BadRequestException('尚未配置本机构');
    return organization.id;
  }

  async compareClasses(studentId: number) {
    const student = await this.repo.findOne({ where: { id: studentId } });
    if (!student) throw new NotFoundException('学生不存在');

    const enrollments = await this.classStudentRepo.find({
      where: { studentId },
      relations: ['class'],
    });

    if (enrollments.length === 0) {
      return { student, enrollments: [] };
    }

    const classIds = enrollments.map((e) => e.classId);
    const attendanceRecords = await this.attendanceRepo.find({
      where: { studentId, classId: In(classIds) },
    });

    const attendanceByClass = new Map<number, AttendanceRecord[]>();
    for (const r of attendanceRecords) {
      const list = attendanceByClass.get(r.classId) || [];
      list.push(r);
      attendanceByClass.set(r.classId, list);
    }

    const comparison = enrollments.map((e) => {
      const records = attendanceByClass.get(e.classId) || [];
      const total = records.length;
      const present = records.filter((r) => r.status === 'present' || r.status === '正常').length;
      const absent = records.filter((r) => r.status === 'absent' || r.status === '缺勤').length;
      const late = records.filter((r) => r.status === 'late' || r.status === '迟到').length;
      const leave = records.filter((r) => r.status === 'leave' || r.status === '请假').length;

      return {
        classId: e.classId,
        className: e.class?.name || '',
        classType: e.class?.classType || '',
        startDate: e.class?.startDate || '',
        endDate: e.class?.endDate || '',
        joinDate: e.joinDate,
        leaveDate: e.leaveDate,
        status: e.status,
        attendanceStats: {
          total,
          present,
          absent,
          late,
          leave,
          rate: total > 0 ? Math.round((present / total) * 100) : 0,
        },
      };
    });

    return { student, enrollments: comparison };
  }

  // -------------------------------------------------------------------------
  //  Agent-safe query APIs — SQL-level scope + field whitelist
  // -------------------------------------------------------------------------

  /**
   * Whitelisted output type for agent student searches.
   * NEVER exposes phone, idNumber, emergencyContact, or emergencyPhone.
   */
  private static readonly AGENT_STUDENT_SELECT = [
    'student.id',
    'student.name',
    'student.studentNo',
    'student.gender',
    'student.status',
    'student.organizationId',
    'organization.name',
  ] as const;

  /**
   * Search students with SQL-enforced scope, field whitelist, and limit.
   *
   * @param scope — data-range discriminator (manageAll or teacher).
   * @param query — optional keyword, classId, organizationId, limit.
   * @returns formatted whitelist-only results with classIds.
   */
  async agentSearchStudents(
    scope: StudentAccessScope,
    query?: {
      keyword?: string;
      classId?: number;
      organizationId?: number;
      limit?: number;
    },
  ): Promise<
    {
      id: number;
      name: string;
      studentNo: string;
      gender: string;
      status: string;
      organizationId: number;
      organizationName: string;
      classIds: number[];
    }[]
  > {
    const limit = Math.max(1, Math.min(query?.limit ?? 20, 50));

    const qb = this.repo
      .createQueryBuilder('student')
      .distinct(true)
      .select([
        'student.id',
        'student.name',
        'student.studentNo',
        'student.gender',
        'student.status',
        'student.organizationId',
        'organization.name',
      ])
      .leftJoin('student.organization', 'organization');

    // ---- Scope enforcement ----
    this.applyStudentScope(qb, scope, query?.classId);

    // ---- Filters ----
    if (query?.keyword) {
      qb.andWhere(
        '(student.name LIKE :keyword OR student.studentNo LIKE :keyword)',
        { keyword: `%${query.keyword}%` },
      );
    }
    if (query?.organizationId) {
      qb.andWhere('student.organizationId = :orgId', { orgId: query.organizationId });
    }

    qb.orderBy('student.createdAt', 'DESC').take(limit);

    const rows: Record<string, unknown>[] = await qb.getRawMany();
    if (rows.length === 0) return [];

    // Second bounded query: classIds only for the returned student ids.
    // For teacher scope, the class filter MUST be re-applied so the
    // teacher only sees classIds they are assigned to.
    const studentIds = rows.map((r) => r.student_id as number);
    const csQb = this.classStudentRepo
      .createQueryBuilder('cs')
      .select(['cs.studentId', 'cs.classId'])
      .where('cs.studentId IN (:...ids)', { ids: studentIds })
      .andWhere('cs.status = :status', { status: 'active' });

    if (scope.type === 'teacher') {
      csQb.andWhere(
        'cs.classId IN (SELECT ct.class_id FROM class_teacher ct WHERE ct.user_id = :scopeTeacherUserId)',
        { scopeTeacherUserId: scope.userId },
      );
    }

    const classRows = await csQb.getRawMany();

    const classMap = new Map<number, number[]>();
    for (const cr of classRows as { cs_student_id: number; cs_class_id: number }[]) {
      const sid = cr.cs_student_id;
      if (!classMap.has(sid)) classMap.set(sid, []);
      classMap.get(sid)!.push(cr.cs_class_id);
    }

    return rows.map((r) => ({
      id: r.student_id as number,
      name: r.student_name as string,
      studentNo: (r.student_student_no as string) ?? '',
      gender: (r.student_gender as string) ?? '',
      status: r.student_status as string,
      organizationId: r.student_organization_id as number,
      organizationName: (r.organization_name as string) ?? '',
      classIds: classMap.get(r.student_id as number) ?? [],
    }));
  }

  /**
   * Get single student basic info with SQL-enforced scope + whitelist.
   * Returns `null` for students out of scope or non-existent (no leak).
   */
  async agentGetStudentBasic(
    scope: StudentAccessScope,
    studentId: number,
  ): Promise<{
    id: number;
    name: string;
    studentNo: string;
    gender: string;
    status: string;
    organizationId: number;
    organizationName: string;
    classIds: number[];
  } | null> {
    const qb = this.repo
      .createQueryBuilder('student')
      .select([
        'student.id',
        'student.name',
        'student.studentNo',
        'student.gender',
        'student.status',
        'student.organizationId',
        'organization.name',
      ])
      .leftJoin('student.organization', 'organization')
      .where('student.id = :studentId', { studentId });

    this.applyStudentScope(qb, scope);

    const row = await qb.getRawOne();
    if (!row) return null;

    // For teacher scope, re-apply class filter so teacher only sees
    // classIds they are assigned to (not ALL active classIds of the student).
    const csQb = this.classStudentRepo
      .createQueryBuilder('cs')
      .select(['cs.classId'])
      .where('cs.studentId = :studentId', { studentId })
      .andWhere('cs.status = :status', { status: 'active' });

    if (scope.type === 'teacher') {
      csQb.andWhere(
        'cs.classId IN (SELECT ct.class_id FROM class_teacher ct WHERE ct.user_id = :scopeTeacherUserId)',
        { scopeTeacherUserId: scope.userId },
      );
    }

    const classRows = await csQb.getRawMany();

    return {
      id: row.student_id as number,
      name: row.student_name as string,
      studentNo: (row.student_student_no as string) ?? '',
      gender: (row.student_gender as string) ?? '',
      status: row.student_status as string,
      organizationId: row.student_organization_id as number,
      organizationName: (row.organization_name as string) ?? '',
      classIds: (classRows as { cs_class_id: number }[]).map((cr) => cr.cs_class_id),
    };
  }

  /**
   * Apply data-range scope to a student QueryBuilder.
   *
   * - `manageAll`: no restriction.
   * - `teacher`: INNER JOIN ClassStudent → active students in the
   *    teacher's assigned classes (via ClassTeacher).
   * - When `classId` is provided, it is ANDed with the scope
   *   (intersection) — the model cannot widen access.
   */
  private applyStudentScope(
    qb: ReturnType<typeof this.repo.createQueryBuilder>,
    scope: StudentAccessScope,
    classId?: number,
  ): void {
    if (scope.type === 'manageAll') {
      if (classId != null) {
        qb.innerJoin(
          'class_student',
          'cs_scope',
          'cs_scope.student_id = student.id AND cs_scope.class_id = :scopeClassId AND cs_scope.status = :scopeCsStatus',
          { scopeClassId: classId, scopeCsStatus: 'active' },
        );
      }
      return;
    }

    // Teacher scope: active students in teacher's assigned classes
    const teacherClause =
      'cs_scope.student_id = student.id AND cs_scope.status = :scopeCsStatus AND cs_scope.class_id IN ' +
      '(SELECT ct.class_id FROM class_teacher ct WHERE ct.user_id = :scopeTeacherUserId)';

    qb.innerJoin('class_student', 'cs_scope', teacherClause, {
      scopeTeacherUserId: scope.userId,
      scopeCsStatus: 'active',
    });

    if (classId != null) {
      qb.andWhere('cs_scope.class_id = :scopeClassId', { scopeClassId: classId });
    }
  }
}
