import { ZoomGeneratedLinkService } from './zoom-generated-link.service';
import { GeneratedLinkSourceType } from 'src/common/enum/generated-link-source-type.enum';
import { OnlineSessionRegistrationStatus } from 'src/common/enum/online-session-registration-status.enum';
import { OnlineTypeEnum } from 'src/common/enum/online-type.enum';
import InifniBadRequestException from 'src/common/exceptions/infini-badrequest-exception';
import { ZoomRole } from '../enums/zoom-role.enum';

describe('ZoomGeneratedLinkService', () => {
  const provisionedSession = (id: number, onlineType = OnlineTypeEnum.WEBINAR) => ({
    id,
    programId: 1,
    onlineType,
    onlineSession: { externalId: `ext-${id}` },
  });

  const onlineSessionService = { findOne: jest.fn(), findAll: jest.fn() };
  const linkRepository = {
    create: jest.fn((data: any) => ({ ...data })),
    saveMany: jest.fn((rows: any[]) => Promise.resolve(rows)),
    excludeRegisteredAndClearFailedByUser: jest.fn(),
    excludeRegisteredAndClearFailedByBatch: jest.fn(),
    listBySession: jest.fn(),
    listByProgram: jest.fn(),
    findByUserId: jest.fn(),
  };
  const userRepository = { getUsersByRoleKeys: jest.fn() };
  const webinar = { addParticipant: jest.fn() };
  const meeting = { addParticipant: jest.fn() };
  const logger = { log: jest.fn(), warn: jest.fn(), error: jest.fn() };

  let service: ZoomGeneratedLinkService;

  beforeEach(() => {
    jest.clearAllMocks();
    onlineSessionService.findOne.mockResolvedValue(provisionedSession(7));
    linkRepository.excludeRegisteredAndClearFailedByUser.mockResolvedValue(new Set());
    linkRepository.excludeRegisteredAndClearFailedByBatch.mockResolvedValue(new Set());
    service = new ZoomGeneratedLinkService(
      onlineSessionService as any,
      linkRepository as any,
      userRepository as any,
      webinar as any,
      meeting as any,
      logger as any,
    );
  });

  it('rejects a sessionId that is not Zoom-provisioned', async () => {
    onlineSessionService.findOne.mockResolvedValue({ id: 7, programId: 1, onlineSession: null });
    await expect(
      service.generate({ sessionId: 7, roleKeys: ['ROLE_ADMIN'] } as any),
    ).rejects.toBeInstanceOf(InifniBadRequestException);
  });

  it('rejects a programId with no Zoom-provisioned sessions at all', async () => {
    onlineSessionService.findAll.mockResolvedValue({ data: [] });
    await expect(
      service.generate({ programId: 1, roleKeys: ['ROLE_ADMIN'] } as any),
    ).rejects.toBeInstanceOf(InifniBadRequestException);
  });

  it('generates ONE link/row per recipient for a shared program (sessions sharing one Zoom resource)', async () => {
    // 3 sessions of the program, all sharing the SAME Zoom resource (same externalId).
    const sharedResource = { externalId: 'ext-shared' };
    onlineSessionService.findAll.mockResolvedValue({
      data: [
        { id: 11, programId: 1, onlineType: OnlineTypeEnum.WEBINAR, onlineSession: sharedResource },
        { id: 12, programId: 1, onlineType: OnlineTypeEnum.WEBINAR, onlineSession: sharedResource },
        { id: 13, programId: 1, onlineType: OnlineTypeEnum.WEBINAR, onlineSession: sharedResource },
      ],
    });
    userRepository.getUsersByRoleKeys.mockResolvedValue([
      { id: 42, fullName: 'RM One', email: 'rm@x.com', userRoleMaps: [] },
    ]);
    webinar.addParticipant.mockResolvedValue({ joinUrl: 'https://zoom/join/1', zoomRegistrantId: 'r1' });

    const result = await service.generate({ programId: 1, roleKeys: ['ROLE_ADMIN'] } as any, 5);

    // Deduped to the one shared resource → one Zoom call, but one stored row per sibling
    // session so per-session listing still works, all reusing the same joinUrl/registrant id.
    expect(webinar.addParticipant).toHaveBeenCalledTimes(1);
    expect(result.created).toHaveLength(3);
    expect(result.created.map((row) => row.programSessionId).sort()).toEqual([11, 12, 13]);
    expect(new Set(result.created.map((row) => row.joinUrl))).toEqual(new Set(['https://zoom/join/1']));
    expect(linkRepository.saveMany).toHaveBeenCalledTimes(1);
  });

  describe('role group, single sessionId', () => {
    it('registers every role-matched user with a tagged email and persists a REGISTERED row per user', async () => {
      userRepository.getUsersByRoleKeys.mockResolvedValue([
        { id: 42, email: 'admin@divami.com', fullName: 'Admin One', userRoleMaps: [{ role: { roleKey: 'ROLE_ADMIN' } }] },
      ]);
      webinar.addParticipant.mockResolvedValue({ joinUrl: 'https://zoom/join/1', zoomRegistrantId: 'r1' });

      const result = await service.generate({ sessionId: 7, roleKeys: ['ROLE_ADMIN'] } as any, 5);

      expect(userRepository.getUsersByRoleKeys).toHaveBeenCalledWith(['ROLE_ADMIN']);
      expect(webinar.addParticipant).toHaveBeenCalledWith(
        provisionedSession(7),
        // Zoom rejects a registrant with no last_name — a multi-word name splits into both.
        expect.objectContaining({ email: 'admin+gen42p1@divami.com', firstName: 'Admin', lastName: 'One' }),
        ZoomRole.ATTENDEE,
        true,
      );
      expect(result.created).toHaveLength(1);
      expect(result.created[0]).toMatchObject({
        programSessionId: 7,
        sourceType: GeneratedLinkSourceType.ROLE,
        userId: 42,
        roleKey: 'ROLE_ADMIN',
        email: 'admin@divami.com',
        joinUrl: 'https://zoom/join/1',
        status: OnlineSessionRegistrationStatus.REGISTERED,
      });
      expect(result.skippedExisting).toBe(0);
      expect(result.failed).toEqual([]);
    });

    it('keeps a single-word name as both firstName and lastName — no salutation for general links', async () => {
      userRepository.getUsersByRoleKeys.mockResolvedValue([
        { id: 43, email: 'shoba@yopmail.com', fullName: 'Shoba', gender: 'Female', userRoleMaps: [] },
      ]);
      webinar.addParticipant.mockResolvedValue({ joinUrl: 'https://zoom/join/1', zoomRegistrantId: 'r1' });

      await service.generate({ sessionId: 7, roleKeys: ['ROLE_ADMIN'] } as any);

      expect(webinar.addParticipant).toHaveBeenCalledWith(
        provisionedSession(7),
        expect.objectContaining({ firstName: 'Shoba', lastName: 'Shoba', name: 'Shoba' }),
        ZoomRole.ATTENDEE,
        true,
      );
    });

    it('prefers orgUsrName, then legalFullName, then fullName, then first+last name for the display/contact name', async () => {
      userRepository.getUsersByRoleKeys.mockResolvedValue([
        {
          id: 44,
          email: 'org@divami.com',
          orgUsrName: 'Org Handle',
          legalFullName: 'Legal Full Name',
          fullName: 'Full Name',
          firstName: 'First',
          lastName: 'Last',
          userRoleMaps: [],
        },
      ]);
      webinar.addParticipant.mockResolvedValue({ joinUrl: 'https://zoom/join/1', zoomRegistrantId: 'r1' });

      const result = await service.generate({ sessionId: 7, roleKeys: ['ROLE_ADMIN'] } as any);

      expect(result.created[0].displayName).toBe('Org Handle');
    });

    it('dedupes a user who comes back more than once from the role-keys query (holds several requested roles), registering it exactly once', async () => {
      userRepository.getUsersByRoleKeys.mockResolvedValue([
        {
          id: 42,
          email: 'admin@divami.com',
          fullName: 'Admin One',
          userRoleMaps: [{ role: { roleKey: 'ROLE_ADMIN' } }],
        },
        {
          id: 42,
          email: 'admin@divami.com',
          fullName: 'Admin One',
          userRoleMaps: [{ role: { roleKey: 'ROLE_RELATIONAL_MANAGER' } }],
        },
      ]);
      webinar.addParticipant.mockResolvedValue({ joinUrl: 'https://zoom/join/1', zoomRegistrantId: 'r1' });

      const result = await service.generate({
        sessionId: 7,
        roleKeys: ['ROLE_ADMIN', 'ROLE_RELATIONAL_MANAGER'],
      } as any);

      expect(webinar.addParticipant).toHaveBeenCalledTimes(1);
      expect(result.created).toHaveLength(1);
      expect(linkRepository.saveMany).toHaveBeenCalledTimes(1);
    });

    it("records the user's highest-priority requested role (lowest UserRole.priority), not whichever the query happened to return first", async () => {
      userRepository.getUsersByRoleKeys.mockResolvedValue([
        {
          id: 42,
          email: 'admin@divami.com',
          fullName: 'Admin One',
          // Listed with the lower-priority (higher number) role first — a naive "first match"
          // would wrongly record ROLE_RELATIONAL_MANAGER here.
          userRoleMaps: [
            { role: { roleKey: 'ROLE_RELATIONAL_MANAGER', priority: 8 } },
            { role: { roleKey: 'ROLE_ADMIN', priority: 3 } },
          ],
        },
      ]);
      webinar.addParticipant.mockResolvedValue({ joinUrl: 'https://zoom/join/1', zoomRegistrantId: 'r1' });

      const result = await service.generate({
        sessionId: 7,
        roleKeys: ['ROLE_ADMIN', 'ROLE_RELATIONAL_MANAGER'],
      } as any);

      expect(result.created[0].roleKey).toBe('ROLE_ADMIN');
    });

    it('skips a user with no email on file rather than registering a garbage address', async () => {
      userRepository.getUsersByRoleKeys.mockResolvedValue([
        { id: 42, email: null, fullName: 'No Email', userRoleMaps: [] },
      ]);

      const result = await service.generate({ sessionId: 7, roleKeys: ['ROLE_ADMIN'] } as any);

      expect(webinar.addParticipant).not.toHaveBeenCalled();
      expect(result.created).toHaveLength(0);
    });

    it('skips a user who already has an active row for this session, with no Zoom call', async () => {
      userRepository.getUsersByRoleKeys.mockResolvedValue([
        { id: 42, email: 'admin@divami.com', fullName: 'Admin One', userRoleMaps: [] },
      ]);
      linkRepository.excludeRegisteredAndClearFailedByUser.mockResolvedValue(new Set([42]));

      const result = await service.generate({ sessionId: 7, roleKeys: ['ROLE_ADMIN'] } as any);

      expect(webinar.addParticipant).not.toHaveBeenCalled();
      expect(result.created).toHaveLength(0);
      expect(result.skippedExisting).toBe(1);
    });

    it('records a FAILED row without throwing when the Zoom call itself fails', async () => {
      userRepository.getUsersByRoleKeys.mockResolvedValue([
        { id: 42, email: 'admin@divami.com', fullName: 'Admin One', userRoleMaps: [] },
      ]);
      webinar.addParticipant.mockRejectedValue(new Error('Zoom API down'));

      const result = await service.generate({ sessionId: 7, roleKeys: ['ROLE_ADMIN'] } as any);

      expect(result.created).toHaveLength(1);
      expect(result.created[0].status).toBe(OnlineSessionRegistrationStatus.FAILED);
      expect(result.failed).toEqual([{ name: 'Admin One', email: 'admin@divami.com', reason: 'Zoom API down' }]);
    });

    it('uses the meeting handler when the session onlineType is MEETING, not the webinar handler', async () => {
      onlineSessionService.findOne.mockResolvedValue(provisionedSession(7, OnlineTypeEnum.MEETING));
      userRepository.getUsersByRoleKeys.mockResolvedValue([
        { id: 42, email: 'admin@divami.com', fullName: 'Admin One', userRoleMaps: [] },
      ]);
      meeting.addParticipant.mockResolvedValue({ joinUrl: 'https://zoom/m/1', zoomRegistrantId: 'r1' });

      await service.generate({ sessionId: 7, roleKeys: ['ROLE_ADMIN'] } as any);

      expect(meeting.addParticipant).toHaveBeenCalled();
      expect(webinar.addParticipant).not.toHaveBeenCalled();
    });
  });

  describe('placeholder group, single sessionId', () => {
    it('generates <count> sequentially-named/emailed placeholder registrants', async () => {
      webinar.addParticipant
        .mockResolvedValueOnce({ joinUrl: 'https://zoom/join/1', zoomRegistrantId: 'r1' })
        .mockResolvedValueOnce({ joinUrl: 'https://zoom/join/2', zoomRegistrantId: 'r2' });

      const result = await service.generate({
        sessionId: 7,
        placeholderName: 'Staff',
        placeholderCount: 2,
        placeholderDomain: 'example.com',
      } as any);

      expect(webinar.addParticipant).toHaveBeenCalledTimes(2);
      expect(result.created.map((row) => row.displayName)).toEqual(['Staff 1', 'Staff 2']);
      expect(result.created.map((row) => row.email)).toEqual([
        'staff+gen1p1@example.com',
        'staff+gen2p1@example.com',
      ]);
      expect(result.created.every((row) => row.sourceType === GeneratedLinkSourceType.PLACEHOLDER)).toBe(true);
      expect(result.created.every((row) => row.roleKey === 'SYSTEM')).toBe(true);
    });

    it('skips already-generated sequence numbers for the same batch name', async () => {
      linkRepository.excludeRegisteredAndClearFailedByBatch.mockResolvedValue(new Set([1]));
      webinar.addParticipant.mockResolvedValue({ joinUrl: 'https://zoom/join/2', zoomRegistrantId: 'r2' });

      const result = await service.generate({
        sessionId: 7,
        placeholderName: 'Staff',
        placeholderCount: 2,
        placeholderDomain: 'example.com',
      } as any);

      expect(webinar.addParticipant).toHaveBeenCalledTimes(1);
      expect(result.skippedExisting).toBe(1);
      expect(result.created).toHaveLength(1);
      expect(result.created[0].sequenceNumber).toBe(2);
    });
  });

  describe('programId scope (no sessionId)', () => {
    it('registers each recipient against every Zoom-provisioned session of the program, skipping unprovisioned ones', async () => {
      onlineSessionService.findAll.mockResolvedValue({
        data: [provisionedSession(1), provisionedSession(2), { id: 3, programId: 1, onlineSession: null }],
      });
      userRepository.getUsersByRoleKeys.mockResolvedValue([
        { id: 42, email: 'admin@divami.com', fullName: 'Admin One', userRoleMaps: [] },
      ]);
      webinar.addParticipant.mockResolvedValue({ joinUrl: 'https://zoom/join/x', zoomRegistrantId: 'rx' });

      const result = await service.generate({ programId: 1, roleKeys: ['ROLE_ADMIN'] } as any);

      expect(webinar.addParticipant).toHaveBeenCalledTimes(2);
      expect(result.created.map((row) => row.programSessionId).sort()).toEqual([1, 2]);
    });
  });

  describe('list', () => {
    it('lists by session when sessionId is given', async () => {
      linkRepository.listBySession.mockResolvedValue({ data: [], total: 0 });
      await service.list({ sessionId: 7 }, 2, 10);
      expect(linkRepository.listBySession).toHaveBeenCalledWith(7, 2, 10);
      expect(linkRepository.listByProgram).not.toHaveBeenCalled();
    });

    it('lists by program when only programId is given', async () => {
      linkRepository.listByProgram.mockResolvedValue({ data: [], total: 0 });
      await service.list({ programId: 1 }, 1, 20);
      expect(linkRepository.listByProgram).toHaveBeenCalledWith(1, 1, 20);
      expect(linkRepository.listBySession).not.toHaveBeenCalled();
    });
  });

  describe('listByUser', () => {
    it('delegates to the repository by user id, join URL included on each row', async () => {
      linkRepository.findByUserId.mockResolvedValue([{ id: 1, userId: 42, joinUrl: 'https://zoom/join/1' }]);

      const result = await service.listByUser(42);

      expect(linkRepository.findByUserId).toHaveBeenCalledWith(42, undefined, undefined);
      expect(result[0]).toMatchObject({ userId: 42, joinUrl: 'https://zoom/join/1' });
    });

    it('forwards optional programId/sessionId narrowing to the repository', async () => {
      linkRepository.findByUserId.mockResolvedValue([]);

      await service.listByUser(42, 3, 7);

      expect(linkRepository.findByUserId).toHaveBeenCalledWith(42, 3, 7);
    });
  });
});
