import { Test, TestingModule } from '@nestjs/testing';
import { OnlineAttendanceService } from './online-attendance.service';
import { OnlineAttendanceRepository } from './online-attendance.repository';
import { AppLoggerService } from 'src/common/services/logger.service';
import { ExcelService } from 'src/common/services/excel.service';
import { OnlineTypeEnum } from 'src/common/enum/online-type.enum';
import { SessionLaunchMode } from 'src/common/enum/session-launch-mode.enum';
import { AttendanceSourceEnum } from 'src/common/enum/attendance-source.enum';
import { AttendanceStatus } from 'src/common/enum/attendance-status.enum';
import { ERROR_CODES } from 'src/common/constants/error-string-constants';
import InifniConflictException from 'src/common/exceptions/infini-conflict-exception';

/** Roles that resolve to a manual coordinator-level / RM-level mark. */
const ADMIN_ROLES = ['admin'];
const RM_ROLES = ['relational_manager'];
const COORDINATOR_ROLES = ['shoba'];

const MINUTE = 60_000;

const makeSession = (overrides: Record<string, any> = {}) => ({
  id: 1,
  name: 'Monthly Satsang',
  programId: 3,
  onlineType: OnlineTypeEnum.WEBINAR,
  startsAt: new Date(Date.now() + 5 * MINUTE), // started window: opens 15m before
  duration: '60',
  meetingLink: null,
  onlineSession: {
    externalId: 'zoom-123',
    joinUrl: 'https://zoom.us/j/123',
    password: 'secret',
    launchMode: SessionLaunchMode.ZOOM_CLIENT as SessionLaunchMode | null,
    joinOpensMinutesBefore: 15,
    actualMeetingEndsAt: null as Date | null,
  },
  ...overrides,
});

const makeRegistration = (overrides: Record<string, any> = {}) => ({
  id: 10,
  registrationSeqNumber: 'SEQ001',
  fullName: 'Test User',
  emailAddress: 'test@example.com',
  mobileNumber: '9999999999',
  ...overrides,
});

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

  const mockRepo = {
    findSessionById: jest.fn(),
    resolveJoinUrl: jest.fn(),
    findConfirmedRegistration: jest.fn(),
    findConfirmedRegistrationById: jest.fn(),
    findRegistrationById: jest.fn(),
    findBySessionAndUser: jest.fn(),
    findBySessionAndRegistration: jest.fn(),
    findBySessionAndEmail: jest.fn(),
    findAllBySession: jest.fn(),
    listSessionsByProgram: jest.fn(),
    create: jest.fn((x) => ({ ...x })),
    save: jest.fn(async (x) => x),
    updateAttendanceLock: jest.fn(),
  };
  const mockLogger = { log: jest.fn(), error: jest.fn(), warn: jest.fn() };
  const mockExcel = {
    jsonToExcelAndUpload: jest.fn().mockResolvedValue('https://s3.example.com/report.xlsx'),
  };

  beforeEach(async () => {
    jest.clearAllMocks();
    const module: TestingModule = await Test.createTestingModule({
      providers: [
        OnlineAttendanceService,
        { provide: OnlineAttendanceRepository, useValue: mockRepo },
        { provide: AppLoggerService, useValue: mockLogger },
        { provide: ExcelService, useValue: mockExcel },
      ],
    }).compile();
    service = module.get(OnlineAttendanceService);
  });

  describe('joinSession', () => {
    it('marks attendance and returns the configured launch mode inside the window', async () => {
      mockRepo.findSessionById.mockResolvedValue(makeSession());
      mockRepo.findConfirmedRegistration.mockResolvedValue(makeRegistration());
      mockRepo.findBySessionAndUser.mockResolvedValue(null);

      const result = await service.joinSession(1, 5);

      expect(result.attendanceMarked).toBe(true);
      expect(result.launchMode).toBe(SessionLaunchMode.ZOOM_CLIENT);
      expect(result.joinUrl).toBe('https://zoom.us/j/123');
      expect(mockRepo.save).toHaveBeenCalled();
    });

    it('records who actually clicked join on the JOIN_CLICK event', async () => {
      mockRepo.findSessionById.mockResolvedValue(makeSession());
      mockRepo.findConfirmedRegistration.mockResolvedValue(makeRegistration());
      mockRepo.findBySessionAndUser.mockResolvedValue(null);

      await service.joinSession(1, 5);

      const saved = mockRepo.save.mock.calls[0][0];
      const event = saved.attendanceEvents[0];
      expect(event.source).toBe(AttendanceSourceEnum.JOIN_CLICK);
      expect(event.performedBy).toBe(5);
      expect(event.performedByRole).toBe('Seeker');
    });

    it('does not credit isAttended off a lone join-click — only Zoom\'s own webhook or a manual mark should', async () => {
      mockRepo.findSessionById.mockResolvedValue(makeSession());
      mockRepo.findConfirmedRegistration.mockResolvedValue(makeRegistration());
      mockRepo.findBySessionAndUser.mockResolvedValue(null);

      await service.joinSession(1, 5);

      const saved = mockRepo.save.mock.calls[0][0];
      expect(saved.isAttended).toBe(false);
      expect(saved.checkedInAt).toBeNull();
    });

    it('defaults launch mode to SDK when none is configured', async () => {
      const session = makeSession();
      session.onlineSession.launchMode = null;
      mockRepo.findSessionById.mockResolvedValue(session);
      mockRepo.findConfirmedRegistration.mockResolvedValue(makeRegistration());
      mockRepo.findBySessionAndUser.mockResolvedValue(null);

      const result = await service.joinSession(1, 5);

      expect(result.launchMode).toBe(SessionLaunchMode.SDK);
    });

    it('rejects join before the window opens', async () => {
      const session = makeSession({ startsAt: new Date(Date.now() + 60 * MINUTE) });
      session.onlineSession.joinOpensMinutesBefore = 15; // opens 45m from now
      mockRepo.findSessionById.mockResolvedValue(session);

      await expect(service.joinSession(1, 5)).rejects.toMatchObject({
        code: ERROR_CODES.JOIN_NOT_OPEN_YET,
      });
      expect(mockRepo.save).not.toHaveBeenCalled();
    });

    it('rejects join after the session has ended', async () => {
      const session = makeSession({
        startsAt: new Date(Date.now() - 120 * MINUTE),
        endsAt: new Date(Date.now() - 60 * MINUTE), // ended 60m ago
      });
      mockRepo.findSessionById.mockResolvedValue(session);

      await expect(service.joinSession(1, 5)).rejects.toMatchObject({
        code: ERROR_CODES.JOIN_WINDOW_CLOSED,
      });
      expect(mockRepo.save).not.toHaveBeenCalled();
    });
  });

  describe('markAttendance', () => {
    it('creates a row and marks present via the coordinator-level source (admin role)', async () => {
      mockRepo.findSessionById.mockResolvedValue(makeSession());
      mockRepo.findRegistrationById.mockResolvedValue(makeRegistration());
      mockRepo.findBySessionAndRegistration.mockResolvedValue(null);
      mockRepo.findBySessionAndEmail.mockResolvedValue(null);

      const result = await service.markAttendance(1, 10, AttendanceStatus.PRESENT, ADMIN_ROLES, 99);

      expect(result.isAttended).toBe(true);
      expect(result.attendanceStatus).toBe(AttendanceStatus.PRESENT);
      expect(result.decidedBySource).toBe(AttendanceSourceEnum.MANUAL_COORDINATOR);
      const saved = mockRepo.save.mock.calls[0][0];
      expect(saved.registrationId).toBe(10);
      expect(saved.isManuallyCheckedIn).toBe(true);
      expect(saved.checkedInByUserId).toBe(99);
      expect(saved.attendanceEvents).toEqual([
        expect.objectContaining({
          source: AttendanceSourceEnum.MANUAL_COORDINATOR,
          status: AttendanceStatus.PRESENT,
          performedBy: 99,
        }),
      ]);
    });

    it('RM marking absent overrides an existing Zoom-present record', async () => {
      mockRepo.findSessionById.mockResolvedValue(makeSession());
      mockRepo.findRegistrationById.mockResolvedValue(makeRegistration());
      mockRepo.findBySessionAndRegistration.mockResolvedValue({
        id: 7,
        registrationId: 10,
        isAttended: true,
        attendanceEvents: [
          { source: AttendanceSourceEnum.ZOOM_WEBHOOK, status: AttendanceStatus.PRESENT, occurredAt: '2026-01-01T00:00:01.000Z', performedBy: null },
        ],
      });

      const result = await service.markAttendance(1, 10, AttendanceStatus.ABSENT, RM_ROLES, 42);

      expect(result.attendanceStatus).toBe(AttendanceStatus.ABSENT);
      expect(result.decidedBySource).toBe(AttendanceSourceEnum.MANUAL_RM);
      expect(result.isAttended).toBe(false);
    });

    it('reuses a Zoom-created email-keyed row and back-fills the registration id', async () => {
      mockRepo.findSessionById.mockResolvedValue(makeSession());
      mockRepo.findRegistrationById.mockResolvedValue(makeRegistration());
      mockRepo.findBySessionAndRegistration.mockResolvedValue(null);
      mockRepo.findBySessionAndEmail.mockResolvedValue({
        id: 7,
        email: 'test@example.com',
        registrationId: null,
        isAttended: false,
        attendanceEvents: [{ source: AttendanceSourceEnum.ZOOM_WEBHOOK, occurredAt: '2026-01-01T00:00:01.000Z', performedBy: null }],
      });

      await service.markAttendance(1, 10, AttendanceStatus.PRESENT, ADMIN_ROLES, 99);

      const saved = mockRepo.save.mock.calls[0][0];
      expect(saved.id).toBe(7); // reused, not a new row
      expect(saved.registrationId).toBe(10);
      expect(saved.isAttended).toBe(true);
    });

    it("never reassigns a sibling registration's row that shares the same real email — creates a fresh row instead", async () => {
      mockRepo.findSessionById.mockResolvedValue(makeSession());
      mockRepo.findRegistrationById.mockResolvedValue(makeRegistration({ id: 10 }));
      mockRepo.findBySessionAndRegistration.mockResolvedValue(null);
      // The email-keyed row already belongs to a DIFFERENT registrant (99) —
      // e.g. a proxy/child sibling registration sharing the same real email.
      mockRepo.findBySessionAndEmail.mockResolvedValue({
        id: 7,
        email: 'test@example.com',
        registrationId: 99,
        isAttended: true,
        attendanceEvents: [
          { source: AttendanceSourceEnum.ZOOM_WEBHOOK, status: AttendanceStatus.PRESENT, occurredAt: '2026-01-01T00:00:01.000Z', performedBy: null },
        ],
      });

      await service.markAttendance(1, 10, AttendanceStatus.PRESENT, ADMIN_ROLES, 99);

      const saved = mockRepo.save.mock.calls[0][0];
      // A fresh row for registration 10 — NOT the sibling's row (id 7, registrationId 99).
      expect(saved.id).not.toBe(7);
      expect(saved.registrationId).toBe(10);
      expect(mockRepo.create).toHaveBeenCalledWith(expect.objectContaining({ registrationId: 10 }));
    });

    it('throws when the registration id does not belong to this program at all', async () => {
      mockRepo.findSessionById.mockResolvedValue(makeSession());
      mockRepo.findRegistrationById.mockResolvedValue(null);

      await expect(
        service.markAttendance(1, 10, AttendanceStatus.PRESENT, ADMIN_ROLES, 99),
      ).rejects.toMatchObject({ code: ERROR_CODES.NOT_REGISTERED_FOR_SESSION });
      expect(mockRepo.save).not.toHaveBeenCalled();
    });

    it('still marks attendance for a registration later cancelled/archived/soft-deleted — attendance is a historical fact, not gated by current status', async () => {
      mockRepo.findSessionById.mockResolvedValue(makeSession());
      // findRegistrationById (unlike findConfirmedRegistrationById) has no
      // seatAllocated/registrationStatus/deletedAt gate, so a since-cancelled
      // (or soft-deleted) registration still resolves here.
      mockRepo.findRegistrationById.mockResolvedValue(
        makeRegistration({ seatAllocated: false, registrationStatus: 'cancelled', deletedAt: new Date() }),
      );
      mockRepo.findBySessionAndRegistration.mockResolvedValue(null);
      mockRepo.findBySessionAndEmail.mockResolvedValue(null);

      const result = await service.markAttendance(1, 10, AttendanceStatus.PRESENT, ADMIN_ROLES, 99);

      expect(result.isAttended).toBe(true);
      expect(mockRepo.save).toHaveBeenCalled();
    });

    it('is idempotent when the actor re-marks the same status for their own source', async () => {
      mockRepo.findSessionById.mockResolvedValue(makeSession());
      mockRepo.findRegistrationById.mockResolvedValue(makeRegistration());
      mockRepo.findBySessionAndRegistration.mockResolvedValue({
        id: 7,
        registrationId: 10,
        isAttended: true,
        attendanceEvents: [
          { source: AttendanceSourceEnum.MANUAL_COORDINATOR, status: AttendanceStatus.PRESENT, occurredAt: '2026-01-01T00:00:01.000Z', performedBy: 99 },
        ],
      });

      const result = await service.markAttendance(1, 10, AttendanceStatus.PRESENT, ADMIN_ROLES, 99);

      // Idempotent: no new event appended, but the row is still persisted (summary refresh).
      const appended = mockRepo.save.mock.calls[0]?.[0]?.attendanceEvents ?? [];
      expect(appended).toHaveLength(1);
      expect(result.attendanceStatus).toBe(AttendanceStatus.PRESENT);
    });

    it('a re-mark from a different actor under the same source replaces the existing entry', async () => {
      mockRepo.findSessionById.mockResolvedValue(makeSession());
      mockRepo.findRegistrationById.mockResolvedValue(makeRegistration());
      mockRepo.findBySessionAndRegistration.mockResolvedValue({
        id: 7,
        registrationId: 10,
        isAttended: true,
        attendanceEvents: [
          { source: AttendanceSourceEnum.MANUAL_RM, status: AttendanceStatus.PRESENT, occurredAt: '2026-01-01T00:00:01.000Z', performedBy: 11 },
        ],
      });

      await service.markAttendance(1, 10, AttendanceStatus.ABSENT, RM_ROLES, 22);

      const saved = mockRepo.save.mock.calls[0][0];
      // Still exactly one MANUAL_RM event — the new actor's mark replaced the old one, not appended.
      expect(saved.attendanceEvents).toHaveLength(1);
      expect(saved.attendanceEvents[0]).toMatchObject({
        source: AttendanceSourceEnum.MANUAL_RM,
        status: AttendanceStatus.ABSENT,
        performedBy: 22,
      });
    });

    it('blocks an RM mark once the Coordinator has locked the session', async () => {
      mockRepo.findSessionById.mockResolvedValue(makeSession({ isAttendanceLocked: true }));

      await expect(
        service.markAttendance(1, 10, AttendanceStatus.PRESENT, RM_ROLES, 22),
      ).rejects.toBeInstanceOf(InifniConflictException);
      expect(mockRepo.save).not.toHaveBeenCalled();
    });

    it('blocks an Admin mark once the Coordinator has locked the session', async () => {
      mockRepo.findSessionById.mockResolvedValue(makeSession({ isAttendanceLocked: true }));

      await expect(
        service.markAttendance(1, 10, AttendanceStatus.PRESENT, ADMIN_ROLES, 99),
      ).rejects.toBeInstanceOf(InifniConflictException);
      expect(mockRepo.save).not.toHaveBeenCalled();
    });

    it('still lets a Coordinator mark attendance once the session is locked', async () => {
      mockRepo.findSessionById.mockResolvedValue(makeSession({ isAttendanceLocked: true }));
      mockRepo.findRegistrationById.mockResolvedValue(makeRegistration());
      mockRepo.findBySessionAndRegistration.mockResolvedValue(null);
      mockRepo.findBySessionAndEmail.mockResolvedValue(null);

      const result = await service.markAttendance(1, 10, AttendanceStatus.PRESENT, COORDINATOR_ROLES, 1);

      expect(result.isAttended).toBe(true);
      expect(mockRepo.save).toHaveBeenCalled();
    });
  });

  describe('undoAttendance', () => {
    it("removes the caller's own mark for their role-derived source, falling back to the next-ranked source", async () => {
      mockRepo.findSessionById.mockResolvedValue(makeSession());
      mockRepo.findBySessionAndRegistration.mockResolvedValue({
        id: 7,
        registrationId: 10,
        isAttended: true,
        attendanceEvents: [
          { source: AttendanceSourceEnum.ZOOM_WEBHOOK, status: AttendanceStatus.PRESENT, occurredAt: '2026-01-01T00:00:01.000Z', performedBy: null },
          { source: AttendanceSourceEnum.MANUAL_COORDINATOR, status: AttendanceStatus.ABSENT, occurredAt: '2026-01-01T00:00:02.000Z', performedBy: 99 },
        ],
      });

      const result = await service.undoAttendance(1, 10, ADMIN_ROLES, 99);

      const saved = mockRepo.save.mock.calls[0][0];
      expect(saved.attendanceEvents).toEqual([
        expect.objectContaining({ source: AttendanceSourceEnum.ZOOM_WEBHOOK }),
      ]);
      expect(result?.attendanceStatus).toBe(AttendanceStatus.PRESENT);
      expect(result?.decidedBySource).toBe(AttendanceSourceEnum.ZOOM_WEBHOOK);
    });

    it("refuses to remove another actor's mark under the same source", async () => {
      mockRepo.findSessionById.mockResolvedValue(makeSession());
      mockRepo.findBySessionAndRegistration.mockResolvedValue({
        id: 7,
        registrationId: 10,
        isAttended: false,
        attendanceEvents: [
          { source: AttendanceSourceEnum.MANUAL_RM, status: AttendanceStatus.ABSENT, occurredAt: '2026-01-01T00:00:01.000Z', performedBy: 11 },
        ],
      });

      const result = await service.undoAttendance(1, 10, RM_ROLES, 22); // different RM (22, not 11)

      expect(mockRepo.save).not.toHaveBeenCalled();
      expect(result?.attendanceEvents).toHaveLength(1); // untouched
    });

    it('is a no-op when that source never marked this registrant', async () => {
      mockRepo.findSessionById.mockResolvedValue(makeSession());
      mockRepo.findBySessionAndRegistration.mockResolvedValue({
        id: 7,
        registrationId: 10,
        isAttended: false,
        attendanceEvents: [],
      });

      const result = await service.undoAttendance(1, 10, ADMIN_ROLES, 99);

      expect(mockRepo.save).not.toHaveBeenCalled();
      expect(result?.attendanceEvents).toEqual([]);
    });

    it('blocks an RM undo once the Coordinator has locked the session', async () => {
      mockRepo.findSessionById.mockResolvedValue(makeSession({ isAttendanceLocked: true }));

      await expect(service.undoAttendance(1, 10, RM_ROLES, 22)).rejects.toBeInstanceOf(InifniConflictException);
      expect(mockRepo.save).not.toHaveBeenCalled();
    });
  });

  describe('unmarkAttendance', () => {
    it('clears the attendance summary flags on an attended row', async () => {
      mockRepo.findSessionById.mockResolvedValue(makeSession());
      mockRepo.findBySessionAndRegistration.mockResolvedValue({
        id: 7,
        registrationId: 10,
        isAttended: true,
        isManuallyCheckedIn: true,
        checkedInAt: new Date(),
        checkedInByUserId: 5,
        attendanceEvents: [{ source: AttendanceSourceEnum.ZOOM_WEBHOOK, occurredAt: '', performedBy: null }],
      });

      const result = await service.unmarkAttendance(1, 10, ADMIN_ROLES, 99);

      const saved = mockRepo.save.mock.calls[0][0];
      expect(saved.isAttended).toBe(false);
      expect(saved.isManuallyCheckedIn).toBe(false);
      expect(saved.checkedInAt).toBeNull();
      expect(saved.checkedInByUserId).toBeNull();
      expect(result?.isAttended).toBe(false);
    });

    it('is a no-op when there is nothing to unmark', async () => {
      mockRepo.findSessionById.mockResolvedValue(makeSession());
      mockRepo.findBySessionAndRegistration.mockResolvedValue(null);
      mockRepo.findConfirmedRegistrationById.mockResolvedValue(null);

      const result = await service.unmarkAttendance(1, 10, ADMIN_ROLES, 99);

      expect(result).toBeUndefined();
      expect(mockRepo.save).not.toHaveBeenCalled();
    });

    it('blocks Admin too once the Coordinator has locked the session', async () => {
      mockRepo.findSessionById.mockResolvedValue(makeSession({ isAttendanceLocked: true }));

      await expect(service.unmarkAttendance(1, 10, ADMIN_ROLES, 99)).rejects.toBeInstanceOf(InifniConflictException);
      expect(mockRepo.save).not.toHaveBeenCalled();
    });
  });

  describe('getManualMarksBySession', () => {
    it("returns each registrant's own RM/Coordinator marks, independent of which one resolved the winning status", async () => {
      mockRepo.findAllBySession.mockResolvedValue([
        {
          registrationId: 10,
          attendanceEvents: [
            { source: AttendanceSourceEnum.MANUAL_RM, status: AttendanceStatus.PRESENT, occurredAt: '2026-07-01T00:00:01.000Z', performedBy: 5 },
            { source: AttendanceSourceEnum.MANUAL_COORDINATOR, status: AttendanceStatus.ABSENT, occurredAt: '2026-07-01T00:00:02.000Z', performedBy: 9 },
          ],
        },
        {
          registrationId: 11,
          attendanceEvents: [
            { source: AttendanceSourceEnum.ZOOM_WEBHOOK, status: AttendanceStatus.PRESENT, occurredAt: '2026-07-01T00:00:01.000Z', performedBy: null },
          ],
        },
      ]);

      const result = await service.getManualMarksBySession(1);

      expect(mockRepo.findAllBySession).toHaveBeenCalledWith(1);
      // Registrant 10: Coordinator's ABSENT outranks RM's PRESENT for the resolved status, but both
      // sources' own marks are still reported independently here — the resolved status/source
      // ("Final" column) reflects the Coordinator's win even though RM's own mark stays visible.
      expect(result).toContainEqual({
        registrationId: 10,
        rm: true,
        coordinator: false,
        attendanceStatus: AttendanceStatus.ABSENT,
        decidedBySource: AttendanceSourceEnum.MANUAL_COORDINATOR,
      });
      // Registrant 11: only Zoom marked them — neither RM nor Coordinator ever did, and Zoom's own
      // mark decides the resolved status.
      expect(result).toContainEqual({
        registrationId: 11,
        rm: null,
        coordinator: null,
        attendanceStatus: AttendanceStatus.PRESENT,
        decidedBySource: AttendanceSourceEnum.ZOOM_WEBHOOK,
      });
    });

    it('excludes rows with no linked registration', async () => {
      mockRepo.findAllBySession.mockResolvedValue([
        { registrationId: null, attendanceEvents: [] },
      ]);

      const result = await service.getManualMarksBySession(1);

      expect(result).toEqual([]);
    });

    it('a bare JOIN_CLICK (no Zoom webhook / RM / Coordinator mark) resolves to UNKNOWN, not PRESENT — join-click alone never decides the seeker table\'s "Final" column', async () => {
      mockRepo.findAllBySession.mockResolvedValue([
        {
          registrationId: 20,
          attendanceEvents: [
            { source: AttendanceSourceEnum.JOIN_CLICK, status: AttendanceStatus.PRESENT, occurredAt: '2026-07-01T00:00:01.000Z', performedBy: null },
          ],
        },
      ]);

      const result = await service.getManualMarksBySession(1);

      expect(result).toContainEqual({
        registrationId: 20,
        rm: null,
        coordinator: null,
        attendanceStatus: AttendanceStatus.UNKNOWN,
        decidedBySource: null,
      });
    });

    it('Zoom webhook still outranks a same-registrant JOIN_CLICK for the resolved status, even though join-click is excluded from deciding it alone', async () => {
      mockRepo.findAllBySession.mockResolvedValue([
        {
          registrationId: 21,
          attendanceEvents: [
            { source: AttendanceSourceEnum.JOIN_CLICK, status: AttendanceStatus.PRESENT, occurredAt: '2026-07-01T00:00:01.000Z', performedBy: null },
            { source: AttendanceSourceEnum.ZOOM_WEBHOOK, status: AttendanceStatus.PRESENT, occurredAt: '2026-07-01T00:00:02.000Z', performedBy: null },
          ],
        },
      ]);

      const result = await service.getManualMarksBySession(1);

      expect(result).toContainEqual({
        registrationId: 21,
        rm: null,
        coordinator: null,
        attendanceStatus: AttendanceStatus.PRESENT,
        decidedBySource: AttendanceSourceEnum.ZOOM_WEBHOOK,
      });
    });
  });

  describe('recordZoomJoin', () => {
    const params = {
      sessionId: 1,
      programId: 3,
      registrationId: 10,
      userId: 42,
      fullName: 'A',
      email: 'a@x.com',
      mobile: '111',
      occurredAt: new Date('2026-07-08T10:00:00.000Z'),
    };

    it('creates a new row and appends a ZOOM_WEBHOOK mark when none exists yet', async () => {
      mockRepo.findSessionById.mockResolvedValue(makeSession());
      mockRepo.findBySessionAndRegistration.mockResolvedValue(null);
      mockRepo.findBySessionAndEmail.mockResolvedValue(null);

      await service.recordZoomJoin(params);

      const saved = mockRepo.save.mock.calls[0][0];
      expect(saved.sessionId).toBe(1);
      expect(saved.registrationId).toBe(10);
      const event = saved.attendanceEvents[0];
      expect(event.source).toBe(AttendanceSourceEnum.ZOOM_WEBHOOK);
      expect(event.status).toBe(AttendanceStatus.PRESENT);
      expect(event.performedBy).toBeNull();
    });

    it('appends onto an existing row rather than creating a duplicate', async () => {
      mockRepo.findSessionById.mockResolvedValue(makeSession());
      mockRepo.findBySessionAndRegistration.mockResolvedValue({
        id: 5,
        registrationId: 10,
        attendanceEvents: [],
      });

      await service.recordZoomJoin(params);

      expect(mockRepo.create).not.toHaveBeenCalled();
      const saved = mockRepo.save.mock.calls[0][0];
      expect(saved.id).toBe(5);
    });

    it('is idempotent — a reconnect does not append a second ZOOM_WEBHOOK mark', async () => {
      mockRepo.findSessionById.mockResolvedValue(makeSession());
      mockRepo.findBySessionAndRegistration.mockResolvedValue({
        id: 5,
        registrationId: 10,
        attendanceEvents: [
          { source: AttendanceSourceEnum.ZOOM_WEBHOOK, status: AttendanceStatus.PRESENT, occurredAt: '2026-07-08T09:00:00.000Z', performedBy: null },
        ],
      });

      await service.recordZoomJoin(params);

      expect(mockRepo.save).not.toHaveBeenCalled();
    });

    it("doesn't clobber a Coordinator's later mark — Zoom's own mark still lands, precedence decides the winner separately", async () => {
      mockRepo.findSessionById.mockResolvedValue(makeSession());
      mockRepo.findBySessionAndRegistration.mockResolvedValue({
        id: 5,
        registrationId: 10,
        attendanceEvents: [
          { source: AttendanceSourceEnum.MANUAL_COORDINATOR, status: AttendanceStatus.ABSENT, occurredAt: '2026-07-08T09:00:00.000Z', performedBy: 9 },
        ],
      });

      await service.recordZoomJoin(params);

      const saved = mockRepo.save.mock.calls[0][0];
      expect(saved.attendanceEvents).toHaveLength(2);
      // Coordinator's ABSENT still wins the resolved status even though Zoom just marked PRESENT.
      expect(saved.isAttended).toBe(false);
    });

    it('silently skips the write once the Coordinator has locked the session — no error surfaced to the webhook', async () => {
      mockRepo.findSessionById.mockResolvedValue(makeSession({ isAttendanceLocked: true }));

      await expect(service.recordZoomJoin(params)).resolves.toBeUndefined();
      expect(mockRepo.save).not.toHaveBeenCalled();
    });
  });

  describe('lockSessionAttendance / unlockSessionAttendance', () => {
    it('locks a session and records who locked it', async () => {
      mockRepo.findSessionById.mockResolvedValue(makeSession({ isAttendanceLocked: false }));

      const result = await service.lockSessionAttendance(1, 7);

      expect(result.isAttendanceLocked).toBe(true);
      expect(result.attendanceLockedBy).toBe(7);
      expect(result.attendanceLockedAt).toBeInstanceOf(Date);
      expect(mockRepo.updateAttendanceLock).toHaveBeenCalledWith(1, {
        isAttendanceLocked: true,
        attendanceLockedBy: 7,
        attendanceLockedAt: expect.any(Date),
        updatedBy: 7,
      });
    });

    it('unlocks a session and clears who locked it', async () => {
      mockRepo.findSessionById.mockResolvedValue(
        makeSession({ isAttendanceLocked: true, attendanceLockedBy: 7, attendanceLockedAt: new Date() }),
      );

      const result = await service.unlockSessionAttendance(1, 9);

      expect(result.isAttendanceLocked).toBe(false);
      expect(result.attendanceLockedBy).toBeNull();
      expect(result.attendanceLockedAt).toBeNull();
    });

    it('locking an already-locked session is idempotent — just refreshes locker/time', async () => {
      mockRepo.findSessionById.mockResolvedValue(
        makeSession({ isAttendanceLocked: true, attendanceLockedBy: 7, attendanceLockedAt: new Date('2026-01-01') }),
      );

      const result = await service.lockSessionAttendance(1, 9);

      expect(result.isAttendanceLocked).toBe(true);
      expect(result.attendanceLockedBy).toBe(9);
    });
  });

  describe('exportSessionReport', () => {
    it('builds an Excel export and returns its URL', async () => {
      mockRepo.findSessionById.mockResolvedValue(makeSession());
      mockRepo.findAllBySession.mockResolvedValue([
        {
          id: 1,
          fullName: 'Test User',
          email: 'test@example.com',
          mobile: '999',
          isAttended: true,
          checkedInAt: new Date(),
          attendanceEvents: [{ source: AttendanceSourceEnum.JOIN_CLICK, occurredAt: '', performedBy: null }],
        },
      ]);

      const result = await service.exportSessionReport(1);

      expect(result.fileUrl).toBe('https://s3.example.com/report.xlsx');
      expect(mockExcel.jsonToExcelAndUpload).toHaveBeenCalledTimes(1);
      const [rows] = mockExcel.jsonToExcelAndUpload.mock.calls[0];
      expect(rows[0]['Attendance Sources']).toContain(AttendanceSourceEnum.JOIN_CLICK);
    });
  });
});
