import { StudentsService } from './students.service';
import { StudentAccessScope } from './student-access-scope';

// ---------------------------------------------------------------------------
//  Mock helpers — simulate TypeORM QueryBuilder with raw-column naming
// ---------------------------------------------------------------------------

interface QbMock extends Record<string, jest.Mock> {
  select: jest.Mock;
  distinct: jest.Mock;
  leftJoin: jest.Mock;
  innerJoin: jest.Mock;
  where: jest.Mock;
  andWhere: jest.Mock;
  orderBy: jest.Mock;
  take: jest.Mock;
  getRawMany: jest.Mock;
  getRawOne: jest.Mock;
  setParameter: jest.Mock;
}

function makeQb(rawMany: unknown[] = [], rawOne: unknown | null = null): QbMock {
  const qb: QbMock = {
    select: jest.fn().mockReturnThis(),
    distinct: jest.fn().mockReturnThis(),
    leftJoin: jest.fn().mockReturnThis(),
    innerJoin: jest.fn().mockReturnThis(),
    where: jest.fn().mockReturnThis(),
    andWhere: jest.fn().mockReturnThis(),
    orderBy: jest.fn().mockReturnThis(),
    take: jest.fn().mockReturnThis(),
    setParameter: jest.fn().mockReturnThis(),
    getRawMany: jest.fn().mockResolvedValue(rawMany),
    getRawOne: jest.fn().mockResolvedValue(rawOne),
  };
  return qb;
}

/** StudentsService constructor args in order. */
function makeService(
  studentRawMany: unknown[] = [],
  studentRawOne: unknown | null = null,
  classStudentRawMany: unknown[] = [],
  innerJoinOnStudentQb?: (qb: QbMock) => void,
): {
  service: StudentsService;
  studentQb: QbMock;
  classStudentQb: QbMock;
} {
  const studentQb = makeQb(studentRawMany, studentRawOne ?? studentRawMany[0] ?? null);
  const classStudentQb = makeQb(classStudentRawMany);

  if (innerJoinOnStudentQb) innerJoinOnStudentQb(studentQb);

  const studentRepo = { createQueryBuilder: jest.fn().mockReturnValue(studentQb) };
  const classStudentRepo = { createQueryBuilder: jest.fn().mockReturnValue(classStudentQb) };

  const service = new StudentsService(
    studentRepo as never,
    classStudentRepo as never,
    {} as never, // classRepo
    {} as never, // attendanceRepo
    {} as never, // classTeacherRepo
    {} as never, // organizationRepo
    {} as never, // profileRepo
    {} as never, // enrollmentRepo
    {} as never, // examScoreRepo
    {} as never, // learningRecordRepo
    {} as never, // resultRepo
  );

  return { service, studentQb, classStudentQb };
}

const manageAll: StudentAccessScope = { type: 'manageAll' };
const teacher: StudentAccessScope = { type: 'teacher', userId: 42 };

// TypeORM getRawMany/getRawOne uses snake_case column aliases
const sampleRaw = {
  student_id: 1,
  student_name: '张三',
  student_student_no: 'S001',
  student_gender: '男',
  student_status: 'active',
  student_organization_id: 10,
  organization_name: '杭州校区',
};

const expectedOutput = {
  id: 1,
  name: '张三',
  studentNo: 'S001',
  gender: '男',
  status: 'active',
  organizationId: 10,
  organizationName: '杭州校区',
  classIds: [5],
};

// classStudentRepo raw aliases: cs.studentId → cs_student_id, cs.classId → cs_class_id
const classStudentRaw = [
  { cs_student_id: 1, cs_class_id: 5 },
];

// ---------------------------------------------------------------------------
//  Tests
// ---------------------------------------------------------------------------

describe('StudentsService — agent-safe query APIs', () => {
  // -----------------------------------------------------------------------
  //  agentSearchStudents — scope enforcement in SQL
  // -----------------------------------------------------------------------

  describe('agentSearchStudents — scope enforcement in SQL', () => {
    it('manageAll scope does NOT add teacher/class restrictions', async () => {
      const { service, studentQb } = makeService([sampleRaw], null, classStudentRaw);

      await service.agentSearchStudents(manageAll, {});

      const innerJoinArgs = studentQb.innerJoin.mock.calls.flat().join(' ');
      expect(innerJoinArgs).not.toContain('class_teacher');
      expect(innerJoinArgs).not.toContain('scopeTeacherUserId');
    });

    it('teacher scope adds class_student join with class_teacher subquery', async () => {
      const { service, studentQb } = makeService([], null, classStudentRaw);

      await service.agentSearchStudents(teacher, {});

      const innerJoinArgs = studentQb.innerJoin.mock.calls.flat().join(' ');
      expect(innerJoinArgs).toContain('class_teacher');
      expect(innerJoinArgs).toContain('scopeTeacherUserId');
    });

    it('teacher scope returns empty array when no students match', async () => {
      const { service } = makeService([], null, []);

      const result = await service.agentSearchStudents(teacher, {});
      expect(result).toEqual([]);
    });
  });

  // -----------------------------------------------------------------------
  //  agentSearchStudents — classId intersection
  // -----------------------------------------------------------------------

  describe('agentSearchStudents — classId intersection', () => {
    it('classId triggers class_student join for manageAll', async () => {
      const { service, studentQb } = makeService([sampleRaw], null, classStudentRaw);

      await service.agentSearchStudents(manageAll, { classId: 5 });

      const innerJoinArgs = studentQb.innerJoin.mock.calls.flat().join(' ');
      expect(innerJoinArgs).toContain('class_student');
      expect(innerJoinArgs).toContain('scopeClassId');
    });

    it('classId ANDs with teacher scope (intersection, not widening)', async () => {
      const { service, studentQb } = makeService([sampleRaw], null, classStudentRaw);

      await service.agentSearchStudents(teacher, { classId: 3 });

      const innerJoinArgs = studentQb.innerJoin.mock.calls.flat().join(' ');
      expect(innerJoinArgs).toContain('class_teacher');
      expect(innerJoinArgs).toContain('scopeTeacherUserId');

      // classId adds an andWhere, not replacing the teacher join
      const andWhereArgs = studentQb.andWhere.mock.calls.flat().join(' ');
      expect(andWhereArgs).toContain('scopeClassId');
    });
  });

  // -----------------------------------------------------------------------
  //  agentSearchStudents — field whitelist
  // -----------------------------------------------------------------------

  describe('agentSearchStudents — field whitelist', () => {
    it('select list excludes sensitive fields', async () => {
      const { service, studentQb } = makeService([sampleRaw], null, classStudentRaw);

      await service.agentSearchStudents(manageAll, {});

      const selectCalls = studentQb.select.mock.calls.flat().join(' ');
      const forbidden = ['phone', 'idNumber', 'emergencyContact', 'emergencyPhone'];
      for (const field of forbidden) {
        expect(selectCalls).not.toContain(field);
      }
    });

    it('maps raw columns to formatted output', async () => {
      const { service } = makeService([sampleRaw], null, classStudentRaw);

      const result = await service.agentSearchStudents(manageAll, {});
      expect(result).toEqual([expectedOutput]);
    });

    it('output never contains phone/idNumber', async () => {
      const { service } = makeService([{ ...sampleRaw, student_phone: '13800138000' }], null, classStudentRaw);

      const result = await service.agentSearchStudents(manageAll, {});
      if (result.length > 0) {
        const keys = Object.keys(result[0]);
        expect(keys).not.toContain('phone');
        expect(keys).not.toContain('idNumber');
      }
    });
  });

  // -----------------------------------------------------------------------
  //  agentSearchStudents — limit
  // -----------------------------------------------------------------------

  describe('agentSearchStudents — limit', () => {
    it('clamps limit to max 50', async () => {
      const { service, studentQb } = makeService([], null, []);

      await service.agentSearchStudents(manageAll, { limit: 200 });
      expect(studentQb.take).toHaveBeenCalledWith(50);
    });

    it('clamps limit to min 1', async () => {
      const { service, studentQb } = makeService([], null, []);

      await service.agentSearchStudents(manageAll, { limit: 0 });
      expect(studentQb.take).toHaveBeenCalledWith(1);
    });

    it('defaults limit to 20 when not specified', async () => {
      const { service, studentQb } = makeService([], null, []);

      await service.agentSearchStudents(manageAll, {});
      expect(studentQb.take).toHaveBeenCalledWith(20);
    });
  });

  // -----------------------------------------------------------------------
  //  agentSearchStudents — filters
  // -----------------------------------------------------------------------

  describe('agentSearchStudents — filters', () => {
    it('keyword goes to SQL WHERE', async () => {
      const { service, studentQb } = makeService([], null, []);

      await service.agentSearchStudents(manageAll, { keyword: '张三' });

      const andWhereStr = JSON.stringify(studentQb.andWhere.mock.calls);
      expect(andWhereStr).toContain('LIKE');
      expect(andWhereStr).toContain('keyword');
    });

    it('organizationId goes to SQL WHERE', async () => {
      const { service, studentQb } = makeService([], null, []);

      await service.agentSearchStudents(manageAll, { organizationId: 10 });

      const andWhereStr = JSON.stringify(studentQb.andWhere.mock.calls);
      expect(andWhereStr).toContain('orgId');
    });
  });

  // -----------------------------------------------------------------------
  //  agentGetStudentBasic
  // -----------------------------------------------------------------------

  describe('agentGetStudentBasic', () => {
    it('returns formatted result for student in scope', async () => {
      // getRawOne returns first element
      const { service } = makeService([sampleRaw], sampleRaw, classStudentRaw);

      const result = await service.agentGetStudentBasic(manageAll, 1);
      expect(result).toEqual(expectedOutput);
    });

    it('returns null when student not in scope', async () => {
      const { service } = makeService([], null, []);

      const result = await service.agentGetStudentBasic(teacher, 999);
      expect(result).toBeNull();
    });

    it('enforces teacher scope at SQL level via subquery', async () => {
      const { service, studentQb } = makeService([sampleRaw], sampleRaw, []);

      await service.agentGetStudentBasic(teacher, 1);

      const innerJoinArgs = studentQb.innerJoin.mock.calls.flat().join(' ');
      expect(innerJoinArgs).toContain('class_student');
      expect(innerJoinArgs).toContain('class_teacher');
    });

    it('output whitelist excludes sensitive fields', async () => {
      const { service } = makeService([sampleRaw], sampleRaw, classStudentRaw);

      const result = await service.agentGetStudentBasic(manageAll, 1);
      const keys = Object.keys(result!);
      expect(keys).not.toContain('phone');
      expect(keys).not.toContain('idNumber');
      expect(keys).not.toContain('emergencyContact');
      expect(keys).not.toContain('emergencyPhone');
    });
  });

  // -----------------------------------------------------------------------
  //  P1-3: classIds scope enforcement (second query re-applies teacher filter)
  // -----------------------------------------------------------------------

  describe('P1-3: classIds second query enforces teacher scope', () => {
    it('agentSearchStudents teacher scope: second class query includes teacher filter', async () => {
      const { service, classStudentQb } = makeService([sampleRaw], null, classStudentRaw);

      await service.agentSearchStudents(teacher, {});

      const andWhereCalls = classStudentQb.andWhere.mock.calls.flat().join(' ');
      expect(andWhereCalls).toContain('class_teacher');
      expect(andWhereCalls).toContain(':scopeTeacherUserId');
    });

    it('agentSearchStudents manageAll scope: second class query has NO teacher filter', async () => {
      const { service, classStudentQb } = makeService([sampleRaw], null, classStudentRaw);

      await service.agentSearchStudents(manageAll, {});

      const andWhereCalls = classStudentQb.andWhere.mock.calls.flat().join(' ');
      expect(andWhereCalls).not.toContain('class_teacher');
    });

    it('agentGetStudentBasic teacher scope: second class query includes teacher filter', async () => {
      const { service, classStudentQb } = makeService([], sampleRaw, classStudentRaw);

      await service.agentGetStudentBasic(teacher, 1);

      const andWhereCalls = classStudentQb.andWhere.mock.calls.flat().join(' ');
      expect(andWhereCalls).toContain('class_teacher');
      expect(andWhereCalls).toContain(':scopeTeacherUserId');
    });

    it('agentGetStudentBasic manageAll: second class query has NO teacher filter', async () => {
      const { service, classStudentQb } = makeService([], sampleRaw, classStudentRaw);

      await service.agentGetStudentBasic(manageAll, 1);

      const andWhereCalls = classStudentQb.andWhere.mock.calls.flat().join(' ');
      expect(andWhereCalls).not.toContain('class_teacher');
    });
  });

  // -----------------------------------------------------------------------
  //  P2-3: DISTINCT to prevent duplicate students from teacher multi-class join
  // -----------------------------------------------------------------------

  describe('P2-3: DISTINCT in main student query', () => {
    it('agentSearchStudents teacher scope calls distinct(true)', async () => {
      const { service, studentQb } = makeService([], null, []);

      await service.agentSearchStudents(teacher, {});

      expect(studentQb.distinct).toHaveBeenCalledWith(true);
    });

    it('agentSearchStudents manageAll scope also calls distinct(true)', async () => {
      const { service, studentQb } = makeService([], null, []);

      await service.agentSearchStudents(manageAll, {});

      expect(studentQb.distinct).toHaveBeenCalledWith(true);
    });
  });
});
