import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { getDataSourceToken } from '@nestjs/typeorm';
import { BadRequestException, ValidationPipe } from '@nestjs/common';
import { Repository } from 'typeorm';
import { AttendanceService } from './attendance.service';
import { AttendanceRecord } from '../entities/attendance-record.entity';
import { AttendanceSession } from '../entities/attendance-session.entity';
import { AttendanceDevice } from '../entities/attendance-device.entity';
import { AttendancePeriodConfig } from '../entities/attendance-period-config.entity';
import { DingAttendanceRaw } from '../entities/ding-attendance-raw.entity';
import { Class } from '../entities/class.entity';
import { Student } from '../entities/student.entity';
import { ClassSchedule } from '../entities/class-schedule.entity';
import { ClassStudent } from '../entities/class-student.entity';
import { StudentDingMapping } from '../entities/student-ding-mapping.entity';
import { ClassTeacher } from '../entities/class-teacher.entity';
import { BatchCreateAttendanceDto } from './dto/attendance.dto';

describe('AttendanceService — batchCreate', () => {
  let service: AttendanceService;
  let attendanceRepo: jest.Mocked<Pick<Repository<AttendanceRecord>, 'create' | 'save'>>;

  const savedRecords: AttendanceRecord[] = [];

  beforeEach(async () => {
    savedRecords.length = 0;
    const mockRepo = {
      create: jest
        .fn()
        .mockImplementation((data: Partial<AttendanceRecord>) => ({ id: 1, ...data } as AttendanceRecord)),
      save: jest
        .fn()
        .mockImplementation((entities: AttendanceRecord[]) => {
          const result = entities.map((e, i) => ({ ...e, id: i + 1 }));
          savedRecords.push(...result);
          return Promise.resolve(result);
        }),
    };

    const mockDingRepo = {};
    const mockClassRepo = { find: jest.fn().mockResolvedValue([]) };
    const mockStudentRepo = { find: jest.fn().mockResolvedValue([]) };
    // Reserved for future tests (auto-match, schedule-based attendance, etc.)
    const mockScheduleRepo = { find: jest.fn().mockResolvedValue([]) };
    const mockClassStudentRepo = { find: jest.fn().mockResolvedValue([]) };
    const mockStudentDingMappingRepo = { find: jest.fn().mockResolvedValue([]) };
    const mockAttendanceDeviceRepo = { find: jest.fn().mockResolvedValue([]) };

    const module: TestingModule = await Test.createTestingModule({
      providers: [
        AttendanceService,
        { provide: getRepositoryToken(AttendanceRecord), useValue: mockRepo },
        { provide: getRepositoryToken(DingAttendanceRaw), useValue: mockDingRepo },
        { provide: getRepositoryToken(Class), useValue: mockClassRepo },
        { provide: getRepositoryToken(Student), useValue: mockStudentRepo },
        { provide: getRepositoryToken(ClassSchedule), useValue: mockScheduleRepo },
        { provide: getRepositoryToken(StudentDingMapping), useValue: mockStudentDingMappingRepo },
        { provide: getRepositoryToken(ClassStudent), useValue: mockClassStudentRepo },
        { provide: getRepositoryToken(ClassTeacher), useValue: { findOne: jest.fn() } },
        { provide: getRepositoryToken(AttendanceSession), useValue: {} },
        { provide: getRepositoryToken(AttendanceDevice), useValue: mockAttendanceDeviceRepo },
        { provide: getRepositoryToken(AttendancePeriodConfig), useValue: {} },
        { provide: getDataSourceToken(), useValue: { transaction: jest.fn() } },
      ],
    }).compile();

    service = module.get<AttendanceService>(AttendanceService);
    attendanceRepo = module.get(getRepositoryToken(AttendanceRecord));
  });

  it('valid batch with morning_reading, evening_study, and night_check sessions → succeeds', async () => {
    const dto: BatchCreateAttendanceDto = {
      records: [
        { studentId: 1, classId: 10, attendanceDate: '2026-07-05', session: 'morning_reading', status: 'present' },
        { studentId: 2, classId: 10, attendanceDate: '2026-07-05', session: 'evening_study', status: 'present' },
        { studentId: 3, classId: 10, attendanceDate: '2026-07-05', session: 'night_check', status: 'present' },
      ],
    };

    const result = await service.batchCreate(dto);

    expect(result.count).toBe(3);
    expect(result.records).toHaveLength(3);
    expect(result.records[0].session).toBe('morning_reading');
    expect(result.records[1].session).toBe('evening_study');
    expect(result.records[2].session).toBe('night_check');
  });

  it('invalid session → validation error (DTO-level)', async () => {
    const pipe = new ValidationPipe({ whitelist: true });

    const invalidPayload = {
      records: [
        { studentId: 1, attendanceDate: '2026-07-05', session: 'invalid_session', status: 'present' },
      ],
    };

    await expect(
      pipe.transform(invalidPayload, {
        type: 'body',
        metatype: BatchCreateAttendanceDto,
      }),
    ).rejects.toThrow();
  });

  it('empty batch → BadRequestException', async () => {
    const dto: BatchCreateAttendanceDto = { records: [] };

    await expect(service.batchCreate(dto)).rejects.toThrow(BadRequestException);
  });

  it.skip('autoMatchDingRecords with StudentDingMapping chain', async () => {
    // TODO: match dingtalk raw records to students via StudentDingMapping lookup,
    // then to class schedules → ClassStudent association, producing attendance records.
    // Requires mock setup for StudentDingMapping, ClassSchedule, ClassStudent, and DingAttendanceRaw repos.
  });
});

describe('AttendanceService — teacher DingTalk class scope', () => {
  const classTeacherRepo = {
    findOne: jest.fn(),
    find: jest.fn(),
  };
  const classStudentRepo = {
    find: jest.fn(),
  };
  const mappingRepo = {
    find: jest.fn(),
  };

  const createService = () =>
    new AttendanceService(
      {} as never,
      {} as never,
      {} as never,
      {} as never,
      {} as never,
      classStudentRepo as never,
      mappingRepo as never,
      classTeacherRepo as never,
      {} as never,
      {} as never,
      {} as never,
      {} as never,
    );

  beforeEach(() => {
    jest.clearAllMocks();
  });

  it('returns only mapped active students for a class assigned to the teacher', async () => {
    classTeacherRepo.findOne.mockResolvedValue({ classId: 8, userId: 21 });
    classStudentRepo.find.mockResolvedValue([
      { studentId: 2 },
      { studentId: 1 },
      { studentId: 2 },
    ]);
    mappingRepo.find.mockResolvedValue([
      { studentId: 1, dingUserId: 'ding-1' },
      { studentId: 2, dingUserId: 'ding-2' },
    ]);

    await expect(createService().getTeacherClassDingUserIds(21, 8, false)).resolves.toEqual([
      'ding-1',
      'ding-2',
    ]);
  });

  it('lists distinct classes assigned to a teacher', async () => {
    classTeacherRepo.find.mockResolvedValue([
      { classId: 8, class: { name: '八班' } },
      { classId: 8, class: { name: '八班' } },
      { classId: 9, class: { name: '九班' } },
    ]);

    await expect(createService().getImportableClasses(21, false)).resolves.toEqual([
      { classId: 8, className: '八班' },
      { classId: 9, className: '九班' },
    ]);
    expect(classTeacherRepo.find).toHaveBeenCalledWith({
      where: { userId: 21 },
      relations: ['class'],
    });
  });

  it('rejects a class that is not assigned to the teacher', async () => {
    classTeacherRepo.findOne.mockResolvedValue(null);

    await expect(createService().getTeacherClassDingUserIds(21, 99, false)).rejects.toBeInstanceOf(
      BadRequestException,
    );
  });
});

describe('AttendanceService — DingTalk raw query', () => {
  it('returns the paginated shape and filters by class student mappings', async () => {
    const qb = {
      leftJoinAndSelect: jest.fn().mockReturnThis(),
      andWhere: jest.fn().mockReturnThis(),
      orderBy: jest.fn().mockReturnThis(),
      addOrderBy: jest.fn().mockReturnThis(),
      skip: jest.fn().mockReturnThis(),
      take: jest.fn().mockReturnThis(),
      getManyAndCount: jest.fn().mockResolvedValue([[{ id: 1 }], 1]),
    };
    const dingRepo = { createQueryBuilder: jest.fn().mockReturnValue(qb) };
    const classStudentRepo = { find: jest.fn().mockResolvedValue([{ studentId: 3 }]) };
    const mappingRepo = { find: jest.fn().mockResolvedValue([{ studentId: 3, dingUserId: 'ding-3' }]) };
    const service = new AttendanceService(
      {} as never,
      dingRepo as never,
      {} as never,
      {} as never,
      {} as never,
      classStudentRepo as never,
      mappingRepo as never,
      {} as never,
      {} as never,
      {} as never,
      {} as never,
      {} as never,
    );

    await expect(
      service.getDingRaw({ classId: 8, dateFrom: '2026-07-01', page: 2, pageSize: 10 }),
    ).resolves.toEqual({ list: [{ id: 1 }], total: 1, page: 2, pageSize: 10 });

    expect(qb.andWhere).toHaveBeenCalledWith('ar.dingUserId IN (:...dingUserIds)', {
      dingUserIds: ['ding-3'],
    });
    expect(qb.andWhere).toHaveBeenCalledWith('ar.attendanceDate >= :dateFrom', {
      dateFrom: '2026-07-01',
    });
    expect(qb.skip).toHaveBeenCalledWith(10);
    expect(qb.take).toHaveBeenCalledWith(10);
  });
});


describe('AttendanceService — attendance device display mappings', () => {
  function createHistoryQueryBuilder(records: AttendanceRecord[]) {
    return {
      leftJoinAndSelect: jest.fn().mockReturnThis(),
      andWhere: jest.fn().mockReturnThis(),
      orderBy: jest.fn().mockReturnThis(),
      addOrderBy: jest.fn().mockReturnThis(),
      skip: jest.fn().mockReturnThis(),
      take: jest.fn().mockReturnThis(),
      getManyAndCount: jest.fn().mockResolvedValue([records, records.length]),
      getMany: jest.fn().mockResolvedValue(records),
    };
  }

  function createServiceWithRecords(records: AttendanceRecord[]) {
    const qb = createHistoryQueryBuilder(records);
    const attendanceRepo = { createQueryBuilder: jest.fn().mockReturnValue(qb) };
    const attendanceDeviceRepo = {
      find: jest.fn().mockImplementation(async (options: { where?: Record<string, unknown> }) => {
        if (options.where && 'deviceSn' in options.where) {
          return [
            {
              id: 1,
              deviceSn: 'ATM-01',
              deviceName: '东门考勤机',
              classroomId: 8,
              classroom: { id: 8, name: '一号教室' },
              status: 'disabled',
            },
          ];
        }
        return [];
      }),
    };
    const service = new AttendanceService(
      attendanceRepo as never,
      {} as never,
      {} as never,
      {} as never,
      {} as never,
      {} as never,
      {} as never,
      {} as never,
      {} as never,
      attendanceDeviceRepo as never,
      {} as never,
      {} as never,
    );
    return { service, qb, attendanceDeviceRepo };
  }

  it('maps history list punch device ids to configured attendance device names', async () => {
    const record = {
      id: 1,
      classId: 8,
      status: 'present',
      source: 'dingtalk',
      punchSource: 'ATM',
      punchDeviceId: 'ATM-01',
      punchDeviceName: '钉钉原始设备名',
    } as AttendanceRecord;
    const { service, attendanceDeviceRepo } = createServiceWithRecords([record]);

    await expect(service.findAll({}, [8])).resolves.toMatchObject({
      list: [
        {
          punchDeviceId: 'ATM-01',
          punchDeviceName: '东门考勤机 · 一号教室',
        },
      ],
      total: 1,
    });
    expect(attendanceDeviceRepo.find).toHaveBeenCalledWith({
      where: { deviceSn: expect.any(Object) },
      relations: ['classroom'],
    });
  });

  it('maps exported punch device ids to configured attendance device names', async () => {
    const record = {
      id: 2,
      classId: 8,
      status: 'present',
      source: 'dingtalk',
      punchSource: 'ATM',
      punchDeviceId: 'ATM-01',
      punchDeviceName: '钉钉原始设备名',
    } as AttendanceRecord;
    const { service } = createServiceWithRecords([record]);

    await expect(service.findAllForExport({}, [8])).resolves.toEqual([
      expect.objectContaining({
        punchDeviceId: 'ATM-01',
        punchDeviceName: '东门考勤机 · 一号教室',
      }),
    ]);
  });
});



// ── Session serialization tests ──
function deferred<T>(): {
  promise: Promise<T>;
  resolve: (value: T | PromiseLike<T>) => void;
  reject: (reason?: unknown) => void;
} {
  let resolve!: (value: T | PromiseLike<T>) => void;
  let reject!: (reason?: unknown) => void;
  const promise = new Promise<T>((res, rej) => {
    resolve = res;
    reject = rej;
  });
  return { promise, resolve, reject };
}

describe('AttendanceService — session serialization', () => {
  const sessionId = 100;
  const recordId = 200;
  const sessionInProgress = { id: sessionId, status: 'in_progress' };
  const sessionCompleted = { id: sessionId, status: 'completed' };
  const record: Record<string, unknown> = {
    id: recordId,
    attendanceSessionId: sessionId,
    studentId: 1,
    status: 'present',
    source: 'dingtalk',
    attendanceDate: '2026-07-01',
    session: 'morning',
  };

  function makeService(
    dataSourceMock: { transaction: jest.Mock },
    attendanceRepoOverrides?: Record<string, jest.Mock>,
  ) {
    const defaultAttendanceRepo = {
      findOne: jest.fn().mockResolvedValue(record),
      find: jest.fn().mockResolvedValue([record]),
      save: jest.fn().mockImplementation((r: unknown) => Promise.resolve(r)),
      remove: jest.fn().mockResolvedValue(undefined),
      create: jest.fn(),
      createQueryBuilder: jest.fn(),
    };
    const attendanceRepo = { ...defaultAttendanceRepo, ...attendanceRepoOverrides };

    return new AttendanceService(
      attendanceRepo as never,
      {} as never,
      {} as never,
      {} as never,
      {} as never,
      {} as never,
      {} as never,
      {} as never,
      {} as never,
      { find: jest.fn().mockResolvedValue([]) } as never,
      {} as never,
      dataSourceMock as never,
    );
  }

  function makeTxManager(sessionStatus: string) {
    const session = sessionStatus === 'completed' ? { ...sessionCompleted } : { ...sessionInProgress };

    const sessionRepo = {
      findOne: jest.fn().mockResolvedValue(session),
      save: jest.fn().mockImplementation((s: unknown) => Promise.resolve(s)),
    };

    const recordRepo = {
      findOne: jest.fn().mockResolvedValue(record),
      find: jest.fn().mockResolvedValue([record]),
      count: jest.fn().mockResolvedValue(0),
      save: jest.fn().mockImplementation((r: unknown) => Promise.resolve(r)),
      remove: jest.fn().mockResolvedValue(undefined),
    };

    return {
      getRepository: jest.fn((entity: unknown) => {
        if (entity === AttendanceSession) return sessionRepo;
        if (entity === AttendanceRecord) return recordRepo;
        throw new Error('Unexpected entity');
      }),
      sessionRepo,
      recordRepo,
    };
  }

  it('complete holds lock; queued update is rejected after session becomes completed', async () => {
    // For this test, the manager has a completed session
    // The complete callback is stalled, update queues and then finds completed
    const manager = makeTxManager('completed');
    const completeStall = deferred<unknown>();

    const txMock = jest
      .fn()
      .mockImplementationOnce(() => completeStall.promise)
      .mockImplementationOnce((cb: (m: unknown) => unknown) => cb(manager));

    const svc = makeService({ transaction: txMock });

    const completeP = svc.completeLessonAttendance(sessionId, 1);
    const updateP = svc.update(recordId, { status: 'absent' });

    completeStall.resolve({ session: sessionCompleted, records: [record] });

    await expect(completeP).resolves.toEqual({ session: sessionCompleted, records: [record] });
    await expect(updateP).rejects.toThrow(BadRequestException);

    expect(manager.sessionRepo.findOne).toHaveBeenCalled();
  });

  it('update holds lock; complete reads current state after update finishes', async () => {
    const manager = makeTxManager('in_progress');
    const updateStall = deferred<unknown>();

    // Track call order
    const callOrder: string[] = [];
    let transactionsRun = 0;

    const txMock = jest.fn().mockImplementation((cb: (m: unknown) => unknown) => {
      transactionsRun++;
      if (transactionsRun === 1) {
        // update: stall
        callOrder.push('update-tx-started');
        return updateStall.promise.then((v) => {
          callOrder.push('update-tx-resolved');
          return v;
        });
      }
      // complete: actually run the callback
      callOrder.push('complete-tx-started');
      return cb(manager);
    });

    const svc = makeService({ transaction: txMock });

    // Start update but DON'T await — it will stall
    const updateP = svc.update(recordId, { status: 'absent' });

    // Give update time to enter the mutex and transaction
    await Promise.resolve();
    await Promise.resolve();
    await Promise.resolve();
    await Promise.resolve();

    // Start complete while update is stalled
    const completeP = svc.completeLessonAttendance(sessionId, 1);

    // Another tick
    await Promise.resolve();

    // Resolve update's stalled transaction
    updateStall.resolve(record);

    const updateResult = await updateP;
    expect(updateResult).toEqual(record);

    const completeResult = await completeP;

    // Verify the callback was actually run correctly
    expect(callOrder).toContain('update-tx-started');
    expect(callOrder).toContain('complete-tx-started');

    expect(completeResult).toHaveProperty('session');
    expect(completeResult).toHaveProperty('records');
  });

  it('records without attendanceSessionId bypass the mutex and keep original behaviour', async () => {
    const noSessionRecord = { ...record, attendanceSessionId: null };

    const overrides = {
      findOne: jest.fn().mockResolvedValue(noSessionRecord),
      save: jest.fn().mockImplementation((r: unknown) => Promise.resolve(r)),
    };

    const txMock = jest.fn();
    const svc = makeService({ transaction: txMock }, overrides);

    const result = await svc.update(recordId, { status: 'absent' });
    expect(result).toEqual(noSessionRecord);
    expect(txMock).not.toHaveBeenCalled();

    const removeResult = await svc.remove(recordId);
    expect(removeResult).toEqual({ deleted: true });
  });

  it('complete is idempotent: returns current state when session already completed', async () => {
    const manager = makeTxManager('completed');

    const txMock = jest
      .fn()
      .mockImplementation((cb: (m: unknown) => unknown) => cb(manager));

    const svc = makeService({ transaction: txMock });

    const result = await svc.completeLessonAttendance(sessionId, 1);

    expect(result).toHaveProperty('session');
    expect(result).toHaveProperty('records');
    expect(manager.recordRepo.count).not.toHaveBeenCalled();
  });
});
