import { Test, TestingModule } from '@nestjs/testing';
import { ReportsService } from './reports.service';
import { ReportsRepository } from './reports.repository';
import { ProgramRepository } from 'src/program/program.repository';
import { AppLoggerService } from 'src/common/services/logger.service';
import { ExcelService } from 'src/common/services/excel.service';
import { AwsS3Service } from 'src/common/services/awsS3.service';
import { FeatureFlagService } from 'src/feature-flag/feature-flag.service';
import { ERROR_CODES } from 'src/common/constants/error-string-constants';
import { FEATURE_FLAG_KEYS } from 'src/common/constants/constants';

const REGISTRATION_FIELD_DEFS = [
  { key: 'fullName', alias: 'fullName', label: 'Full Name', group: 'Registration', order: 2, format: null, dependsOn: null, requiredFlags: null, conditionalFormat: null },
];

const SESSION_ATTENDANCE_FIELD_DEFS = [
  { key: 'sessionName', alias: 'sessionName', label: 'Session Name', group: 'Session', order: 1, format: null, dependsOn: null, requiredFlags: null, conditionalFormat: null },
  { key: 'sessionStartsAt', alias: 'sessionStartsAt', label: 'Session Date', group: 'Session', order: 2, format: 'datetime', dependsOn: null, requiredFlags: null, conditionalFormat: null },
  { key: 'registrationSeqNumber', alias: 'registrationSeqNumber', label: 'Registration Seq No.', group: 'Attendee', order: 3, format: null, dependsOn: null, requiredFlags: null, conditionalFormat: null },
  { key: 'fullName', alias: 'fullName', label: 'Full Name', group: 'Attendee', order: 4, format: null, dependsOn: null, requiredFlags: null, conditionalFormat: null },
  { key: 'email', alias: 'email', label: 'Email', group: 'Attendee', order: 5, format: null, dependsOn: null, requiredFlags: null, conditionalFormat: null },
  { key: 'phone', alias: 'phone', label: 'Mobile', group: 'Attendee', order: 6, format: null, dependsOn: null, requiredFlags: null, conditionalFormat: null },
  { key: 'activationStatus', alias: 'activationStatus', label: 'Activation Status', group: 'Attendee', order: 7, format: 'title-case', dependsOn: null, requiredFlags: null, conditionalFormat: null },
  { key: 'attendanceStatus', alias: 'attendanceStatus', label: 'Final Attendance', group: 'Attendance', order: 8, format: null, dependsOn: null, requiredFlags: null, conditionalFormat: null },
  { key: 'systemAttendance', alias: 'systemAttendance', label: 'System Attendance', group: 'Attendance', order: 9, format: 'boolean', dependsOn: null, requiredFlags: null, conditionalFormat: null },
  { key: 'rmAttendance', alias: 'rmAttendance', label: 'RM Attendance', group: 'Attendance', order: 10, format: 'boolean', dependsOn: null, requiredFlags: null, conditionalFormat: null },
  { key: 'coordinatorAttendance', alias: 'coordinatorAttendance', label: 'Coordinator Attendance', group: 'Attendance', order: 11, format: 'boolean', dependsOn: null, requiredFlags: null, conditionalFormat: null },
  { key: 'joinedTime', alias: 'joinedTime', label: 'Joined Time', group: 'Attendance', order: 12, format: 'datetime', dependsOn: null, requiredFlags: null, conditionalFormat: null },
  { key: 'noOfLogins', alias: 'noOfLogins', label: 'No. of Logins', group: 'Attendance', order: 13, format: 'number', dependsOn: null, requiredFlags: null, conditionalFormat: null },
  { key: 'duration', alias: 'duration', label: 'Duration (mins)', group: 'Attendance', order: 14, format: 'number', dependsOn: null, requiredFlags: null, conditionalFormat: null },
  { key: 'lateBy', alias: 'lateBy', label: 'Late By (mins)', group: 'Attendance', order: 15, format: 'number', dependsOn: null, requiredFlags: null, conditionalFormat: null },
  { key: 'dropoffCount', alias: 'dropoffCount', label: 'Drop-off Count', group: 'Attendance', order: 16, format: 'number', dependsOn: null, requiredFlags: null, conditionalFormat: null },
  { key: 'lastDropoffAt', alias: 'lastDropoffAt', label: 'Last Drop-off At', group: 'Attendance', order: 17, format: 'datetime', dependsOn: null, requiredFlags: null, conditionalFormat: null },
  { key: 'joinCount', alias: 'joinCount', label: 'Join Count', group: 'Attendance', order: 18, format: 'number', dependsOn: null, requiredFlags: null, conditionalFormat: null },
  { key: 'durationSeconds', alias: 'durationSeconds', label: 'Duration (secs)', group: 'Attendance', order: 19, format: 'number', dependsOn: null, requiredFlags: null, conditionalFormat: null },
  { key: 'lateBySeconds', alias: 'lateBySeconds', label: 'Late By (secs)', group: 'Attendance', order: 20, format: 'number', dependsOn: null, requiredFlags: null, conditionalFormat: null },
];

describe('ReportsService', () => {
  let service: ReportsService;
  const mockReportsRepository = {
    getAllFieldDefinitions: jest.fn(),
    fetchReportRows: jest.fn(),
    getAllSessionAttendanceFieldDefinitions: jest.fn(),
    fetchSessionAttendanceReportRows: jest.fn(),
    fetchSessionDropoffReportRows: jest.fn(),
    fetchGeneralAttendeesReportRows: jest.fn(),
    fetchGeneralSessionDropoffReportRows: jest.fn(),
    fetchProgramZoomLinks: jest.fn(),
  };
  const mockProgramRepository = { findOneById: jest.fn() };
  const mockLogger = { log: jest.fn(), debug: jest.fn(), error: jest.fn() };
  const mockExcelService = { jsonToExcelBuffer: jest.fn() };
  const mockAwsS3Service = { uploadToS3: jest.fn(), getSignedUrl: jest.fn() };
  const mockFeatureFlagService = { isFeatureEnabledForProgramType: jest.fn() };

  beforeEach(async () => {
    jest.clearAllMocks();
    // handleKnownErrors (invoked by every error path under test) logs via the static
    // AppLoggerService.getInstance() singleton, not DI — stub it so it doesn't throw
    // "not initialized" when no real AppLoggerService has been constructed yet.
    jest.spyOn(AppLoggerService, 'getInstance').mockReturnValue({ error: jest.fn() } as any);
    mockReportsRepository.getAllFieldDefinitions.mockResolvedValue(REGISTRATION_FIELD_DEFS);
    mockReportsRepository.getAllSessionAttendanceFieldDefinitions.mockResolvedValue(SESSION_ATTENDANCE_FIELD_DEFS);
    // Every gated purpose/endpoint defaults to enabled — tests that care about the
    // disabled path override this per-test.
    mockFeatureFlagService.isFeatureEnabledForProgramType.mockResolvedValue(true);

    const module: TestingModule = await Test.createTestingModule({
      providers: [
        ReportsService,
        { provide: ReportsRepository, useValue: mockReportsRepository },
        { provide: ProgramRepository, useValue: mockProgramRepository },
        { provide: AppLoggerService, useValue: mockLogger },
        { provide: ExcelService, useValue: mockExcelService },
        { provide: AwsS3Service, useValue: mockAwsS3Service },
        { provide: FeatureFlagService, useValue: mockFeatureFlagService },
      ],
    }).compile();

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

  it('should be defined', () => {
    expect(service).toBeDefined();
  });

  describe('getAvailableFields', () => {
    it('throws PROGRAM_NOT_FOUND when the program does not exist', async () => {
      mockProgramRepository.findOneById.mockResolvedValue(null);

      await expect(service.getAvailableFields(999)).rejects.toMatchObject({
        code: ERROR_CODES.PROGRAM_NOT_FOUND,
      });
    });

    it('returns the available fields for a valid program', async () => {
      mockProgramRepository.findOneById.mockResolvedValue({
        requiresApproval: false, requiresPayment: false, isProformaAllowed: false,
        involvesTravel: false, hasGoodies: false, isGroupedProgram: false,
        parentalConsentEnabled: false, waitlistApplicable: false, requiresResidence: false,
      });

      const fields = await service.getAvailableFields(1);

      expect(fields.map((f) => f.key)).toEqual(['fullName']);
    });
  });

  describe('buildExcel', () => {
    it('throws NO_DATA_FOUND_FOR_REPORT for an empty result set', async () => {
      await expect(service.buildExcel([], ['fullName'], 'report')).rejects.toMatchObject({
        code: ERROR_CODES.NO_DATA_FOUND_FOR_REPORT,
      });
      expect(mockExcelService.jsonToExcelBuffer).not.toHaveBeenCalled();
    });

    it('uploads the generated buffer and returns a signed URL', async () => {
      mockExcelService.jsonToExcelBuffer.mockResolvedValue(Buffer.from('xlsx'));
      mockAwsS3Service.uploadToS3.mockResolvedValue(undefined);
      mockAwsS3Service.getSignedUrl.mockResolvedValue('https://signed-url');

      const url = await service.buildExcel([{ fullName: 'Ram' }], ['fullName'], 'report');

      expect(url).toBe('https://signed-url');
      expect(mockAwsS3Service.uploadToS3).toHaveBeenCalled();
    });
  });

  describe('getAvailableSessionAttendanceFields', () => {
    it('throws PROGRAM_NOT_FOUND when the program does not exist', async () => {
      mockProgramRepository.findOneById.mockResolvedValue(null);

      await expect(service.getAvailableSessionAttendanceFields(999)).rejects.toMatchObject({
        code: ERROR_CODES.PROGRAM_NOT_FOUND,
      });
    });

    it('returns the session attendance field registry', async () => {
      mockProgramRepository.findOneById.mockResolvedValue({ id: 1 });

      const fields = await service.getAvailableSessionAttendanceFields(1);

      expect(fields.map((f) => f.key)).toEqual(expect.arrayContaining(['sessionName', 'attendanceStatus']));
    });
  });

  describe('generateSessionAttendanceReport', () => {
    it('throws INVALID_REPORT_FIELDS for an unknown field key', async () => {
      mockProgramRepository.findOneById.mockResolvedValue({ id: 1 });

      await expect(
        service.generateSessionAttendanceReport({
          fields: ['sessionName', 'unknownField'],
          filters: { programId: 1 },
        } as any),
      ).rejects.toMatchObject({ code: ERROR_CODES.INVALID_REPORT_FIELDS });
    });

    it('throws PROGRAM_NOT_FOUND when the program does not exist', async () => {
      mockProgramRepository.findOneById.mockResolvedValue(null);

      await expect(
        service.generateSessionAttendanceReport({
          fields: ['sessionName'],
          filters: { programId: 999 },
        } as any),
      ).rejects.toMatchObject({ code: ERROR_CODES.PROGRAM_NOT_FOUND });
    });

    it('fetches and transforms rows for a valid request', async () => {
      mockProgramRepository.findOneById.mockResolvedValue({ id: 1 });
      mockReportsRepository.fetchSessionAttendanceReportRows.mockResolvedValue([
        { sessionName: 'Session 1', attendanceStatus: 'Attended' },
      ]);

      const result = await service.generateSessionAttendanceReport({
        fields: ['sessionName', 'attendanceStatus'],
        filters: { programId: 1, sessionId: 10 },
      } as any);

      expect(mockReportsRepository.fetchSessionAttendanceReportRows).toHaveBeenCalledWith(
        '"sessionName", "attendanceStatus"',
        expect.stringContaining('"programId":1'),
      );
      expect(result.total).toBe(1);
      expect(result.data[0]).toMatchObject({ sessionName: 'Session 1', attendanceStatus: 'Attended' });
    });

    it('throws FIELDS_OR_PURPOSE_REQUIRED when neither fields nor purpose is provided', async () => {
      mockProgramRepository.findOneById.mockResolvedValue({ id: 1 });

      await expect(
        service.generateSessionAttendanceReport({ filters: { programId: 1 } } as any),
      ).rejects.toMatchObject({ code: ERROR_CODES.FIELDS_OR_PURPOSE_REQUIRED });
    });

    it('resolves the hardcoded field set for purpose "single-session-attendance" instead of requiring fields', async () => {
      mockProgramRepository.findOneById.mockResolvedValue({ id: 1 });
      mockReportsRepository.fetchSessionAttendanceReportRows.mockResolvedValue([
        { sessionName: 'Session 1' },
      ]);

      const result = await service.generateSessionAttendanceReport({
        purpose: 'single-session-attendance',
        filters: { programId: 1 },
      } as any);

      expect(mockReportsRepository.fetchSessionAttendanceReportRows).toHaveBeenCalledWith(
        '"sessionName", "sessionStartsAt", "registrationSeqNumber", "fullName", "email", "phone", "activationStatus", "attendanceStatus", "systemAttendance", "rmAttendance", "coordinatorAttendance", "joinedTime", "noOfLogins", "duration", "durationSeconds"',
        expect.stringContaining('"programId":1'),
      );
      expect(result.total).toBe(1);
    });

    it('still resolves the full purpose field set when fields is passed as an empty array', async () => {
      mockProgramRepository.findOneById.mockResolvedValue({ id: 1 });
      mockReportsRepository.fetchSessionAttendanceReportRows.mockResolvedValue([
        { sessionName: 'Session 1' },
      ]);

      await service.generateSessionAttendanceReport({
        purpose: 'single-session-attendance',
        fields: [],
        filters: { programId: 1 },
      } as any);

      expect(mockReportsRepository.fetchSessionAttendanceReportRows).toHaveBeenCalledWith(
        '"sessionName", "sessionStartsAt", "registrationSeqNumber", "fullName", "email", "phone", "activationStatus", "attendanceStatus", "systemAttendance", "rmAttendance", "coordinatorAttendance", "joinedTime", "noOfLogins", "duration", "durationSeconds"',
        expect.stringContaining('"programId":1'),
      );
    });

    it('resolves the "all-sessions-attendance" purpose without requiring filters.sessionId', async () => {
      mockProgramRepository.findOneById.mockResolvedValue({ id: 1 });
      mockReportsRepository.fetchSessionAttendanceReportRows.mockResolvedValue([
        { sessionName: 'Session 1', attendanceStatus: 'Attended' },
        { sessionName: 'Session 2', attendanceStatus: null },
      ]);

      const result = await service.generateSessionAttendanceReport({
        purpose: 'all-sessions-attendance',
        filters: { programId: 1 },
      } as any);

      expect(mockReportsRepository.fetchSessionAttendanceReportRows).toHaveBeenCalledWith(
        '"sessionName", "sessionStartsAt", "registrationSeqNumber", "fullName", "email", "phone", "activationStatus", "attendanceStatus", "systemAttendance", "rmAttendance", "coordinatorAttendance", "joinedTime", "noOfLogins"',
        expect.stringContaining('"programId":1'),
      );
      // The not-yet-completed session's attendanceStatus (null, from the view) passes
      // straight through — formatValue's null passthrough, not something this purpose
      // needs its own logic for.
      expect(result.data[1]).toMatchObject({ sessionName: 'Session 2', attendanceStatus: null });
      expect(result.total).toBe(2);
    });

    it('resolves the "session-report" purpose with the merged duration/lateBy/dropoff/join columns (incl. seconds companions)', async () => {
      mockProgramRepository.findOneById.mockResolvedValue({ id: 1 });
      mockReportsRepository.fetchSessionAttendanceReportRows.mockResolvedValue([
        { sessionName: 'Session 1', lateBy: 4, lateBySeconds: 245, dropoffCount: 2, joinCount: 3 },
      ]);

      const result = await service.generateSessionAttendanceReport({
        purpose: 'session-report',
        filters: { programId: 1, sessionId: 10 },
      } as any);

      expect(mockReportsRepository.fetchSessionAttendanceReportRows).toHaveBeenCalledWith(
        '"sessionName", "sessionStartsAt", "registrationSeqNumber", "fullName", "email", "phone", "activationStatus", "attendanceStatus", "systemAttendance", "rmAttendance", "coordinatorAttendance", "joinedTime", "noOfLogins", "duration", "durationSeconds", "lateBy", "lateBySeconds", "dropoffCount", "joinCount", "lastDropoffAt"',
        expect.stringContaining('"programId":1'),
      );
      expect(result.data[0]).toMatchObject({ lateBy: 4, lateBySeconds: 245, dropoffCount: 2, joinCount: 3 });
    });

    it('resolves the "late-comer-report" purpose to the join-timing field subset (incl. lateBySeconds)', async () => {
      mockProgramRepository.findOneById.mockResolvedValue({ id: 1 });
      mockReportsRepository.fetchSessionAttendanceReportRows.mockResolvedValue([
        { sessionName: 'Session 1', joinedTime: '2026-01-01T10:05:00Z', lateBy: 3, lateBySeconds: 185 },
      ]);

      await service.generateSessionAttendanceReport({
        purpose: 'late-comer-report',
        filters: { programId: 1, sessionId: 10 },
      } as any);

      expect(mockReportsRepository.fetchSessionAttendanceReportRows).toHaveBeenCalledWith(
        '"sessionName", "sessionStartsAt", "registrationSeqNumber", "fullName", "email", "phone", "joinedTime", "lateBy", "lateBySeconds"',
        expect.stringContaining('"programId":1'),
      );
    });

    it('drops on-time and never-joined rows (lateBy null) from the "late-comer-report" purpose', async () => {
      mockProgramRepository.findOneById.mockResolvedValue({ id: 1 });
      mockReportsRepository.fetchSessionAttendanceReportRows.mockResolvedValue([
        { fullName: 'Late Larry', joinedTime: '2026-01-01T10:05:00Z', lateBy: 3, lateBySeconds: 185 },
        { fullName: 'On-time Otto', joinedTime: '2026-01-01T10:00:00Z', lateBy: null, lateBySeconds: null },
        { fullName: 'Never-joined Nina', joinedTime: null, lateBy: null, lateBySeconds: null },
      ]);

      const result = await service.generateSessionAttendanceReport({
        purpose: 'late-comer-report',
        filters: { programId: 1, sessionId: 10 },
      } as any);

      expect(result.data.map((row: any) => row.fullName)).toEqual(['Late Larry']);
    });

    it('does not filter by lateBy for purposes other than "late-comer-report" (e.g. "session-report")', async () => {
      mockProgramRepository.findOneById.mockResolvedValue({ id: 1 });
      mockReportsRepository.fetchSessionAttendanceReportRows.mockResolvedValue([
        { fullName: 'Late Larry', lateBy: 3, dropoffCount: 0, joinCount: 1 },
        { fullName: 'On-time Otto', lateBy: null, dropoffCount: 0, joinCount: 1 },
      ]);

      const result = await service.generateSessionAttendanceReport({
        purpose: 'session-report',
        filters: { programId: 1, sessionId: 10 },
      } as any);

      expect(result.data.map((row: any) => row.fullName)).toEqual(['Late Larry', 'On-time Otto']);
    });
  });

  describe('generateSessionAttendanceReport — session-dropoffs purpose', () => {
    it('throws SESSION_ID_REQUIRED_FOR_REPORT when filters.sessionId is missing', async () => {
      await expect(
        service.generateSessionAttendanceReport({
          purpose: 'session-dropoffs',
          filters: { programId: 1 },
        } as any),
      ).rejects.toMatchObject({ code: ERROR_CODES.SESSION_ID_REQUIRED_FOR_REPORT });
      expect(mockReportsRepository.fetchSessionDropoffReportRows).not.toHaveBeenCalled();
    });

    it('throws PROGRAM_NOT_FOUND when the program does not exist', async () => {
      mockProgramRepository.findOneById.mockResolvedValue(null);

      await expect(
        service.generateSessionAttendanceReport({
          purpose: 'session-dropoffs',
          filters: { programId: 999, sessionId: 10 },
        } as any),
      ).rejects.toMatchObject({ code: ERROR_CODES.PROGRAM_NOT_FOUND });
    });

    it('fetches drop-off rows scoped by programId/sessionId and returns them with hardcoded field defs', async () => {
      mockProgramRepository.findOneById.mockResolvedValue({ id: 1 });
      mockReportsRepository.fetchSessionDropoffReportRows.mockResolvedValue([
        {
          registrationId: 1234, fullName: 'Ram', phone: '9999999999', email: 'ram@example.com',
          rmName: 'Shoba', zoomTaggedEmail: 'ram+reg1234@example.com', dropoffCount: 1, joinCount: 3,
          leaveReasons: 'Connection Lost / Network Issue; User Manually Left',
        },
      ]);

      const result = await service.generateSessionAttendanceReport({
        purpose: 'session-dropoffs',
        filters: { programId: 1, sessionId: 10 },
      } as any);

      expect(mockReportsRepository.fetchSessionDropoffReportRows).toHaveBeenCalledWith(
        expect.stringContaining('"sessionId":10'),
      );
      expect(result.total).toBe(1);
      expect(result.data[0]).toMatchObject({ fullName: 'Ram', dropoffCount: 1, joinCount: 3 });
      expect(result.fieldDefs?.rmName).toMatchObject({ label: 'RM Name' });
      expect(result.tableHeaders.map(h => h.key)).toEqual([
        'registrationId', 'fullName', 'phone', 'email', 'rmName', 'zoomTaggedEmail', 'joinCount', 'dropoffCount', 'leaveReasons',
      ]);
    });
  });

  describe('generateSessionAttendanceReport — general-attendees purpose', () => {
    it('throws SESSION_ID_REQUIRED_FOR_REPORT when filters.sessionId is missing', async () => {
      await expect(
        service.generateSessionAttendanceReport({
          purpose: 'general-attendees',
          filters: { programId: 1 },
        } as any),
      ).rejects.toMatchObject({ code: ERROR_CODES.SESSION_ID_REQUIRED_FOR_REPORT });
      expect(mockReportsRepository.fetchGeneralAttendeesReportRows).not.toHaveBeenCalled();
    });

    it('throws PROGRAM_NOT_FOUND when the program does not exist', async () => {
      mockProgramRepository.findOneById.mockResolvedValue(null);

      await expect(
        service.generateSessionAttendanceReport({
          purpose: 'general-attendees',
          filters: { programId: 999, sessionId: 10 },
        } as any),
      ).rejects.toMatchObject({ code: ERROR_CODES.PROGRAM_NOT_FOUND });
    });

    it('fetches general attendee rows scoped by programId/sessionId and returns them with hardcoded field defs', async () => {
      mockProgramRepository.findOneById.mockResolvedValue({ id: 1 });
      mockReportsRepository.fetchGeneralAttendeesReportRows.mockResolvedValue([
        {
          fullName: 'Staff 3', email: 'staff3@example.com', mobile: '9999999999',
          attendance: 'Attended', joinedAt: '2026-07-30T10:00:00Z', noOfDevices: 1,
          dropoffCount: 0, durationSeconds: 1800, sourceTag: 'SYSTEM',
        },
      ]);

      const result = await service.generateSessionAttendanceReport({
        purpose: 'general-attendees',
        filters: { programId: 1, sessionId: 10 },
      } as any);

      expect(mockReportsRepository.fetchGeneralAttendeesReportRows).toHaveBeenCalledWith(
        expect.stringContaining('"sessionId":10'),
      );
      expect(result.total).toBe(1);
      expect(result.data[0]).toMatchObject({ fullName: 'Staff 3', sourceTag: 'SYSTEM' });
      expect(result.fieldDefs?.mobile).toMatchObject({ label: 'Phone Number' });
      expect(result.tableHeaders.map(h => h.key)).toEqual([
        'fullName', 'email', 'mobile', 'attendance', 'joinedAt', 'noOfDevices', 'dropoffCount', 'durationSeconds', 'sourceTag',
      ]);
    });
  });

  describe('generateSessionAttendanceReport — general-session-report purpose', () => {
    it('throws SESSION_ID_REQUIRED_FOR_REPORT when filters.sessionId is missing', async () => {
      await expect(
        service.generateSessionAttendanceReport({
          purpose: 'general-session-report',
          filters: { programId: 1 },
        } as any),
      ).rejects.toMatchObject({ code: ERROR_CODES.SESSION_ID_REQUIRED_FOR_REPORT });
      expect(mockReportsRepository.fetchGeneralAttendeesReportRows).not.toHaveBeenCalled();
    });

    it('fetches the same general-attendee rows as general-attendees, but with the full field set including lateBy/joinCount/lastDropoffAt', async () => {
      mockProgramRepository.findOneById.mockResolvedValue({ id: 1 });
      mockReportsRepository.fetchGeneralAttendeesReportRows.mockResolvedValue([
        {
          fullName: 'Staff 3', email: 'staff3@example.com', mobile: '9999999999',
          attendance: 'Attended', joinedAt: '2026-07-30T10:15:00Z', noOfDevices: 1,
          dropoffCount: 1, durationSeconds: 1800, lateBy: 15, lateBySeconds: 900,
          joinCount: 2, lastDropoffAt: '2026-07-30T10:30:00Z', sourceTag: 'SYSTEM',
        },
      ]);

      const result = await service.generateSessionAttendanceReport({
        purpose: 'general-session-report',
        filters: { programId: 1, sessionId: 10 },
      } as any);

      expect(result.data[0]).toMatchObject({ fullName: 'Staff 3', lateBy: 15, joinCount: 2 });
      expect(result.tableHeaders.map(h => h.key)).toEqual([
        'fullName', 'email', 'mobile', 'attendance', 'joinedAt', 'noOfDevices', 'dropoffCount',
        'durationSeconds', 'lateBy', 'lateBySeconds', 'joinCount', 'lastDropoffAt', 'sourceTag',
      ]);
    });
  });

  describe('generateSessionAttendanceReport — general-late-comer-report purpose', () => {
    it('throws SESSION_ID_REQUIRED_FOR_REPORT when filters.sessionId is missing', async () => {
      await expect(
        service.generateSessionAttendanceReport({
          purpose: 'general-late-comer-report',
          filters: { programId: 1 },
        } as any),
      ).rejects.toMatchObject({ code: ERROR_CODES.SESSION_ID_REQUIRED_FOR_REPORT });
    });

    it('returns only the join-timing columns', async () => {
      mockProgramRepository.findOneById.mockResolvedValue({ id: 1 });
      mockReportsRepository.fetchGeneralAttendeesReportRows.mockResolvedValue([
        { fullName: 'Staff 3', email: 'staff3@example.com', mobile: '9999999999', joinedAt: '2026-07-30T10:15:00Z', lateBy: 15, lateBySeconds: 900, sourceTag: 'SYSTEM' },
      ]);

      const result = await service.generateSessionAttendanceReport({
        purpose: 'general-late-comer-report',
        filters: { programId: 1, sessionId: 10 },
      } as any);

      expect(result.tableHeaders.map(h => h.key)).toEqual([
        'fullName', 'email', 'mobile', 'joinedAt', 'lateBy', 'lateBySeconds', 'sourceTag',
      ]);
    });

    it('drops on-time and never-joined rows (lateBy null)', async () => {
      mockProgramRepository.findOneById.mockResolvedValue({ id: 1 });
      mockReportsRepository.fetchGeneralAttendeesReportRows.mockResolvedValue([
        { fullName: 'Late Larry', lateBy: 15, lateBySeconds: 900, sourceTag: 'SYSTEM' },
        { fullName: 'On-time Otto', lateBy: null, lateBySeconds: null, sourceTag: 'SYSTEM' },
      ]);

      const result = await service.generateSessionAttendanceReport({
        purpose: 'general-late-comer-report',
        filters: { programId: 1, sessionId: 10 },
      } as any);

      expect(result.data.map((row: any) => row.fullName)).toEqual(['Late Larry']);
    });
  });

  describe('generateSessionAttendanceReport — general-session-dropoffs purpose', () => {
    it('throws SESSION_ID_REQUIRED_FOR_REPORT when filters.sessionId is missing', async () => {
      await expect(
        service.generateSessionAttendanceReport({
          purpose: 'general-session-dropoffs',
          filters: { programId: 1 },
        } as any),
      ).rejects.toMatchObject({ code: ERROR_CODES.SESSION_ID_REQUIRED_FOR_REPORT });
      expect(mockReportsRepository.fetchGeneralSessionDropoffReportRows).not.toHaveBeenCalled();
    });

    it('throws PROGRAM_NOT_FOUND when the program does not exist', async () => {
      mockProgramRepository.findOneById.mockResolvedValue(null);

      await expect(
        service.generateSessionAttendanceReport({
          purpose: 'general-session-dropoffs',
          filters: { programId: 999, sessionId: 10 },
        } as any),
      ).rejects.toMatchObject({ code: ERROR_CODES.PROGRAM_NOT_FOUND });
    });

    it('fetches general drop-off rows scoped by programId/sessionId and returns them with hardcoded field defs', async () => {
      mockProgramRepository.findOneById.mockResolvedValue({ id: 1 });
      mockReportsRepository.fetchGeneralSessionDropoffReportRows.mockResolvedValue([
        {
          fullName: 'Staff 3', email: 'staff3@example.com', mobile: '9999999999', sourceTag: 'SYSTEM',
          zoomTaggedEmail: 'staff3+gen9562p1474@example.com', joinCount: 2, dropoffCount: 1,
          leaveReasons: 'Connection Lost / Network Issue',
        },
      ]);

      const result = await service.generateSessionAttendanceReport({
        purpose: 'general-session-dropoffs',
        filters: { programId: 1, sessionId: 10 },
      } as any);

      expect(mockReportsRepository.fetchGeneralSessionDropoffReportRows).toHaveBeenCalledWith(
        expect.stringContaining('"sessionId":10'),
      );
      expect(result.total).toBe(1);
      expect(result.data[0]).toMatchObject({ fullName: 'Staff 3', dropoffCount: 1, joinCount: 2 });
      expect(result.tableHeaders.map(h => h.key)).toEqual([
        'fullName', 'email', 'mobile', 'sourceTag', 'zoomTaggedEmail', 'joinCount', 'dropoffCount', 'leaveReasons',
      ]);
    });
  });

  describe('generateSessionAttendanceReport — eligible-registrations-attendance purpose', () => {
    it('throws PROGRAM_NOT_FOUND when the program does not exist', async () => {
      mockProgramRepository.findOneById.mockResolvedValue(null);

      await expect(
        service.generateSessionAttendanceReport({
          purpose: 'eligible-registrations-attendance',
          filters: { programId: 999 },
        } as any),
      ).rejects.toMatchObject({ code: ERROR_CODES.PROGRAM_NOT_FOUND });
    });

    it('fetches with only programId/registrationStatus filters, ignoring sessionId/attended', async () => {
      mockProgramRepository.findOneById.mockResolvedValue({ id: 1 });
      mockReportsRepository.fetchSessionAttendanceReportRows.mockResolvedValue([]);

      await service.generateSessionAttendanceReport({
        purpose: 'eligible-registrations-attendance',
        filters: { programId: 1, sessionId: 10, attended: true, registrationStatus: ['ACTIVE'] },
      } as any);

      expect(mockReportsRepository.fetchSessionAttendanceReportRows).toHaveBeenCalledWith(
        'reg_id, session_id, session_display_order, "registrationSeqNumber", "fullName", "email", "phone", "activationStatus", "attendanceStatus"',
        JSON.stringify({ programId: 1, registrationStatus: ['ACTIVE'] }),
      );
    });

    it('pivots one row per registrant × session into one row per registrant with Session 1..N columns', async () => {
      mockProgramRepository.findOneById.mockResolvedValue({ id: 1 });
      mockReportsRepository.fetchSessionAttendanceReportRows.mockResolvedValue([
        { reg_id: 1, session_id: 10, session_display_order: 1, registrationSeqNumber: 'R1', fullName: 'Alice', email: 'a@x.com', phone: '111', activationStatus: 'active', attendanceStatus: 'Attended' },
        { reg_id: 1, session_id: 11, session_display_order: 2, registrationSeqNumber: 'R1', fullName: 'Alice', email: 'a@x.com', phone: '111', activationStatus: 'active', attendanceStatus: 'Attended' },
        { reg_id: 2, session_id: 10, session_display_order: 1, registrationSeqNumber: 'R2', fullName: 'Bob', email: 'b@x.com', phone: '222', activationStatus: 'active', attendanceStatus: 'Not Attended' },
        { reg_id: 2, session_id: 11, session_display_order: 2, registrationSeqNumber: 'R2', fullName: 'Bob', email: 'b@x.com', phone: '222', activationStatus: 'active', attendanceStatus: 'Attended' },
      ]);

      const result = await service.generateSessionAttendanceReport({
        purpose: 'eligible-registrations-attendance',
        filters: { programId: 1 },
      } as any);

      expect(result.total).toBe(2);
      expect(result.data[0]).toMatchObject({
        registrationSeqNumber: 'R1', fullName: 'Alice',
        session_1: 'Attended', session_2: 'Attended',
        eligibleForFinalSession: 'Eligible',
      });
      expect(result.data[1]).toMatchObject({
        registrationSeqNumber: 'R2', fullName: 'Bob',
        session_1: 'Not Attended', session_2: 'Attended',
        eligibleForFinalSession: 'Absent',
      });
      expect(result.tableHeaders.map(h => h.key)).toEqual([
        'registrationSeqNumber', 'fullName', 'email', 'phone', 'activationStatus',
        'session_1', 'session_2', 'eligibleForFinalSession',
      ]);
      expect(result.fieldDefs?.session_1).toMatchObject({ key: 'session_1', label: 'Session 1' });
    });

    it('passes a null (not-yet-finished) session through and does not mark it Absent', async () => {
      mockProgramRepository.findOneById.mockResolvedValue({ id: 1 });
      mockReportsRepository.fetchSessionAttendanceReportRows.mockResolvedValue([
        { reg_id: 1, session_id: 10, session_display_order: 1, registrationSeqNumber: 'R1', fullName: 'Alice', email: 'a@x.com', phone: '111', activationStatus: 'active', attendanceStatus: 'Attended' },
        { reg_id: 1, session_id: 11, session_display_order: 2, registrationSeqNumber: 'R1', fullName: 'Alice', email: 'a@x.com', phone: '111', activationStatus: 'active', attendanceStatus: null },
      ]);

      const result = await service.generateSessionAttendanceReport({
        purpose: 'eligible-registrations-attendance',
        filters: { programId: 1 },
      } as any);

      expect(result.data[0]).toMatchObject({ session_1: 'Attended', session_2: null, eligibleForFinalSession: 'Eligible' });
    });
  });

  describe('generateSessionAttendanceReport — feature-flag gating', () => {
    // Flags are resolved through the program's TYPE, not its own id — every mocked program below
    // carries a distinct typeId (7) from its id (1) so a test asserting on typeId would fail loudly
    // if the service ever regressed to checking programId directly instead.
    it('throws REPORT_FEATURE_DISABLED for "session-dropoffs" when its own flag is off', async () => {
      mockProgramRepository.findOneById.mockResolvedValue({ id: 1, typeId: 7 });
      mockFeatureFlagService.isFeatureEnabledForProgramType.mockResolvedValue(false);

      await expect(
        service.generateSessionAttendanceReport({
          purpose: 'session-dropoffs',
          filters: { programId: 1, sessionId: 10 },
        } as any),
      ).rejects.toMatchObject({ code: ERROR_CODES.REPORT_FEATURE_DISABLED });

      expect(mockFeatureFlagService.isFeatureEnabledForProgramType).toHaveBeenCalledWith(
        FEATURE_FLAG_KEYS.ENABLE_SESSION_DROPOFFS_REPORT, 7,
      );
      expect(mockReportsRepository.fetchSessionDropoffReportRows).not.toHaveBeenCalled();
    });

    it('does not check ENABLE_SINGLE_SESSION_REPORT for "late-comer-report" — the umbrella is unrelated to the individual purpose flags', async () => {
      mockProgramRepository.findOneById.mockResolvedValue({ id: 1, typeId: 7 });
      mockReportsRepository.fetchSessionAttendanceReportRows.mockResolvedValue([]);
      mockFeatureFlagService.isFeatureEnabledForProgramType.mockImplementation(
        async (key: string) => key === FEATURE_FLAG_KEYS.ENABLE_LATE_COMER_REPORT,
      );

      await service.generateSessionAttendanceReport({
        purpose: 'late-comer-report',
        filters: { programId: 1, sessionId: 10 },
      } as any);

      expect(mockFeatureFlagService.isFeatureEnabledForProgramType).toHaveBeenCalledWith(
        FEATURE_FLAG_KEYS.ENABLE_LATE_COMER_REPORT, 7,
      );
      expect(mockFeatureFlagService.isFeatureEnabledForProgramType).not.toHaveBeenCalledWith(
        FEATURE_FLAG_KEYS.ENABLE_SINGLE_SESSION_REPORT, expect.anything(),
      );
    });

    it('proceeds for "single-session-attendance" once its own flag is on, without needing the umbrella', async () => {
      mockProgramRepository.findOneById.mockResolvedValue({ id: 1, typeId: 7 });
      mockReportsRepository.fetchSessionAttendanceReportRows.mockResolvedValue([]);
      mockFeatureFlagService.isFeatureEnabledForProgramType.mockImplementation(
        async (key: string) => key === FEATURE_FLAG_KEYS.ENABLE_SINGLE_SESSION_ATTENDANCE_REPORT,
      );

      await service.generateSessionAttendanceReport({
        purpose: 'single-session-attendance',
        filters: { programId: 1, sessionId: 10 },
      } as any);

      expect(mockFeatureFlagService.isFeatureEnabledForProgramType).toHaveBeenCalledWith(
        FEATURE_FLAG_KEYS.ENABLE_SINGLE_SESSION_ATTENDANCE_REPORT, 7,
      );
      expect(mockFeatureFlagService.isFeatureEnabledForProgramType).not.toHaveBeenCalledWith(
        FEATURE_FLAG_KEYS.ENABLE_SINGLE_SESSION_REPORT, expect.anything(),
      );
    });

    it('checks ENABLE_ATTENDEES_LIVE_REPORT for the "general-attendees" purpose, with no umbrella involved', async () => {
      mockProgramRepository.findOneById.mockResolvedValue({ id: 1, typeId: 7 });
      mockReportsRepository.fetchGeneralAttendeesReportRows.mockResolvedValue([]);

      await service.generateSessionAttendanceReport({
        purpose: 'general-attendees',
        filters: { programId: 1, sessionId: 10 },
      } as any);

      expect(mockFeatureFlagService.isFeatureEnabledForProgramType).toHaveBeenCalledTimes(1);
      expect(mockFeatureFlagService.isFeatureEnabledForProgramType).toHaveBeenCalledWith(
        FEATURE_FLAG_KEYS.ENABLE_ATTENDEES_LIVE_REPORT, 7,
      );
    });

    it('checks its own distinct flag for each of "general-session-report"/"general-late-comer-report"/"general-session-dropoffs"', async () => {
      mockProgramRepository.findOneById.mockResolvedValue({ id: 1, typeId: 7 });
      mockReportsRepository.fetchGeneralAttendeesReportRows.mockResolvedValue([]);
      mockReportsRepository.fetchGeneralSessionDropoffReportRows.mockResolvedValue([]);

      for (const purpose of ['general-session-report', 'general-late-comer-report', 'general-session-dropoffs']) {
        await service.generateSessionAttendanceReport({ purpose, filters: { programId: 1, sessionId: 10 } } as any);
      }

      expect(mockFeatureFlagService.isFeatureEnabledForProgramType).toHaveBeenCalledWith(
        FEATURE_FLAG_KEYS.ENABLE_GENERAL_SESSION_REPORT, 7,
      );
      expect(mockFeatureFlagService.isFeatureEnabledForProgramType).toHaveBeenCalledWith(
        FEATURE_FLAG_KEYS.ENABLE_GENERAL_LATE_COMER_REPORT, 7,
      );
      expect(mockFeatureFlagService.isFeatureEnabledForProgramType).toHaveBeenCalledWith(
        FEATURE_FLAG_KEYS.ENABLE_GENERAL_SESSION_DROPOFFS_REPORT, 7,
      );
    });

    it('checks ENABLE_ELIGIBLE_REGISTRATIONS_REPORT for "eligible-registrations-attendance"', async () => {
      mockProgramRepository.findOneById.mockResolvedValue({ id: 1, typeId: 7 });
      mockFeatureFlagService.isFeatureEnabledForProgramType.mockResolvedValue(false);

      await expect(
        service.generateSessionAttendanceReport({
          purpose: 'eligible-registrations-attendance',
          filters: { programId: 1 },
        } as any),
      ).rejects.toMatchObject({ code: ERROR_CODES.REPORT_FEATURE_DISABLED });

      expect(mockFeatureFlagService.isFeatureEnabledForProgramType).toHaveBeenCalledWith(
        FEATURE_FLAG_KEYS.ENABLE_ELIGIBLE_REGISTRATIONS_REPORT, 7,
      );
    });

    it('does not check any flag for "all-sessions-attendance" (ungated)', async () => {
      mockProgramRepository.findOneById.mockResolvedValue({ id: 1, typeId: 7 });
      mockReportsRepository.fetchSessionAttendanceReportRows.mockResolvedValue([]);

      await service.generateSessionAttendanceReport({
        purpose: 'all-sessions-attendance',
        filters: { programId: 1 },
      } as any);

      expect(mockFeatureFlagService.isFeatureEnabledForProgramType).not.toHaveBeenCalled();
    });
  });

  describe('downloadZoomLinks — feature-flag gating', () => {
    it('throws REPORT_FEATURE_DISABLED when ENABLE_ZOOM_LINKS_REPORT is off for the program type, without fetching links', async () => {
      mockProgramRepository.findOneById.mockResolvedValue({ id: 1, typeId: 7 });
      mockFeatureFlagService.isFeatureEnabledForProgramType.mockResolvedValue(false);

      await expect(
        service.downloadZoomLinks({ programId: 1 } as any),
      ).rejects.toMatchObject({ code: ERROR_CODES.REPORT_FEATURE_DISABLED });

      expect(mockFeatureFlagService.isFeatureEnabledForProgramType).toHaveBeenCalledWith(
        FEATURE_FLAG_KEYS.ENABLE_ZOOM_LINKS_REPORT, 7,
      );
      expect(mockReportsRepository.fetchProgramZoomLinks).not.toHaveBeenCalled();
    });
  });

  describe('getReportFeatureFlags', () => {
    it('throws PROGRAM_NOT_FOUND when the program does not exist', async () => {
      mockProgramRepository.findOneById.mockResolvedValue(null);

      await expect(service.getReportFeatureFlags(999)).rejects.toMatchObject({
        code: ERROR_CODES.PROGRAM_NOT_FOUND,
      });
    });

    it('resolves the flags through the program\'s typeId and returns all 11 keys', async () => {
      mockProgramRepository.findOneById.mockResolvedValue({ id: 1, typeId: 7 });
      mockFeatureFlagService.isFeatureEnabledForProgramType.mockImplementation(
        async (key: string) => key === FEATURE_FLAG_KEYS.ENABLE_ZOOM_LINKS_REPORT,
      );

      const result = await service.getReportFeatureFlags(1);

      expect(result).toEqual({
        [FEATURE_FLAG_KEYS.ENABLE_ZOOM_LINKS_REPORT]: true,
        [FEATURE_FLAG_KEYS.ENABLE_ELIGIBLE_REGISTRATIONS_REPORT]: false,
        [FEATURE_FLAG_KEYS.ENABLE_SINGLE_SESSION_REPORT]: false,
        [FEATURE_FLAG_KEYS.ENABLE_SINGLE_SESSION_ATTENDANCE_REPORT]: false,
        [FEATURE_FLAG_KEYS.ENABLE_SESSION_DETAIL_REPORT]: false,
        [FEATURE_FLAG_KEYS.ENABLE_LATE_COMER_REPORT]: false,
        [FEATURE_FLAG_KEYS.ENABLE_SESSION_DROPOFFS_REPORT]: false,
        [FEATURE_FLAG_KEYS.ENABLE_ATTENDEES_LIVE_REPORT]: false,
        [FEATURE_FLAG_KEYS.ENABLE_GENERAL_SESSION_REPORT]: false,
        [FEATURE_FLAG_KEYS.ENABLE_GENERAL_LATE_COMER_REPORT]: false,
        [FEATURE_FLAG_KEYS.ENABLE_GENERAL_SESSION_DROPOFFS_REPORT]: false,
      });
      expect(mockFeatureFlagService.isFeatureEnabledForProgramType).toHaveBeenCalledWith(
        FEATURE_FLAG_KEYS.ENABLE_ELIGIBLE_REGISTRATIONS_REPORT, 7,
      );
    });
  });

  describe('buildSessionAttendanceExcel', () => {
    it('uses the session attendance field registry for column labels', async () => {
      mockExcelService.jsonToExcelBuffer.mockResolvedValue(Buffer.from('xlsx'));
      mockAwsS3Service.uploadToS3.mockResolvedValue(undefined);
      mockAwsS3Service.getSignedUrl.mockResolvedValue('https://signed-url');

      const url = await service.buildSessionAttendanceExcel(
        [{ sessionName: 'Session 1' }],
        ['sessionName'],
        'session_attendance_report',
      );

      expect(url).toBe('https://signed-url');
      expect(mockExcelService.jsonToExcelBuffer).toHaveBeenCalledWith(
        [{ sessionName: 'Session 1' }],
        expect.objectContaining({ columns: [{ key: 'sessionName', header: 'Session Name' }] }),
      );
    });

    it('uses the provided defsOverride instead of the static registry when given (e.g. dynamic Session N columns)', async () => {
      mockExcelService.jsonToExcelBuffer.mockResolvedValue(Buffer.from('xlsx'));
      mockAwsS3Service.uploadToS3.mockResolvedValue(undefined);
      mockAwsS3Service.getSignedUrl.mockResolvedValue('https://signed-url');

      await service.buildSessionAttendanceExcel(
        [{ session_1: 'Attended' }],
        ['session_1'],
        'eligible_registrations_attendance',
        { session_1: { key: 'session_1', alias: 'session_1', label: 'Session 1', group: 'Attendance', order: 1, format: null, dependsOn: null, requiredFlags: null, conditionalFormat: null } },
      );

      expect(mockReportsRepository.getAllSessionAttendanceFieldDefinitions).not.toHaveBeenCalled();
      expect(mockExcelService.jsonToExcelBuffer).toHaveBeenCalledWith(
        [{ session_1: 'Attended' }],
        expect.objectContaining({ columns: [{ key: 'session_1', header: 'Session 1' }] }),
      );
    });
  });
});
