import { ScheduleSyncService } from './schedule-sync.service';
import { ClassSchedule } from '../entities';

describe('ScheduleSyncService — absence threshold', () => {
  it('updates an existing 16:00-17:00 shift so checking in before 17:00 is not absent', async () => {
    const scheduleRepo = {
      find: jest.fn().mockResolvedValue([
        {
          id: 1,
          classId: 10,
          classroomId: 1,
          weekDay: 5,
          startTime: '16:00',
          endTime: '17:00',
          startDate: '2026-07-01',
          endDate: '2026-07-31',
          status: 'active',
        } as ClassSchedule,
      ]),
    };
    const classStudentRepo = {
      find: jest.fn().mockResolvedValue([{ classId: 10, studentId: 20, status: 'active' }]),
    };
    const mappingRepo = {
      find: jest.fn().mockResolvedValue([{ studentId: 20, dingUserId: 'student-1' }]),
    };
    const classRepo = {
      find: jest.fn().mockResolvedValue([{ id: 10, name: '冲刺班' }]),
    };
    const dingTalkService = {
      queryShifts: jest.fn().mockResolvedValue([{ id: 456, name: '冲刺班_16:00-17:00' }]),
      upsertShift: jest.fn().mockResolvedValue(456),
      queryAttendanceGroups: jest.fn().mockResolvedValue([
        { group_id: 123, group_name: '排课_冲刺班', type: 'TURN', member_count: 1 },
      ]),
      updateAttendanceGroup: jest.fn().mockResolvedValue(undefined),
      createAttendanceGroup: jest.fn(),
      scheduleUsers: jest.fn().mockResolvedValue(undefined),
    };

    const service = new ScheduleSyncService(
      scheduleRepo as never,
      classStudentRepo as never,
      mappingRepo as never,
      classRepo as never,
      dingTalkService as never,
    );

    await service.syncAll('2026-07-10', 1);

    expect(dingTalkService.upsertShift).toHaveBeenCalledWith(expect.objectContaining({
      id: 456,
      name: '冲刺班_16:00-17:00',
      setting: expect.objectContaining({ absenteeism_late_minutes: 60 }),
    }));
  });
});

describe('ScheduleSyncService — attendance machine only', () => {
  it('updates a reused attendance group with machine-only restrictions', async () => {
    const scheduleRepo = {
      find: jest.fn().mockResolvedValue([
        {
          id: 1,
          classId: 10,
          classroomId: 1,
          weekDay: 1,
          startTime: '09:00',
          endTime: '16:00',
          startDate: '2026-07-01',
          endDate: '2026-07-31',
          status: 'active',
        } as ClassSchedule,
      ]),
    };
    const classStudentRepo = {
      find: jest.fn().mockResolvedValue([{ classId: 10, studentId: 20, status: 'active' }]),
    };
    const mappingRepo = {
      find: jest.fn().mockResolvedValue([{ studentId: 20, dingUserId: 'student-1' }]),
    };
    const classRepo = {
      find: jest.fn().mockResolvedValue([{ id: 10, name: '冲刺班' }]),
    };
    const dingTalkService = {
      queryShifts: jest.fn().mockResolvedValue([{ id: 456, name: '排课_09:00-16:00' }]),
      upsertShift: jest.fn().mockResolvedValue(456),
      queryAttendanceGroups: jest.fn().mockResolvedValue([
        { group_id: 123, group_name: '排课_冲刺班', type: 'TURN', member_count: 1 },
      ]),
      updateAttendanceGroup: jest.fn().mockResolvedValue(undefined),
      createAttendanceGroup: jest.fn(),
      scheduleUsers: jest.fn().mockResolvedValue(undefined),
    };

    const service = new ScheduleSyncService(
      scheduleRepo as never,
      classStudentRepo as never,
      mappingRepo as never,
      classRepo as never,
      dingTalkService as never,
    );

    await (service.syncAll as unknown as (
      dateFrom: string,
      days: number,
      opUserId: string,
      attendanceMachineOnly: boolean,
    ) => Promise<unknown>)('2026-07-13', 1, 'manager', true);

    expect(dingTalkService.updateAttendanceGroup).toHaveBeenCalledWith(expect.objectContaining({
      id: 123,
      name: '排课_冲刺班',
      owner: 'manager',
      shift_ids: [456],
      attendance_machine_only: true,
    }));
    expect(dingTalkService.createAttendanceGroup).not.toHaveBeenCalled();
  });
});


describe('ScheduleSyncService — partial batch failure', () => {
  it('reports failedBatchCount > 0 when a scheduleUsers batch fails, not full success', async () => {
    const scheduleRepo = {
      find: jest.fn().mockResolvedValue([
        {
          id: 1,
          classId: 10,
          classroomId: 1,
          weekDay: 1,
          startTime: '09:00',
          endTime: '11:00',
          startDate: '2026-07-06',
          endDate: '2026-07-06',
          status: 'active',
        } as ClassSchedule,
        {
          id: 2,
          classId: 20,
          classroomId: 2,
          weekDay: 2,
          startTime: '14:00',
          endTime: '16:00',
          startDate: '2026-07-07',
          endDate: '2026-07-07',
          status: 'active',
        } as ClassSchedule,
      ]),
    };
    const classStudentRepo = {
      find: jest.fn().mockResolvedValue([
        { classId: 10, studentId: 20, status: 'active' },
        { classId: 20, studentId: 30, status: 'active' },
      ]),
    };
    const mappingRepo = {
      find: jest.fn().mockResolvedValue([
        { studentId: 20, dingUserId: 'student-1' },
        { studentId: 30, dingUserId: 'student-2' },
      ]),
    };
    const classRepo = {
      find: jest.fn().mockResolvedValue([
        { id: 10, name: '冲刺班' },
        { id: 20, name: '强化班' },
      ]),
    };

    const scheduleUsers = jest
      .fn()
      .mockResolvedValueOnce(undefined)
      .mockRejectedValueOnce(new Error('钉钉排班失败: rate limited (code=33018)'));

    const dingTalkService = {
      queryShifts: jest.fn().mockResolvedValue([
        { id: 900, name: '排课_09:00-11:00' },
        { id: 901, name: '排课_14:00-16:00' },
      ]),
      upsertShift: jest.fn().mockResolvedValue(900).mockResolvedValueOnce(900).mockResolvedValueOnce(901),
      queryAttendanceGroups: jest.fn().mockResolvedValue([
        { group_id: 777, group_name: '排课_冲刺班', type: 'TURN', member_count: 1 },
      ]),
      updateAttendanceGroup: jest.fn().mockResolvedValue(undefined),
      createAttendanceGroup: jest.fn().mockResolvedValue(888),
      scheduleUsers,
    };

    const service = new ScheduleSyncService(
      scheduleRepo as never,
      classStudentRepo as never,
      mappingRepo as never,
      classRepo as never,
      dingTalkService as never,
    );

    const result = await service.syncAll('2026-07-06', 2);

    expect(scheduleUsers).toHaveBeenCalledTimes(2);
    expect(result.failedBatchCount).toBeGreaterThan(0);
    expect(result.errors).toBeDefined();
    expect(result.errors.length).toBeGreaterThan(0);
    // syncedItems should only count the successful batch
    expect(result.syncedItems).toBeGreaterThan(0);
  });
});

describe('ScheduleSyncService — attendance group failure', () => {
  it('counts createAttendanceGroup failure as real failure, not skippedNoMapping', async () => {
    const scheduleRepo = {
      find: jest.fn().mockResolvedValue([
        {
          id: 1,
          classId: 10,
          classroomId: 1,
          weekDay: 1,
          startTime: '09:00',
          endTime: '11:00',
          startDate: '2026-07-06',
          endDate: '2026-07-06',
          status: 'active',
        } as ClassSchedule,
        {
          id: 2,
          classId: 20,
          classroomId: 2,
          weekDay: 2,
          startTime: '14:00',
          endTime: '16:00',
          startDate: '2026-07-07',
          endDate: '2026-07-07',
          status: 'active',
        } as ClassSchedule,
      ]),
    };
    const classStudentRepo = {
      find: jest.fn().mockResolvedValue([
        { classId: 10, studentId: 20, status: 'active' },
        { classId: 20, studentId: 30, status: 'active' },
      ]),
    };
    const mappingRepo = {
      find: jest.fn().mockResolvedValue([
        { studentId: 20, dingUserId: 'student-1' },
        { studentId: 30, dingUserId: 'student-2' },
      ]),
    };
    const classRepo = {
      find: jest.fn().mockResolvedValue([
        { id: 10, name: '冲刺班' },
        { id: 20, name: '强化班' },
      ]),
    };

    const createAttendanceGroup = jest
      .fn()
      .mockRejectedValue(new Error('钉钉考勤组创建失败: insuffient permission (code=403)'));

    const dingTalkService = {
      queryShifts: jest.fn().mockResolvedValue([
        { id: 900, name: '排课_09:00-11:00' },
        { id: 901, name: '排课_14:00-16:00' },
      ]),
      upsertShift: jest.fn().mockResolvedValue(900),
      queryAttendanceGroups: jest.fn().mockResolvedValue([
        { group_id: 888, group_name: '排课_冲刺班', type: 'TURN', member_count: 1 },
      ]),
      updateAttendanceGroup: jest.fn().mockResolvedValue(undefined),
      createAttendanceGroup,
      scheduleUsers: jest.fn().mockResolvedValue(undefined),
    };

    const service = new ScheduleSyncService(
      scheduleRepo as never,
      classStudentRepo as never,
      mappingRepo as never,
      classRepo as never,
      dingTalkService as never,
    );

    const result = await service.syncAll('2026-07-06', 2);

    // group failure must NOT be counted as skippedNoMapping
    expect(result.skippedNoMapping).toBe(0);
    // group failure must increment failure counters
    expect(result.failedBatchCount).toBeGreaterThan(0);
    expect(result.failedItems).toBeGreaterThan(0);
    // error message must contain the group failure detail
    expect(result.errors).toBeDefined();
    expect(result.errors.some((e) => e.includes('考勤组'))).toBe(true);
    expect(result.errors.some((e) => e.includes('强化班'))).toBe(true);
    // the successful class should still sync
    expect(result.syncedItems).toBeGreaterThan(0);
    expect(result.groupCount).toBe(1);
  });
});

describe('ScheduleSyncService — dedup', () => {
  it('does not write duplicate schedule items for same user/date/shift', async () => {
    const scheduleRepo = {
      find: jest.fn().mockResolvedValue([
        {
          id: 1,
          classId: 10,
          classroomId: 1,
          weekDay: 3,
          startTime: '10:00',
          endTime: '12:00',
          startDate: '2026-07-01',
          endDate: '2026-07-31',
          status: 'active',
        } as ClassSchedule,
        {
          id: 2,
          classId: 10,
          classroomId: 1,
          weekDay: 3,
          startTime: '10:00',
          endTime: '12:00',
          startDate: '2026-07-08',
          endDate: '2026-07-08',
          status: 'active',
        } as ClassSchedule,
      ]),
    };
    const classStudentRepo = {
      find: jest.fn().mockResolvedValue([{ classId: 10, studentId: 20, status: 'active' }]),
    };
    const mappingRepo = {
      find: jest.fn().mockResolvedValue([{ studentId: 20, dingUserId: 'student-1' }]),
    };
    const classRepo = {
      find: jest.fn().mockResolvedValue([{ id: 10, name: '冲刺班' }]),
    };

    const scheduleUsers = jest.fn().mockResolvedValue(undefined);
    const dingTalkService = {
      queryShifts: jest
        .fn()
        .mockResolvedValue([{ id: 456, name: '排课_10:00-12:00' }]),
      upsertShift: jest.fn().mockResolvedValue(456),
      queryAttendanceGroups: jest.fn().mockResolvedValue([
        { group_id: 123, group_name: '排课_冲刺班', type: 'TURN', member_count: 1 },
      ]),
      updateAttendanceGroup: jest.fn().mockResolvedValue(undefined),
      createAttendanceGroup: jest.fn(),
      scheduleUsers,
    };

    const service = new ScheduleSyncService(
      scheduleRepo as never,
      classStudentRepo as never,
      mappingRepo as never,
      classRepo as never,
      dingTalkService as never,
    );

    await service.syncAll('2026-07-01', 31);

    const batchItems = scheduleUsers.mock.calls[0][1] as Array<{ userid: string; work_date: number; shift_id: number }>;

    // 2026-07-08 is a Wednesday (weekDay 3), so both schedules hit that date.
    // The dedup should collapse the two identical {userid, work_date, shift_id} items into one.
    const key = (item: { userid: string; work_date: number; shift_id: number }) =>
      `${item.userid}-${item.work_date}-${item.shift_id}`;

    const seen = new Set<string>();
    for (const item of batchItems) {
      const k = key(item);
      expect(seen.has(k)).toBe(false);
      seen.add(k);
    }

    // Compare the exact Beijing-midnight epoch so this assertion is independent of host timezone.
    const july8Epoch = new Date('2026-07-08T00:00:00+08:00').getTime();
    const july8Items = batchItems.filter((item) => item.work_date === july8Epoch);
    expect(july8Items.length).toBe(1);
  });
});

describe('ScheduleSyncService — all shifts fail', () => {
  it('counts failedBatchCount and failedItems when every shift creation fails, never skippedNoMapping', async () => {
    const scheduleRepo = {
      find: jest.fn().mockResolvedValue([
        {
          id: 1,
          classId: 10,
          classroomId: 1,
          weekDay: 1,
          startTime: '09:00',
          endTime: '11:00',
          startDate: '2026-07-06',
          endDate: '2026-07-06',
          status: 'active',
        } as ClassSchedule,
        {
          id: 2,
          classId: 10,
          classroomId: 1,
          weekDay: 2,
          startTime: '14:00',
          endTime: '16:00',
          startDate: '2026-07-07',
          endDate: '2026-07-07',
          status: 'active',
        } as ClassSchedule,
      ]),
    };
    const classStudentRepo = {
      find: jest.fn().mockResolvedValue([
        { classId: 10, studentId: 20, status: 'active' },
      ]),
    };
    const mappingRepo = {
      find: jest.fn().mockResolvedValue([{ studentId: 20, dingUserId: 'student-1' }]),
    };
    const classRepo = {
      find: jest.fn().mockResolvedValue([{ id: 10, name: '冲刺班' }]),
    };

    const dingTalkService = {
      queryShifts: jest.fn().mockResolvedValue([]),
      upsertShift: jest.fn().mockRejectedValue(new Error('钉钉班次创建失败: permission denied')),
      queryAttendanceGroups: jest.fn().mockResolvedValue([]),
      updateAttendanceGroup: jest.fn(),
      createAttendanceGroup: jest.fn(),
      scheduleUsers: jest.fn(),
    };

    const service = new ScheduleSyncService(
      scheduleRepo as never,
      classStudentRepo as never,
      mappingRepo as never,
      classRepo as never,
      dingTalkService as never,
    );

    const result = await service.syncAll('2026-07-06', 2);

    // All shifts failed → no shifts created
    expect(result.shiftCount).toBe(0);
    // No attendance groups created (no usable shifts)
    expect(result.groupCount).toBe(0);
    // Nothing synced
    expect(result.syncedItems).toBe(0);
    // Must NOT count as skippedNoMapping
    expect(result.skippedNoMapping).toBe(0);
    // Failure counters must reflect the failed shifts
    expect(result.failedBatchCount).toBeGreaterThan(0);
    expect(result.failedItems).toBeGreaterThan(0);
    // Errors must contain shift failure messages
    expect(result.errors).toBeDefined();
    expect(result.errors.length).toBeGreaterThan(0);
    expect(result.errors.some((e) => e.includes('班次'))).toBe(true);
    // No scheduleUsers calls (no group created)
    expect(dingTalkService.scheduleUsers).not.toHaveBeenCalled();
  });
});

describe('ScheduleSyncService — multiple lessons per student per day', () => {
  it('combines daily lessons into one DingTalk shift with multiple sections', async () => {
    const scheduleRepo = {
      find: jest.fn().mockResolvedValue([
        {
          id: 2,
          classId: 10,
          classroomId: 1,
          weekDay: 1,
          startTime: '22:10',
          endTime: '23:10',
          startDate: '2026-07-13',
          endDate: '2026-07-13',
          status: 'active',
        } as ClassSchedule,
        {
          id: 1,
          classId: 10,
          classroomId: 2,
          weekDay: 1,
          startTime: '20:00',
          endTime: '21:00',
          startDate: '2026-07-13',
          endDate: '2026-07-13',
          status: 'active',
        } as ClassSchedule,
      ]),
    };
    const classStudentRepo = {
      find: jest.fn().mockResolvedValue([{ classId: 10, studentId: 20, status: 'active' }]),
    };
    const mappingRepo = {
      find: jest.fn().mockResolvedValue([{ studentId: 20, dingUserId: 'student-1' }]),
    };
    const classRepo = {
      find: jest.fn().mockResolvedValue([{ id: 10, name: '冲刺一班' }]),
    };
    const scheduleUsers = jest.fn().mockResolvedValue(undefined);
    const upsertShift = jest.fn().mockResolvedValue(2022);
    const dingTalkService = {
      queryShifts: jest.fn().mockResolvedValue([]),
      upsertShift,
      queryAttendanceGroups: jest.fn().mockResolvedValue([
        { group_id: 1495610001, group_name: '排课_冲刺一班', type: 'TURN', member_count: 1 },
      ]),
      updateAttendanceGroup: jest.fn().mockResolvedValue(undefined),
      createAttendanceGroup: jest.fn(),
      scheduleUsers,
    };

    const service = new ScheduleSyncService(
      scheduleRepo as never,
      classStudentRepo as never,
      mappingRepo as never,
      classRepo as never,
      dingTalkService as never,
    );

    const result = await service.syncAll('2026-07-13', 1);

    expect(upsertShift).toHaveBeenCalledTimes(1);
    expect(upsertShift).toHaveBeenCalledWith(expect.objectContaining({
      name: '冲刺一班_20:00-21:00+22:10-23:10',
      sections: [
        expect.objectContaining({
          times: expect.arrayContaining([
            expect.objectContaining({ check_type: 'OnDuty', check_time: '1970-01-01 20:00:00' }),
            expect.objectContaining({ check_type: 'OffDuty', check_time: '1970-01-01 21:00:00' }),
          ]),
        }),
        expect.objectContaining({
          times: expect.arrayContaining([
            expect.objectContaining({ check_type: 'OnDuty', check_time: '1970-01-01 22:10:00' }),
            expect.objectContaining({ check_type: 'OffDuty', check_time: '1970-01-01 23:10:00' }),
          ]),
        }),
      ],
    }));
    expect(scheduleUsers).toHaveBeenCalledTimes(1);
    expect(scheduleUsers.mock.calls[0][1]).toEqual([
      expect.objectContaining({ userid: 'student-1', shift_id: 2022 }),
    ]);
    expect(result.syncedItems).toBe(1);
    expect(result.failedBatchCount).toBe(0);
  });
});


describe('ScheduleSyncService — date and overnight boundaries', () => {
  const createService = (schedule: ClassSchedule) => {
    const scheduleUsers = jest.fn().mockResolvedValue(undefined);
    const upsertShift = jest.fn().mockResolvedValue(501);
    const service = new ScheduleSyncService(
      { find: jest.fn().mockResolvedValue([schedule]) } as never,
      { find: jest.fn().mockResolvedValue([{ classId: 10, studentId: 20, status: 'active' }]) } as never,
      { find: jest.fn().mockResolvedValue([{ studentId: 20, dingUserId: 'student-1' }]) } as never,
      { find: jest.fn().mockResolvedValue([{ id: 10, name: '边界班' }]) } as never,
      {
        queryShifts: jest.fn().mockResolvedValue([]), upsertShift,
        queryAttendanceGroups: jest.fn().mockResolvedValue([
          { group_id: 88, group_name: '排课_边界班', type: 'TURN', member_count: 1 },
        ]),
        updateAttendanceGroup: jest.fn().mockResolvedValue(undefined),
        createAttendanceGroup: jest.fn(), scheduleUsers,
      } as never,
    );
    return { service, scheduleUsers, upsertShift };
  };

  it('syncs exactly the requested number of calendar days', async () => {
    const { service, scheduleUsers } = createService({
      id: 1, classId: 10, weekDay: 1, startTime: '09:00', endTime: '10:00',
      startDate: '2026-07-13', endDate: '2026-07-20', status: 'active',
    } as ClassSchedule);
    await service.syncAll('2026-07-13', 7);
    expect(scheduleUsers.mock.calls.flatMap((call) => call[1])).toHaveLength(1);
  });

  it('marks an overnight lesson off-duty time as next-day', async () => {
    const { service, upsertShift } = createService({
      id: 1, classId: 10, weekDay: 1, startTime: '22:00', endTime: '01:00',
      startDate: '2026-07-13', endDate: '2026-07-13', status: 'active',
    } as ClassSchedule);
    await service.syncAll('2026-07-13', 1);
    expect(upsertShift).toHaveBeenCalledWith(expect.objectContaining({
      sections: [expect.objectContaining({ times: expect.arrayContaining([
        expect.objectContaining({ check_type: 'OnDuty', across: 0 }),
        expect.objectContaining({ check_type: 'OffDuty', across: 1 }),
      ]) })],
    }));
  });

  it('does not shift the requested date when the server timezone is behind China', async () => {
    const originalTz = process.env.TZ;
    process.env.TZ = 'America/Los_Angeles';
    try {
      const { service, scheduleUsers } = createService({
        id: 1, classId: 10, weekDay: 1, startTime: '09:00', endTime: '10:00',
        startDate: '2026-07-13', endDate: '2026-07-13', status: 'active',
      } as ClassSchedule);
      await service.syncAll('2026-07-13', 1);
      expect(scheduleUsers).toHaveBeenCalledTimes(1);
    } finally {
      process.env.TZ = originalTz;
    }
  });
});
