import { BadRequestException, ForbiddenException, NotFoundException, ValidationPipe } from '@nestjs/common';
import { ExamScore } from '../entities';
import { QueryExamDto } from './dto/exam.dto';
import { ExamsService } from './exams.service';

function createService(
  transaction: (run: (manager: any) => Promise<unknown>) => Promise<unknown>,
  repositories: {
    examRepo?: Record<string, jest.Mock>;
    scoreRepo?: Record<string, jest.Mock>;
    classTeacherRepo?: Record<string, jest.Mock>;
  } = {},
) {
  return new ExamsService(
    (repositories.examRepo ?? {}) as never,
    (repositories.scoreRepo ?? {}) as never,
    {} as never,
    {} as never,
    (repositories.classTeacherRepo ?? {
      findOne: jest.fn().mockResolvedValue({ id: 1 }),
    }) as never,
    { transaction } as never,
  );
}

function updateQb(affected = 1) {
  const qb = {
    update: jest.fn(),
    set: jest.fn(),
    where: jest.fn(),
    execute: jest.fn().mockResolvedValue({ affected }),
  };
  qb.update.mockReturnValue(qb);
  qb.set.mockReturnValue(qb);
  qb.where.mockReturnValue(qb);
  return qb;
}

describe('ExamsService', () => {
  it('creates score rows from the active class roster snapshot', async () => {
    const members = [
      { studentId: 11, status: 'active' },
      { studentId: 12, status: 'active' },
    ];
    const manager = {
      findOne: jest.fn().mockResolvedValue({ id: 3, isArchived: false }),
      find: jest.fn().mockResolvedValue(members),
      create: jest.fn((_entity, value) => value),
      save: jest.fn(async (entity, value) =>
        entity.name === 'Exam' ? { ...value, id: 9 } : value,
      ),
    };
    const service = createService(async (run) => run(manager));

    await service.create(
      {
        examType: '月考',
        examName: '七月月考',
        subject: '数学',
        examDate: '2026-07-21',
        classId: 3,
      },
      1,
      true,
    );

    expect(manager.find).toHaveBeenCalledWith(
      expect.anything(),
      expect.objectContaining({ where: { classId: 3, status: 'active' } }),
    );
    expect(manager.save).toHaveBeenCalledWith(
      ExamScore,
      expect.arrayContaining([
        expect.objectContaining({ examId: 9, studentId: 11, score: null }),
        expect.objectContaining({ examId: 9, studentId: 12, score: null }),
      ]),
    );
  });

  it('rejects creating an exam for an empty class', async () => {
    const manager = {
      findOne: jest.fn().mockResolvedValue({ id: 3, isArchived: false }),
      find: jest.fn().mockResolvedValue([]),
    };
    const service = createService(async (run) => run(manager));

    await expect(
      service.create(
        {
          examType: '月考',
          examName: '七月月考',
          subject: '数学',
          examDate: '2026-07-21',
          classId: 3,
        },
        1,
        true,
      ),
    ).rejects.toBeInstanceOf(BadRequestException);
  });

  it('counts zero, ignores empty scores, and uses competition ranking', async () => {
    const rows = [
      { id: 1, score: 90, classAvg: null, rank: null },
      { id: 2, score: 90, classAvg: null, rank: null },
      { id: 3, score: 60, classAvg: null, rank: null },
      { id: 4, score: 0, classAvg: null, rank: null },
      { id: 5, score: null, classAvg: null, rank: null },
    ];
    const manager = {
      findOne: jest
        .fn()
        .mockResolvedValueOnce({ id: 8, classId: 3, status: 'active' })
        .mockResolvedValueOnce(rows[0])
        .mockResolvedValueOnce(rows[0]),
      find: jest.fn().mockResolvedValue(rows),
      save: jest.fn(async (_entity, value) => value),
    };
    const service = createService(async (run) => run(manager));

    await service.updateScore(8, 1, 90, 1, true);

    expect(rows.map((row) => row.rank)).toEqual([1, 1, 3, 4, null]);
    expect(rows.map((row) => row.classAvg)).toEqual([60, 60, 60, 60, 60]);
  });

  it('clears a score and recalculates the remaining rows', async () => {
    const rows = [
      { id: 1, score: 90, classAvg: 80, rank: 1 },
      { id: 2, score: 70, classAvg: 80, rank: 2 },
    ];
    const manager = {
      findOne: jest
        .fn()
        .mockResolvedValueOnce({ id: 8, classId: 3, status: 'active' })
        .mockResolvedValueOnce(rows[0])
        .mockResolvedValueOnce(rows[0]),
      find: jest.fn().mockResolvedValue(rows),
      save: jest.fn(async (_entity, value) => value),
    };
    const service = createService(async (run) => run(manager));

    await service.updateScore(8, 1, null, 1, true);

    expect(rows).toEqual([
      expect.objectContaining({ score: null, classAvg: 70, rank: null }),
      expect.objectContaining({ score: 70, classAvg: 70, rank: 1 }),
    ]);
  });

  it('lists active exams by default and archived exams on request', async () => {
    const examRepo = {
      find: jest.fn().mockResolvedValue([]),
    };
    const service = createService(async () => undefined, { examRepo });

    await service.findAll({});
    await service.findAll({ isArchived: true });

    expect(examRepo.find).toHaveBeenNthCalledWith(
      1,
      expect.objectContaining({ where: expect.objectContaining({ status: 'active' }) }),
    );
    expect(examRepo.find).toHaveBeenNthCalledWith(
      2,
      expect.objectContaining({ where: expect.objectContaining({ status: 'archived' }) }),
    );
  });

  it('preserves the accessible class filter for archived exams', async () => {
    const examRepo = {
      find: jest.fn().mockResolvedValue([]),
    };
    const service = createService(async () => undefined, { examRepo });

    await service.findAll({ isArchived: true }, [3, 5]);

    expect(examRepo.find).toHaveBeenCalledWith(
      expect.objectContaining({
        where: expect.objectContaining({ status: 'archived', classId: expect.anything() }),
      }),
    );
  });

  it('archives an exam without changing score rows', async () => {
    const examRepo = {
      findOne: jest.fn().mockResolvedValue({ id: 8, classId: 3, status: 'active' }),
      update: jest.fn().mockResolvedValue({ affected: 1 }),
    };
    const scoreRepo = { update: jest.fn(), save: jest.fn() };
    const service = createService(async () => undefined, { examRepo, scoreRepo });

    await expect(service.archive(8, 1, true)).resolves.toEqual({ success: true });

    expect(examRepo.update).toHaveBeenCalledWith(8, { status: 'archived' });
    expect(scoreRepo.update).not.toHaveBeenCalled();
    expect(scoreRepo.save).not.toHaveBeenCalled();
  });

  it('rejects archiving an archived exam', async () => {
    const examRepo = {
      findOne: jest.fn().mockResolvedValue({ id: 8, classId: 3, status: 'archived' }),
      update: jest.fn(),
    };
    const service = createService(async () => undefined, { examRepo });

    await expect(service.archive(8, 1, true)).rejects.toBeInstanceOf(BadRequestException);
    expect(examRepo.update).not.toHaveBeenCalled();
  });

  it('restores an archived exam and rejects restoring an active exam', async () => {
    const examRepo = {
      findOne: jest
        .fn()
        .mockResolvedValueOnce({ id: 8, classId: 3, status: 'archived' })
        .mockResolvedValueOnce({ id: 9, classId: 3, status: 'active' }),
      update: jest.fn().mockResolvedValue({ affected: 1 }),
    };
    const service = createService(async () => undefined, { examRepo });

    await expect(service.restore(8, 1, true)).resolves.toEqual({ success: true });
    await expect(service.restore(9, 1, true)).rejects.toBeInstanceOf(BadRequestException);
    expect(examRepo.update).toHaveBeenCalledTimes(1);
    expect(examRepo.update).toHaveBeenCalledWith(8, { status: 'active' });
  });

  it('requires class access before archiving an exam', async () => {
    const examRepo = {
      findOne: jest.fn().mockResolvedValue({ id: 8, classId: 3, status: 'active' }),
      update: jest.fn(),
    };
    const classTeacherRepo = { findOne: jest.fn().mockResolvedValue(null) };
    const service = createService(async () => undefined, { examRepo, classTeacherRepo });

    await expect(service.archive(8, 21, false)).rejects.toBeInstanceOf(ForbiddenException);
    expect(classTeacherRepo.findOne).toHaveBeenCalledWith({ where: { userId: 21, classId: 3 } });
    expect(examRepo.update).not.toHaveBeenCalled();
  });

  it('rejects empty and invalid ids for batch archive and restore', async () => {
    const service = createService(async () => undefined, { examRepo: { find: jest.fn() } });

    for (const call of [
      (ids: number[]) => service.batchArchive(ids, 1, true),
      (ids: number[]) => service.batchRestore(ids, 1, true),
    ]) {
      await expect(call([])).rejects.toBeInstanceOf(BadRequestException);
      await expect(call([0])).rejects.toBeInstanceOf(BadRequestException);
      await expect(call([1.5])).rejects.toBeInstanceOf(BadRequestException);
    }
  });

  it('deduplicates ids, archives active exams, skips archived exams, and leaves scores unchanged', async () => {
    const qb = updateQb();
    const examRepo = {
      find: jest.fn().mockResolvedValue([
        { id: 8, classId: 3, status: 'active' },
        { id: 9, classId: 4, status: 'archived' },
      ]),
      createQueryBuilder: jest.fn(() => qb),
    };
    const scoreRepo = { update: jest.fn(), save: jest.fn() };
    const service = createService(async () => undefined, { examRepo, scoreRepo });

    await expect(service.batchArchive([8, 8, 9], 1, true)).resolves.toEqual({
      message: '已批量归档 1 场考试',
      archived: 1,
      skipped: 1,
    });
    expect(examRepo.find).toHaveBeenCalledWith({ where: { id: expect.anything() } });
    expect(qb.set).toHaveBeenCalledWith({ status: 'archived' });
    expect(qb.where).toHaveBeenCalledWith('id IN (:...ids)', { ids: [8] });
    expect(scoreRepo.update).not.toHaveBeenCalled();
    expect(scoreRepo.save).not.toHaveBeenCalled();
  });

  it('restores archived exams and skips active exams', async () => {
    const qb = updateQb();
    const examRepo = {
      find: jest.fn().mockResolvedValue([
        { id: 8, classId: 3, status: 'archived' },
        { id: 9, classId: 4, status: 'active' },
      ]),
      createQueryBuilder: jest.fn(() => qb),
    };
    const service = createService(async () => undefined, { examRepo });

    await expect(service.batchRestore([8, 9], 1, true)).resolves.toEqual({
      message: '已批量恢复 1 场考试',
      restored: 1,
      skipped: 1,
    });
    expect(qb.set).toHaveBeenCalledWith({ status: 'active' });
    expect(qb.where).toHaveBeenCalledWith('id IN (:...ids)', { ids: [8] });
  });

  it('rejects a batch when any exam is missing before updating', async () => {
    const examRepo = {
      find: jest.fn().mockResolvedValue([{ id: 8, classId: 3, status: 'active' }]),
      createQueryBuilder: jest.fn(),
    };
    const service = createService(async () => undefined, { examRepo });

    await expect(service.batchArchive([8, 9], 1, true)).rejects.toBeInstanceOf(NotFoundException);
    expect(examRepo.createQueryBuilder).not.toHaveBeenCalled();
  });

  it('checks access for every selected exam before a batch update', async () => {
    const examRepo = {
      find: jest.fn().mockResolvedValue([
        { id: 8, classId: 3, status: 'active' },
        { id: 9, classId: 4, status: 'active' },
      ]),
      createQueryBuilder: jest.fn(),
    };
    const classTeacherRepo = {
      findOne: jest
        .fn()
        .mockResolvedValueOnce({ id: 1 })
        .mockResolvedValueOnce(null),
    };
    const service = createService(async () => undefined, { examRepo, classTeacherRepo });

    await expect(service.batchArchive([8, 9], 21, false)).rejects.toBeInstanceOf(
      ForbiddenException,
    );
    expect(classTeacherRepo.findOne).toHaveBeenNthCalledWith(1, {
      where: { userId: 21, classId: 3 },
    });
    expect(classTeacherRepo.findOne).toHaveBeenNthCalledWith(2, {
      where: { userId: 21, classId: 4 },
    });
    expect(examRepo.createQueryBuilder).not.toHaveBeenCalled();
  });

  it('rejects score updates for archived exams', async () => {
    const manager = {
      findOne: jest.fn().mockResolvedValue({ id: 8, classId: 3, status: 'archived' }),
    };
    const service = createService(async (run) => run(manager));

    await expect(service.updateScore(8, 1, 90, 1, true)).rejects.toBeInstanceOf(
      BadRequestException,
    );
  });
});

describe('QueryExamDto - query transformation', () => {
  const pipe = new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true });

  it.each([
    ['false', false],
    ['0', false],
    ['true', true],
    ['1', true],
  ])('transforms isArchived=%s to %s', async (input, expected) => {
    await expect(
      pipe.transform(
        { isArchived: input },
        { type: 'query', metatype: QueryExamDto, data: undefined },
      ),
    ).resolves.toEqual({ isArchived: expected });
  });
});
