import { Test, TestingModule } from '@nestjs/testing';
import { getDataSourceToken } from '@nestjs/typeorm';
import { QrAttendanceService } from './qr-attendance.service';
import { QrAttendanceRepository } from './qr-attendance.repository';
import { AwsS3Service } from 'src/common/services/awsS3.service';
import { ExcelService } from 'src/common/services/excel.service';
import { AppLoggerService } from 'src/common/services/logger.service';
import { InifniNotFoundException } from 'src/common/exceptions/infini-notfound-exception';
import InifniBadRequestException from 'src/common/exceptions/infini-badrequest-exception';
import InifniConflictException from 'src/common/exceptions/infini-conflict-exception';
import { RegistrationStatusEnum } from 'src/common/enum/registration-status.enum';
import { PaymentStatusEnum } from 'src/common/enum/payment-status.enum';
import { ExportJobStatus } from 'src/common/enum/export-job-status.enum';
import { ERROR_CODES } from 'src/common/constants/error-string-constants';

jest.mock('qrcode', () => ({
  toDataURL: jest.fn((_text, _opts, cb) =>
    cb(null, 'data:image/jpeg;base64,dGVzdA==')),
}));

jest.mock('src/common/utils/handle-error.util', () => ({
  handleKnownErrors: jest.fn((_code: string, err: unknown) => { throw err; }),
}));

const makeMockAttendance = (overrides: Record<string, unknown> = {}) => ({
  id: 1,
  registrationId: 10,
  isAttended: false,
  isManuallyCheckedIn: false,
  checkedInAt: null,
  checkedInByUserId: null,
  qrUrl: 'https://s3.example.com/qr.jpeg',
  sessionId: null,
  ...overrides,
});

const makeMockRegistration = (overrides: Record<string, unknown> = {}) => ({
  id: 10,
  userId: 5,
  programId: 3,
  registrationSeqNumber: 'SEQ001',
  fullName: 'Test User',
  emailAddress: 'test@example.com',
  mobileNumber: '9999999999',
  registrationStatus: RegistrationStatusEnum.COMPLETED,
  isFreeSeat: false,
  program: { id: 3, requiresPayment: false },
  paymentDetails: [],
  user: { uuid: 'uuid-1', fullName: 'Test User' },
  rmContactUser: null,
  ...overrides,
});

describe('QrAttendanceService', () => {
  let service: QrAttendanceService;

  const mockManager = {};
  const mockDataSource = {
    transaction: jest.fn().mockImplementation((cb) => cb(mockManager)),
  };

  const mockRepo = {
    findRegistrationById: jest.fn(),
    findRegistrationsWithoutQr: jest.fn(),
    findByRegistrationId: jest.fn(),
    findById: jest.fn(),
    findList: jest.fn(),
    getAttendanceCounts: jest.fn(),
    createAttendance: jest.fn(),
    saveAttendance: jest.fn(),
    findAttendancesByIds: jest.fn(),
    findExistingAttendancesByIds: jest.fn(),
    findSessionLockStatus: jest.fn().mockResolvedValue(null),
    createBulkJob: jest.fn(),
    findBulkJobById: jest.fn(),
    updateBulkJob: jest.fn(),
    updateBulkJobStatus: jest.fn(),
  };

  const mockAwsS3 = {
    uploadToS3: jest.fn().mockResolvedValue('https://s3.example.com/qr.jpeg'),
    getS3Url: jest.fn().mockReturnValue('https://s3.example.com/folder'),
  };

  const mockExcel = {
    jsonToExcelAndUpload: jest.fn().mockResolvedValue('https://s3.example.com/file.xlsx'),
  };

  const mockLogger = { log: jest.fn(), error: jest.fn(), warn: jest.fn() };

  beforeEach(async () => {
    jest.clearAllMocks();
    mockDataSource.transaction.mockImplementation((cb) => cb(mockManager));
    mockRepo.findSessionLockStatus.mockResolvedValue(null);

    const module: TestingModule = await Test.createTestingModule({
      providers: [
        QrAttendanceService,
        { provide: QrAttendanceRepository, useValue: mockRepo },
        { provide: AwsS3Service, useValue: mockAwsS3 },
        { provide: ExcelService, useValue: mockExcel },
        { provide: AppLoggerService, useValue: mockLogger },
        { provide: getDataSourceToken(), useValue: mockDataSource },
      ],
    }).compile();

    service = module.get<QrAttendanceService>(QrAttendanceService);
  });

  // ─── generateQr ──────────────────────────────────────────────────────────────

  describe('generateQr', () => {
    const dto = { registrationId: 10, sessionId: undefined, createdBy: 1, updatedBy: 1 };

    it('throws NotFoundException when registration does not exist', async () => {
      mockRepo.findRegistrationById.mockResolvedValue(null);

      await expect(service.generateQr(dto)).rejects.toThrow(InifniNotFoundException);
    });

    it('throws BadRequestException when registration status is REJECTED', async () => {
      mockRepo.findRegistrationById.mockResolvedValue(
        makeMockRegistration({ registrationStatus: RegistrationStatusEnum.REJECTED }),
      );

      await expect(service.generateQr(dto)).rejects.toThrow();
    });

    it('throws BadRequestException when registration status is SAVE_AS_DRAFT', async () => {
      mockRepo.findRegistrationById.mockResolvedValue(
        makeMockRegistration({ registrationStatus: RegistrationStatusEnum.SAVE_AS_DRAFT }),
      );

      await expect(service.generateQr(dto)).rejects.toThrow();
    });

    it('throws BadRequestException when program requires payment but none completed', async () => {
      mockRepo.findRegistrationById.mockResolvedValue(
        makeMockRegistration({
          isFreeSeat: false,
          program: { id: 3, requiresPayment: true },
          paymentDetails: [{ paymentStatus: PaymentStatusEnum.ONLINE_PENDING }],
        }),
      );

      await expect(service.generateQr(dto)).rejects.toThrow(InifniBadRequestException);
    });

    it('returns existing attendance when QR already generated', async () => {
      const existing = makeMockAttendance();
      mockRepo.findRegistrationById.mockResolvedValue(makeMockRegistration());
      mockRepo.findByRegistrationId.mockResolvedValue(existing);

      const result = await service.generateQr(dto);

      expect(result).toEqual(existing);
      expect(mockRepo.createAttendance).not.toHaveBeenCalled();
    });

    it('creates new attendance record for eligible registration (no payment required)', async () => {
      const created = makeMockAttendance();
      mockRepo.findRegistrationById.mockResolvedValue(makeMockRegistration());
      mockRepo.findByRegistrationId.mockResolvedValue(null);
      mockRepo.createAttendance.mockResolvedValue(created);

      const result = await service.generateQr(dto);

      expect(mockAwsS3.uploadToS3).toHaveBeenCalled();
      expect(mockRepo.createAttendance).toHaveBeenCalledWith(
        expect.objectContaining({ registrationId: 10, isAttended: false }),
        mockManager,
      );
      expect(result).toEqual(created);
    });

    it('creates attendance when registration is free seat for a paid program', async () => {
      const created = makeMockAttendance();
      mockRepo.findRegistrationById.mockResolvedValue(
        makeMockRegistration({
          isFreeSeat: true,
          program: { id: 3, requiresPayment: true },
          paymentDetails: [],
        }),
      );
      mockRepo.findByRegistrationId.mockResolvedValue(null);
      mockRepo.createAttendance.mockResolvedValue(created);

      const result = await service.generateQr(dto);

      expect(mockRepo.createAttendance).toHaveBeenCalled();
      expect(result).toEqual(created);
    });

    it('creates attendance when payment is ONLINE_COMPLETED', async () => {
      const created = makeMockAttendance();
      mockRepo.findRegistrationById.mockResolvedValue(
        makeMockRegistration({
          isFreeSeat: false,
          program: { id: 3, requiresPayment: true },
          paymentDetails: [{ paymentStatus: PaymentStatusEnum.ONLINE_COMPLETED }],
        }),
      );
      mockRepo.findByRegistrationId.mockResolvedValue(null);
      mockRepo.createAttendance.mockResolvedValue(created);

      const result = await service.generateQr(dto);

      expect(result).toEqual(created);
    });

    it('creates attendance when payment is OFFLINE_COMPLETED', async () => {
      const created = makeMockAttendance();
      mockRepo.findRegistrationById.mockResolvedValue(
        makeMockRegistration({
          isFreeSeat: false,
          program: { id: 3, requiresPayment: true },
          paymentDetails: [{ paymentStatus: PaymentStatusEnum.OFFLINE_COMPLETED }],
        }),
      );
      mockRepo.findByRegistrationId.mockResolvedValue(null);
      mockRepo.createAttendance.mockResolvedValue(created);

      const result = await service.generateQr(dto);

      expect(result).toEqual(created);
    });
  });

  // ─── startBulkGeneration ─────────────────────────────────────────────────────

  describe('startBulkGeneration', () => {
    const dto = { programId: 3, sessionId: 7, batchSize: 5 };

    it('returns jobId, status, and total immediately', async () => {
      const registrations = [makeMockRegistration(), makeMockRegistration({ id: 11 })];
      const job = { id: 42, status: ExportJobStatus.PROCESSING, total: 2 };

      mockRepo.findRegistrationsWithoutQr.mockResolvedValue(registrations);
      mockRepo.createBulkJob.mockResolvedValue(job);
      mockRepo.findByRegistrationId.mockResolvedValue(null);
      mockRepo.createAttendance.mockResolvedValue(makeMockAttendance());
      mockRepo.updateBulkJob.mockResolvedValue(undefined);
      mockRepo.updateBulkJobStatus.mockResolvedValue(undefined);

      const result = await service.startBulkGeneration(dto);

      expect(result).toMatchObject({ jobId: 42, status: ExportJobStatus.PROCESSING, total: 2 });
      expect(mockRepo.findRegistrationsWithoutQr).toHaveBeenCalledWith(3);
      expect(mockRepo.createBulkJob).toHaveBeenCalledWith(
        expect.objectContaining({ total: 2, programId: 3 }),
      );
    });

    it('creates job with zero total when no eligible registrations exist', async () => {
      const job = { id: 43, status: ExportJobStatus.PROCESSING, total: 0 };

      mockRepo.findRegistrationsWithoutQr.mockResolvedValue([]);
      mockRepo.createBulkJob.mockResolvedValue(job);
      mockRepo.updateBulkJobStatus.mockResolvedValue(undefined);

      const result = await service.startBulkGeneration(dto);

      expect(result).toMatchObject({ jobId: 43, total: 0 });
    });
  });

  // ─── getBulkJobStatus ────────────────────────────────────────────────────────

  describe('getBulkJobStatus', () => {
    it('throws NotFoundException when job does not exist', async () => {
      mockRepo.findBulkJobById.mockResolvedValue(null);

      await expect(service.getBulkJobStatus(99)).rejects.toThrow(InifniNotFoundException);
    });

    it('returns job details when found', async () => {
      const job = { id: 42, status: ExportJobStatus.PROCESSING, total: 5, generated: 2 };
      mockRepo.findBulkJobById.mockResolvedValue(job);

      const result = await service.getBulkJobStatus(42);

      expect(result).toEqual(job);
      expect(mockRepo.findBulkJobById).toHaveBeenCalledWith(42);
    });

    it('returns completed job status', async () => {
      const job = {
        id: 42,
        status: ExportJobStatus.COMPLETED,
        total: 5,
        generated: 4,
        skipped: 1,
        failed: 0,
      };
      mockRepo.findBulkJobById.mockResolvedValue(job);

      const result = await service.getBulkJobStatus(42);

      expect(result.status).toBe(ExportJobStatus.COMPLETED);
    });
  });

  // ─── scanQr ──────────────────────────────────────────────────────────────────

  describe('scanQr', () => {
    const dto = { registrationId: 10, isManuallyCheckedIn: false, checkedInByUserId: 2 };

    it('throws BadRequestException when no attendance record found (invalid QR)', async () => {
      mockRepo.findByRegistrationId.mockResolvedValue(null);

      await expect(service.scanQr(dto)).rejects.toThrow(InifniBadRequestException);
    });

    it('returns existing record without updating when already checked in', async () => {
      const attended = makeMockAttendance({ isAttended: true, checkedInAt: new Date() });
      mockRepo.findByRegistrationId.mockResolvedValue(attended);

      const result = await service.scanQr(dto);

      expect(mockRepo.saveAttendance).not.toHaveBeenCalled();
      expect(result).toEqual(attended);
    });

    it('marks isAttended = true and sets checkedInAt on successful scan', async () => {
      const attendance = makeMockAttendance({ isAttended: false });
      const saved = { ...attendance, isAttended: true, checkedInAt: new Date(), checkedInByUserId: 2 };

      mockRepo.findByRegistrationId.mockResolvedValue(attendance);
      mockRepo.saveAttendance.mockResolvedValue(saved);

      const result = await service.scanQr(dto);

      expect(mockRepo.saveAttendance).toHaveBeenCalledWith(
        expect.objectContaining({ isAttended: true, checkedInByUserId: 2 }),
        mockManager,
      );
      expect(result.isAttended).toBe(true);
    });

    it('sets isManuallyCheckedIn = false when scanning via QR', async () => {
      const attendance = makeMockAttendance();
      mockRepo.findByRegistrationId.mockResolvedValue(attendance);
      mockRepo.saveAttendance.mockImplementation((a) => Promise.resolve(a));

      await service.scanQr({ ...dto, isManuallyCheckedIn: false });

      expect(mockRepo.saveAttendance).toHaveBeenCalledWith(
        expect.objectContaining({ isManuallyCheckedIn: false }),
        mockManager,
      );
    });

    it('sets isManuallyCheckedIn = true when flag is passed', async () => {
      const attendance = makeMockAttendance();
      mockRepo.findByRegistrationId.mockResolvedValue(attendance);
      mockRepo.saveAttendance.mockImplementation((a) => Promise.resolve(a));

      await service.scanQr({ ...dto, isManuallyCheckedIn: true });

      expect(mockRepo.saveAttendance).toHaveBeenCalledWith(
        expect.objectContaining({ isManuallyCheckedIn: true }),
        mockManager,
      );
    });

    it('blocks the scan once the Coordinator has locked the session', async () => {
      const attendance = makeMockAttendance({ sessionId: 7 });
      mockRepo.findByRegistrationId.mockResolvedValue(attendance);
      mockRepo.findSessionLockStatus.mockResolvedValue({ isAttendanceLocked: true });

      await expect(service.scanQr(dto)).rejects.toBeInstanceOf(InifniConflictException);
      expect(mockRepo.saveAttendance).not.toHaveBeenCalled();
    });
  });

  // ─── manualCheckin ───────────────────────────────────────────────────────────

  describe('manualCheckin', () => {
    const dto = { registrationId: 10, sessionId: 7, notes: 'VIP', checkedInByUserId: 2 };

    it('throws NotFoundException when attendance record does not exist', async () => {
      mockRepo.findByRegistrationId.mockResolvedValue(null);

      await expect(service.manualCheckin(dto)).rejects.toThrow(InifniNotFoundException);
    });

    it('marks isManuallyCheckedIn = true and saves', async () => {
      const attendance = makeMockAttendance();
      const saved = { ...attendance, isAttended: true, isManuallyCheckedIn: true, checkedInByUserId: 2, sessionId: 7 };

      mockRepo.findByRegistrationId.mockResolvedValue(attendance);
      mockRepo.saveAttendance.mockResolvedValue(saved);

      const result = await service.manualCheckin(dto);

      expect(mockRepo.saveAttendance).toHaveBeenCalledWith(
        expect.objectContaining({
          isAttended: true,
          isManuallyCheckedIn: true,
          checkedInByUserId: 2,
          sessionId: 7,
        }),
        mockManager,
      );
      expect(result.isManuallyCheckedIn).toBe(true);
    });

    it('sets checkedInAt to a valid date', async () => {
      const attendance = makeMockAttendance();
      mockRepo.findByRegistrationId.mockResolvedValue(attendance);
      mockRepo.saveAttendance.mockImplementation((a) => Promise.resolve(a));

      await service.manualCheckin(dto);

      const saved = mockRepo.saveAttendance.mock.calls[0][0];
      expect(saved.checkedInAt).toBeInstanceOf(Date);
    });

    it('does not override sessionId when not provided in dto', async () => {
      const attendance = makeMockAttendance({ sessionId: 5 });
      mockRepo.findByRegistrationId.mockResolvedValue(attendance);
      mockRepo.saveAttendance.mockImplementation((a) => Promise.resolve(a));

      await service.manualCheckin({ registrationId: 10, checkedInByUserId: 2 });

      const saved = mockRepo.saveAttendance.mock.calls[0][0];
      expect(saved.sessionId).toBe(5);
    });

    it('blocks a non-Coordinator manual check-in once the session is locked', async () => {
      const attendance = makeMockAttendance({ sessionId: 7 });
      mockRepo.findByRegistrationId.mockResolvedValue(attendance);
      mockRepo.findSessionLockStatus.mockResolvedValue({ isAttendanceLocked: true });

      await expect(
        service.manualCheckin({ ...dto, roles: ['admin'] }),
      ).rejects.toBeInstanceOf(InifniConflictException);
      expect(mockRepo.saveAttendance).not.toHaveBeenCalled();
    });

    it('still lets a Coordinator manually check in once the session is locked', async () => {
      const attendance = makeMockAttendance({ sessionId: 7 });
      mockRepo.findByRegistrationId.mockResolvedValue(attendance);
      mockRepo.findSessionLockStatus.mockResolvedValue({ isAttendanceLocked: true });
      mockRepo.saveAttendance.mockImplementation((a) => Promise.resolve(a));

      const result = await service.manualCheckin({ ...dto, roles: ['shoba'] });

      expect(result.isManuallyCheckedIn).toBe(true);
      expect(mockRepo.saveAttendance).toHaveBeenCalled();
    });
  });

  // ─── undoCheckin ─────────────────────────────────────────────────────────────

  describe('undoCheckin', () => {
    it('resets attendance fields for all provided IDs', async () => {
      const records = [
        makeMockAttendance({ id: 1, isAttended: true, checkedInAt: new Date(), checkedInByUserId: 2 }),
        makeMockAttendance({ id: 2, isAttended: true, checkedInAt: new Date(), checkedInByUserId: 2 }),
      ];

      mockRepo.findAttendancesByIds.mockResolvedValue(records);
      mockRepo.saveAttendance.mockImplementation((a) => Promise.resolve(a));

      await service.undoCheckin({ attendanceIds: [1, 2] });

      expect(mockRepo.saveAttendance).toHaveBeenCalledTimes(2);
      for (const call of mockRepo.saveAttendance.mock.calls) {
        expect(call[0].isAttended).toBe(false);
        expect(call[0].isManuallyCheckedIn).toBe(false);
        expect(call[0].checkedInAt).toBeNull();
        expect(call[0].checkedInByUserId).toBeNull();
      }
    });

    it('calls findAttendancesByIds with the correct IDs', async () => {
      mockRepo.findAttendancesByIds.mockResolvedValue([]);
      mockRepo.saveAttendance.mockResolvedValue(undefined);

      await service.undoCheckin({ attendanceIds: [3, 4, 5] });

      expect(mockRepo.findAttendancesByIds).toHaveBeenCalledWith([3, 4, 5], mockManager);
    });
  });

  // ─── findById ────────────────────────────────────────────────────────────────

  describe('findById', () => {
    it('returns attendance record when found', async () => {
      const attendance = makeMockAttendance();
      mockRepo.findById.mockResolvedValue(attendance);

      const result = await service.findById(1);

      expect(result).toEqual(attendance);
      expect(mockRepo.findById).toHaveBeenCalledWith(1);
    });

    it('propagates NotFoundException from repository', async () => {
      mockRepo.findById.mockRejectedValue(
        new InifniNotFoundException(ERROR_CODES.PROGRAM_ATTENDANCE_NOTFOUND, null, null, '99'),
      );

      await expect(service.findById(99)).rejects.toThrow(InifniNotFoundException);
    });
  });

  // ─── findList ────────────────────────────────────────────────────────────────

  describe('findList', () => {
    const baseQuery = { programId: 3, limit: 20, offset: 0 };

    it('returns paginated list with status counts', async () => {
      const records = [makeMockAttendance(), makeMockAttendance({ id: 2 })];
      mockRepo.findList.mockResolvedValue([records, 2]);
      mockRepo.getAttendanceCounts.mockResolvedValue({ total: 2, checkedIn: 1, yetToCheckIn: 1 });

      const result = await service.findList(baseQuery as any) as any;

      expect(result.total).toBe(2);
      expect(result.data).toHaveLength(2);
      expect(result.statusCounts).toEqual([
        { status: 'All', count: 2 },
        { status: 'Checked In', count: 1 },
        { status: 'Yet to Check In', count: 1 },
      ]);
    });

    it('returns correct limit and offset in response', async () => {
      mockRepo.findList.mockResolvedValue([[], 0]);
      mockRepo.getAttendanceCounts.mockResolvedValue({ total: 0, checkedIn: 0, yetToCheckIn: 0 });

      const result = await service.findList({ programId: 3, limit: 10, offset: 5 } as any) as any;

      expect(result.limit).toBe(10);
      expect(result.offset).toBe(5);
    });

    it('triggers excel download when isDownload = true', async () => {
      mockRepo.findList.mockResolvedValue([[], 0]);

      const result = await service.findList({ ...baseQuery, isDownload: true } as any);

      expect(mockExcel.jsonToExcelAndUpload).toHaveBeenCalled();
      expect(result).toHaveProperty('fileUrl');
    });

    it('does not trigger excel download when isDownload is false', async () => {
      mockRepo.findList.mockResolvedValue([[], 0]);
      mockRepo.getAttendanceCounts.mockResolvedValue({ total: 0, checkedIn: 0, yetToCheckIn: 0 });

      await service.findList({ ...baseQuery, isDownload: false } as any);

      expect(mockExcel.jsonToExcelAndUpload).not.toHaveBeenCalled();
    });
  });
});
