import { SessionCommunicationRepository } from './session-communication.repository';
import { SessionCommunicationPurposeEnum } from 'src/common/enum/session-communication-purpose.enum';
import { IsNull } from 'typeorm';

/**
 * Fluent query-builder mock: every TypeORM chain method returns itself so `.andWhere(...)` calls
 * can be inspected afterwards, while `getRawMany`/`getRawOne` are stubbed per test.
 */
function buildQueryBuilder(rows: any[] | any | null) {
  const qb: any = {
    andWhereCalls: [] as any[],
    leftJoin: jest.fn().mockReturnThis(),
    select: jest.fn().mockReturnThis(),
    addSelect: jest.fn().mockReturnThis(),
    where: jest.fn().mockReturnThis(),
    orderBy: jest.fn().mockReturnThis(),
    addOrderBy: jest.fn().mockReturnThis(),
  };
  qb.andWhere = jest.fn((clause: string, params?: any) => {
    qb.andWhereCalls.push({ clause, params });
    return qb;
  });
  qb.getRawMany = jest.fn().mockResolvedValue(Array.isArray(rows) ? rows : []);
  qb.getRawOne = jest.fn().mockResolvedValue(Array.isArray(rows) ? rows[0] ?? null : rows);
  return qb;
}

describe('SessionCommunicationRepository — general-link recipients', () => {
  function buildRepository(rows: any[] | any | null) {
    const qb = buildQueryBuilder(rows);
    const generatedLinkRepo = { createQueryBuilder: jest.fn().mockReturnValue(qb) };
    const repository = new SessionCommunicationRepository(
      {} as any, // trackRepo
      {} as any, // registrationRepo
      {} as any, // statusRepo
      generatedLinkRepo as any,
    );
    return { repository, qb };
  }

  it('getGeneralLinkRecipients excludes PLACEHOLDER rows (no user_id) — only real user_id rows qualify', async () => {
    const { repository, qb } = buildRepository([
      {
        id: 1,
        userId: 42,
        displayName: 'Real Staff',
        emailAddress: 'staff@example.com',
        mobileNumber: null,
        joinUrl: 'https://zoom.us/j/1',
        meetingId: '1',
        meetingPasscode: 'pass',
      },
    ]);

    await repository.getGeneralLinkRecipients(
      5,
      null,
      SessionCommunicationPurposeEnum.GENERAL_LINK_WELCOME,
    );

    expect(qb.andWhereCalls.map((c: any) => c.clause)).toContain(
      'generatedLink.user_id IS NOT NULL',
    );
  });

  it('getGeneralLinkRecipientById excludes a PLACEHOLDER row (no user_id) via the same gate', async () => {
    const { repository, qb } = buildRepository(null);

    const result = await repository.getGeneralLinkRecipientById(
      5,
      4210,
      SessionCommunicationPurposeEnum.GENERAL_LINK_WELCOME,
    );

    expect(qb.andWhereCalls.map((c: any) => c.clause)).toContain(
      'generatedLink.user_id IS NOT NULL',
    );
    expect(result).toBeNull();
  });

  it('getGeneralLinkRecipientById returns the row\'s own program_session_id — a single send has no explicit sessionId of its own, so the service must thread THIS back into the merge context', async () => {
    const { repository } = buildRepository({
      userId: 42,
      displayName: 'Real Staff',
      emailAddress: 'staff@example.com',
      mobileNumber: null,
      joinUrl: 'https://zoom.us/j/1',
      meetingId: '1',
      meetingPasscode: 'pass',
      programSessionId: 999,
    });

    const result = await repository.getGeneralLinkRecipientById(
      5,
      4210,
      SessionCommunicationPurposeEnum.GENERAL_LINK_WELCOME,
    );

    expect(result?.programSessionId).toBe(999);
  });

  it('getGeneralLinkRecipientById scopes the row to the requested program (cross-program guard)', async () => {
    const { repository, qb } = buildRepository(null);

    await repository.getGeneralLinkRecipientById(
      5,
      4210,
      SessionCommunicationPurposeEnum.GENERAL_LINK_WELCOME,
    );

    const programGuard = qb.andWhereCalls.find(
      (c: any) => c.clause === 'generatedLink.program_id = :programId',
    );
    expect(programGuard?.params).toEqual({ programId: 5 });
  });

  // Regression: the query builder's alias is the mixed-case "generatedLink". TypeORM quotes that
  // alias wherever IT builds a plain `alias.column = :param` condition, but the hand-rolled
  // NOT EXISTS subquery string (notAttendedSessionClause) must quote every reference to that
  // alias ITSELF — an unquoted `generatedLink.column` inside that raw string case-folds to
  // `generatedlink` in Postgres, which doesn't resolve against the quoted `"generatedLink"` the
  // FROM clause actually declares (42P01 / errorMissingRTE at runtime, never caught by a
  // query-builder mock unless the exact clause text is asserted like this).
  it('getGeneralLinkRecipientById quotes the "generatedLink" alias inside the ABSENT not-attended subquery', async () => {
    const { repository, qb } = buildRepository(null);

    await repository.getGeneralLinkRecipientById(
      5,
      4210,
      SessionCommunicationPurposeEnum.GENERAL_LINK_ABSENT,
    );

    const clause = qb.andWhereCalls.map((c: any) => c.clause).find((c: string) => c.includes('NOT EXISTS'));
    expect(clause).toContain('"generatedLink".source_email');
    expect(clause).toContain('"generatedLink".registrant_email');
    expect(clause).not.toMatch(/[^"]generatedLink\./);
  });

  // The Value Card is post-session material, so it goes ONLY to those who attended — the exact
  // complement of the Absent gate. Both come from generalLinkAttendanceClause, so asserting the
  // EXISTS/NOT EXISTS direction here is what pins them as complements rather than two clauses that
  // could drift apart.
  it('getGeneralLinkRecipients gates GENERAL_LINK_VALUE_CARD on HAVING attended the session', async () => {
    const { repository, qb } = buildRepository([]);

    await repository.getGeneralLinkRecipients(
      5,
      12,
      SessionCommunicationPurposeEnum.GENERAL_LINK_VALUE_CARD,
    );

    const attendance = qb.andWhereCalls
      .map((c: any) => c.clause)
      .find((c: string) => typeof c === 'string' && c.includes('zoom_analytics_attendee_summary'));
    expect(attendance).toBeDefined();
    // EXISTS, not NOT EXISTS — the opposite direction to Absent.
    expect(attendance).toMatch(/^EXISTS/);
    expect(attendance).not.toContain('NOT EXISTS');
    expect(attendance).toContain('zas.is_system_attended = true');
    // Same alias-quoting requirement as the Absent clause (see the regression note above).
    expect(attendance).toContain('"generatedLink".source_email');
    expect(attendance).not.toMatch(/[^"]generatedLink\./);
  });

  it('getGeneralLinkRecipientById applies the same attended gate for a single VALUE_CARD send', async () => {
    const { repository, qb } = buildRepository(null);

    await repository.getGeneralLinkRecipientById(
      5,
      4210,
      SessionCommunicationPurposeEnum.GENERAL_LINK_VALUE_CARD,
    );

    const attendance = qb.andWhereCalls
      .map((c: any) => c.clause)
      .find((c: string) => typeof c === 'string' && c.includes('zoom_analytics_attendee_summary'));
    expect(attendance).toMatch(/^EXISTS/);
    expect(attendance).toContain('"generatedLink".program_session_id');
  });

  // Regression: zoom_analytics_attendee_summary.email holds the uniquely-tagged address a general
  // attendee was registered to Zoom with (registrant_email), never their real one (source_email) —
  // the analytics read path keys its generated-link map by registrantEmail for exactly this reason.
  // Matching zas.email against COALESCE(source_email, registrant_email) therefore compared a real
  // address to a tagged one and matched nobody: the Value Card audience came out empty, and the
  // NOT EXISTS direction sent Absent to attendees. registrant_email must be compared directly.
  async function attendanceClauseFor(purpose: SessionCommunicationPurposeEnum): Promise<string> {
    const { repository, qb } = buildRepository([]);
    await repository.getGeneralLinkRecipients(5, 12, purpose);
    const clauses = (qb.andWhereCalls as Array<{ clause: string }>).map((call) => call.clause);
    return clauses.find((clause) => clause.includes('zoom_analytics_attendee_summary')) ?? '';
  }

  function expectMatchesOnRegistrantEmail(clause: string): void {
    expect(clause).toContain(
      'LOWER(TRIM(zas.email)) = LOWER(TRIM("generatedLink".registrant_email))',
    );
    // The real address stays as a second chance, but never as the preferred/only comparison.
    expect(clause).toContain('LOWER(TRIM(zas.email)) = LOWER(TRIM("generatedLink".source_email))');
    expect(clause).not.toContain('COALESCE');
  }

  it('matches the VALUE_CARD attendee row on registrant_email, not COALESCE(source_email, …)', async () => {
    expectMatchesOnRegistrantEmail(
      await attendanceClauseFor(SessionCommunicationPurposeEnum.GENERAL_LINK_VALUE_CARD),
    );
  });

  it('matches the ABSENT non-attendee row on registrant_email too — the gates stay complements', async () => {
    expectMatchesOnRegistrantEmail(
      await attendanceClauseFor(SessionCommunicationPurposeEnum.GENERAL_LINK_ABSENT),
    );
  });

  // GENERAL_LINK_INVITE recipients are staff/admin (ROLE-type generated links), not seekers on a
  // structured multi-session journey — unlike the seeker final-invite rule, there is no
  // prior-session-attendance gate for them at any occurrence.
  it('getGeneralLinkRecipientById never adds an attendance gate for GENERAL_LINK_INVITE', async () => {
    const { repository, qb } = buildRepository(null);

    await repository.getGeneralLinkRecipientById(
      5,
      4210,
      SessionCommunicationPurposeEnum.GENERAL_LINK_INVITE,
    );

    expect(qb.andWhereCalls.map((c: any) => c.clause).some((c: string) => c.includes('NOT EXISTS'))).toBe(
      false,
    );
  });
});

describe('SessionCommunicationRepository — findLatestSessionStatus', () => {
  function buildRepository(row: any) {
    const statusRepo = { findOne: jest.fn().mockResolvedValue(row) };
    const repository = new SessionCommunicationRepository(
      {} as any, // trackRepo
      {} as any, // registrationRepo
      statusRepo as any,
      {} as any, // generatedLinkRepo
    );
    return { repository, statusRepo };
  }

  it('reads the newest row for the (session, purpose) by id, without filtering to TRIGGERED', async () => {
    const row = { id: 9, status: 'TRIGGERED' };
    const { repository, statusRepo } = buildRepository(row);

    const result = await repository.findLatestSessionStatus(
      7,
      SessionCommunicationPurposeEnum.GENERAL_LINK_VALUE_CARD,
    );

    expect(statusRepo.findOne).toHaveBeenCalledWith({
      where: { sessionId: 7, purpose: SessionCommunicationPurposeEnum.GENERAL_LINK_VALUE_CARD },
      order: { id: 'DESC' },
    });
    // A SKIPPED run must be reportable as "ran, nothing went out" — only a genuinely absent row
    // may come back null, so no status condition belongs in the where clause.
    expect(statusRepo.findOne.mock.calls[0][0].where).not.toHaveProperty('status');
    expect(result).toBe(row);
  });

  it('returns null when the purpose has never run for the session', async () => {
    const { repository } = buildRepository(null);

    expect(
      await repository.findLatestSessionStatus(
        7,
        SessionCommunicationPurposeEnum.GENERAL_LINK_VALUE_CARD,
      ),
    ).toBeNull();
  });
});

describe('SessionCommunicationRepository — hasTriggeredScopedBulkSend', () => {
  function buildRepository(exists: boolean) {
    const statusRepo = { exists: jest.fn().mockResolvedValue(exists) };
    const repository = new SessionCommunicationRepository(
      {} as any, // trackRepo
      {} as any, // registrationRepo
      statusRepo as any,
      {} as any, // generatedLinkRepo
    );
    return { repository, statusRepo };
  }

  it('matches the PROGRAM-LEVEL row with IS NULL, not "any session"', async () => {
    const { repository, statusRepo } = buildRepository(true);

    expect(
      await repository.hasTriggeredScopedBulkSend(
        5,
        null,
        SessionCommunicationPurposeEnum.COMMON_INVITE,
      ),
    ).toBe(true);

    const where = statusRepo.exists.mock.calls[0][0].where;
    // FindOperator from IsNull() — a bare `sessionId: null` would compare `= NULL` and never match.
    expect(where.sessionId).toEqual(IsNull());
    expect(where.status).toBe('TRIGGERED');
    expect(where.purpose).toBe(SessionCommunicationPurposeEnum.COMMON_INVITE);
  });

  it('matches the given session id when the send is session-scoped', async () => {
    const { repository, statusRepo } = buildRepository(false);

    expect(
      await repository.hasTriggeredScopedBulkSend(
        5,
        12,
        SessionCommunicationPurposeEnum.SYSTEM_LINKS,
      ),
    ).toBe(false);

    expect(statusRepo.exists.mock.calls[0][0].where).toMatchObject({
      programId: 5,
      sessionId: 12,
      purpose: SessionCommunicationPurposeEnum.SYSTEM_LINKS,
      status: 'TRIGGERED',
    });
  });
});
