// src/services/backgroundJobService.test.ts
import { describe, it, expect, vi, beforeEach, afterEach, Mocked } from 'vitest';
import type { Logger } from 'pino';

// Use vi.hoisted to ensure the mock variable is available when vi.mock is executed.
const { mockCronSchedule } = vi.hoisted(() => {
  return { mockCronSchedule: vi.fn() };
});
vi.mock('node-cron', () => ({ default: { schedule: mockCronSchedule } }));

// Mock the logger.server module globally so cron.schedule callbacks use this mock.
vi.mock('../services/logger.server', () => ({
  logger: {
    info: vi.fn(),
    error: vi.fn(),
    warn: vi.fn(),
    debug: vi.fn(),
    fatal: vi.fn(),
    trace: vi.fn(),
    silent: vi.fn(),
  },
}));

// Mock the date utility to control the output for the weekly analytics job
vi.mock('../utils/dateUtils', () => ({
  getSimpleWeekAndYear: vi.fn(() => ({ year: 2024, week: 42 })),
}));

import { BackgroundJobService, startBackgroundJobs } from './backgroundJobService';
import type { Queue } from 'bullmq';
import type { PersonalizationRepository } from './db/personalization.db';
import type { NotificationRepository } from './db/notification.db';
import { logger as globalMockLogger } from '../services/logger.server'; // Import the mocked logger

describe('Background Job Service', () => {
  // Create mock dependencies that will be injected into the service
  const mockPersonalizationRepo = {
    getBestSalePricesForAllUsers: vi.fn(),
  };
  const mockNotificationRepo = {
    createBulkNotifications: vi.fn(),
  };
  const mockEmailQueue = {
    add: vi.fn(),
  };

  beforeEach(() => {
    vi.clearAllMocks();
  });

  afterEach(() => {
    vi.useRealTimers();
  });

  describe('runDailyDealCheck', () => {
    // Mock data representing the result of the new single database query
    const mockDealsForAllUsers = [
      // Deals for user-1
      { user_id: 'user-1', email: 'user1@test.com', full_name: 'User One', master_item_id: 1, item_name: 'Apples', best_price_in_cents: 199, store_name: 'Green Grocer', flyer_id: 101, valid_to: '2024-10-20' },
      // Deals for user-2
      { user_id: 'user-2', email: 'user2@test.com', full_name: 'User Two', master_item_id: 2, item_name: 'Milk', best_price_in_cents: 450, store_name: 'Dairy Farm', flyer_id: 102, valid_to: '2024-10-21' },
      { user_id: 'user-2', email: 'user2@test.com', full_name: 'User Two', master_item_id: 3, item_name: 'Bread', best_price_in_cents: 250, store_name: 'Bakery', flyer_id: 103, valid_to: '2024-10-22' },
    ];

    // Helper to create a type-safe mock logger
    const createMockLogger = (): Logger => ({
      info: vi.fn(),
      error: vi.fn(),
      warn: vi.fn(),
      debug: vi.fn(),
      child: vi.fn(() => createMockLogger()),
    } as unknown as Logger);

    // Instantiate the service with mock dependencies for each test run
    const mockServiceLogger = createMockLogger();
    const service = new BackgroundJobService(
      mockPersonalizationRepo as unknown as PersonalizationRepository,
      mockNotificationRepo as unknown as NotificationRepository,
      mockEmailQueue as unknown as Queue,
      mockServiceLogger
    );

    it('should do nothing if no deals are found for any user', async () => {
      mockPersonalizationRepo.getBestSalePricesForAllUsers.mockResolvedValue([]);
      await service.runDailyDealCheck();
      expect(mockServiceLogger.info).toHaveBeenCalledWith('[BackgroundJob] Starting daily deal check for all users...');
      expect(mockServiceLogger.info).toHaveBeenCalledWith('[BackgroundJob] No deals found for any watched items. Skipping.');
      expect(mockPersonalizationRepo.getBestSalePricesForAllUsers).toHaveBeenCalledTimes(1);
      expect(mockEmailQueue.add).not.toHaveBeenCalled();
      expect(mockNotificationRepo.createBulkNotifications).not.toHaveBeenCalled();
    });

    it('should create notifications and enqueue emails when deals are found', async () => {
      mockPersonalizationRepo.getBestSalePricesForAllUsers.mockResolvedValue(mockDealsForAllUsers);

      await service.runDailyDealCheck();

      // Check that it fetched all deals once
      expect(mockPersonalizationRepo.getBestSalePricesForAllUsers).toHaveBeenCalledTimes(1);

      // Check that email jobs were enqueued for both users
      expect(mockEmailQueue.add).toHaveBeenCalledTimes(2);
      const firstEmailJob = mockEmailQueue.add.mock.calls[0];
      expect(firstEmailJob[1].to).toBe('user1@test.com');
      const secondEmailJob = mockEmailQueue.add.mock.calls[1];
      expect(secondEmailJob[1].to).toBe('user2@test.com');
      // Check the content for the user with multiple deals
      expect(secondEmailJob[1].html).toContain('Milk');
      expect(secondEmailJob[1].html).toContain('Bread');

      // Check that in-app notifications were created for both users
      expect(mockNotificationRepo.createBulkNotifications).toHaveBeenCalledTimes(1);
      const notificationPayload = mockNotificationRepo.createBulkNotifications.mock.calls[0][0];
      expect(notificationPayload).toHaveLength(2);
      // Use expect.arrayContaining to be order-agnostic.
      expect(notificationPayload).toEqual(expect.arrayContaining([
        {
          user_id: 'user-1',
          content: 'You have 1 new deal(s) on your watched items!',
          link_url: '/dashboard/deals',
        },
        {
        user_id: 'user-2',
        content: 'You have 2 new deal(s) on your watched items!',
        link_url: '/dashboard/deals',
        }
      ]));
    });

    it('should handle and log errors for individual users without stopping the process', async () => {
      mockPersonalizationRepo.getBestSalePricesForAllUsers.mockResolvedValue(mockDealsForAllUsers);
      // Simulate the email queue failing for the first user but succeeding for the second
      mockEmailQueue.add
        .mockRejectedValueOnce(new Error('Email queue is down'))
        .mockResolvedValueOnce({ id: 'job-2' });

      await service.runDailyDealCheck();

      // Check that it logged the error for user 1
      expect(mockServiceLogger.error).toHaveBeenCalledWith(
        { err: expect.any(Error) },
        expect.stringContaining('Failed to process deals for user user-1'),
      );

      // Check that it still processed user 2 successfully
      // The email queue add should be attempted for both users.
      expect(mockEmailQueue.add).toHaveBeenCalledTimes(2);
      expect(mockNotificationRepo.createBulkNotifications).toHaveBeenCalledTimes(1);
      expect(mockNotificationRepo.createBulkNotifications.mock.calls[0][0]).toHaveLength(1); // Only one notification created
      expect(mockNotificationRepo.createBulkNotifications.mock.calls[0][0][0].user_id).toBe('user-2');
    });

    it('should log a critical error if getBestSalePricesForAllUsers fails', async () => {
      mockPersonalizationRepo.getBestSalePricesForAllUsers.mockRejectedValue(new Error('Critical DB Failure'));
      await expect(service.runDailyDealCheck()).rejects.toThrow('Critical DB Failure'); // This was a duplicate, fixed.
      expect(mockServiceLogger.error).toHaveBeenCalledWith(
        { err: expect.any(Error) },
        '[BackgroundJob] A critical error occurred during the daily deal check'
      );
    });

    it('should log a critical error if createBulkNotifications fails', async () => {
      // Arrange
      mockPersonalizationRepo.getBestSalePricesForAllUsers.mockResolvedValue(mockDealsForAllUsers);
      const dbError = new Error('Bulk insert failed');
      mockNotificationRepo.createBulkNotifications.mockRejectedValue(dbError);

      // Act & Assert
      await expect(service.runDailyDealCheck()).rejects.toThrow(dbError);
      expect(mockServiceLogger.error).toHaveBeenCalledWith(
        { err: dbError }, '[BackgroundJob] A critical error occurred during the daily deal check'
      );
    });
  });

  describe('startBackgroundJobs', () => {
    const mockBackgroundJobService = {
      runDailyDealCheck: vi.fn(),
    } as unknown as Mocked<BackgroundJobService>;

    const mockAnalyticsQueue = {
      add: vi.fn(),
    } as unknown as Mocked<Queue>;
    const mockWeeklyAnalyticsQueue = {
        add: vi.fn(),
    } as unknown as Mocked<Queue>;

    beforeEach(() => {
      vi.clearAllMocks(); // Clear global mock logger calls too
      mockCronSchedule.mockClear();
      vi.mocked(mockBackgroundJobService.runDailyDealCheck).mockClear();
      vi.mocked(mockAnalyticsQueue.add).mockClear();
      vi.mocked(mockWeeklyAnalyticsQueue.add).mockClear();
    });

    it('should schedule three cron jobs with the correct schedules', () => {
      startBackgroundJobs(mockBackgroundJobService, mockAnalyticsQueue, mockWeeklyAnalyticsQueue, globalMockLogger);

      expect(mockCronSchedule).toHaveBeenCalledTimes(3);
      expect(mockCronSchedule).toHaveBeenCalledWith('0 2 * * *', expect.any(Function));
      expect(mockCronSchedule).toHaveBeenCalledWith('0 3 * * *', expect.any(Function));
      expect(mockCronSchedule).toHaveBeenCalledWith('0 4 * * 0', expect.any(Function));
    });

    it('should call runDailyDealCheck when the first cron job function is executed', async () => {
      startBackgroundJobs(mockBackgroundJobService, mockAnalyticsQueue, mockWeeklyAnalyticsQueue, globalMockLogger);

      // Get the callback function for the first cron job
      const dailyDealCheckCallback = mockCronSchedule.mock.calls[0][1];
      await dailyDealCheckCallback();

      expect(mockBackgroundJobService.runDailyDealCheck).toHaveBeenCalledTimes(1);
    });

    it('should log an error and release the lock if runDailyDealCheck fails', async () => {
      const jobError = new Error('Cron job failed');
      vi.mocked(mockBackgroundJobService.runDailyDealCheck).mockRejectedValue(jobError);
      startBackgroundJobs(mockBackgroundJobService, mockAnalyticsQueue, mockWeeklyAnalyticsQueue, globalMockLogger);

      const dailyDealCheckCallback = mockCronSchedule.mock.calls[0][1];
      await dailyDealCheckCallback();

      expect(mockBackgroundJobService.runDailyDealCheck).toHaveBeenCalledTimes(1);
      expect(globalMockLogger.error).toHaveBeenCalledWith(
        { err: jobError },
        '[BackgroundJob] Cron job for daily deal check failed unexpectedly.'
      );
      // It should run again, proving the lock was released in the finally block
      await dailyDealCheckCallback();
      expect(mockBackgroundJobService.runDailyDealCheck).toHaveBeenCalledTimes(2);
    });

    it('should prevent runDailyDealCheck from running if it is already in progress', async () => {
      // Use fake timers to control promise resolution
      vi.useFakeTimers();

      // Make the first call hang indefinitely
      vi.mocked(mockBackgroundJobService.runDailyDealCheck).mockReturnValue(new Promise(() => {}));

      startBackgroundJobs(mockBackgroundJobService, mockAnalyticsQueue, mockWeeklyAnalyticsQueue, globalMockLogger);
      const dailyDealCheckCallback = mockCronSchedule.mock.calls[0][1];

      // Trigger the job once, it will hang
      const firstCall = dailyDealCheckCallback();
      // Trigger it a second time immediately
      const secondCall = dailyDealCheckCallback();

      await Promise.all([firstCall, secondCall]);

      // The service method should only have been called once
      expect(mockBackgroundJobService.runDailyDealCheck).toHaveBeenCalledTimes(1);
    });

    it('should enqueue an analytics job when the second cron job function is executed', async () => {
      startBackgroundJobs(mockBackgroundJobService, mockAnalyticsQueue, mockWeeklyAnalyticsQueue, globalMockLogger);

      const analyticsJobCallback = mockCronSchedule.mock.calls[1][1];
      await analyticsJobCallback();

      expect(mockAnalyticsQueue.add).toHaveBeenCalledWith('generate-daily-report', expect.any(Object), expect.any(Object));
    });

    it('should log an error if enqueuing the analytics job fails', async () => {
      const queueError = new Error('Redis is down');
      vi.mocked(mockAnalyticsQueue.add).mockRejectedValue(queueError);
      startBackgroundJobs(mockBackgroundJobService, mockAnalyticsQueue, mockWeeklyAnalyticsQueue, globalMockLogger);

      const analyticsJobCallback = mockCronSchedule.mock.calls[1][1];
      await analyticsJobCallback();

      expect(mockAnalyticsQueue.add).toHaveBeenCalledTimes(1);
      expect(globalMockLogger.error).toHaveBeenCalledWith({ err: queueError }, '[BackgroundJob] Failed to enqueue daily analytics job.');
    });

    it('should enqueue a weekly analytics job when the third cron job function is executed', async () => {
      startBackgroundJobs(mockBackgroundJobService, mockAnalyticsQueue, mockWeeklyAnalyticsQueue, globalMockLogger);

      // The weekly job is the third one scheduled
      const weeklyAnalyticsJobCallback = mockCronSchedule.mock.calls[2][1];
      await weeklyAnalyticsJobCallback();

      expect(mockWeeklyAnalyticsQueue.add).toHaveBeenCalledWith(
        'generate-weekly-report',
        { reportYear: 2024, reportWeek: 42 }, // Values from the mocked dateUtils
        { jobId: 'weekly-report-2024-42' }
      );
    });

    it('should log an error if enqueuing the weekly analytics job fails', async () => {
      const queueError = new Error('Redis is down for weekly job');
      vi.mocked(mockWeeklyAnalyticsQueue.add).mockRejectedValue(queueError);
      startBackgroundJobs(mockBackgroundJobService, mockAnalyticsQueue, mockWeeklyAnalyticsQueue, globalMockLogger);

      const weeklyAnalyticsJobCallback = mockCronSchedule.mock.calls[2][1];
      await weeklyAnalyticsJobCallback();

      expect(globalMockLogger.error).toHaveBeenCalledWith({ err: queueError }, '[BackgroundJob] Failed to enqueue weekly analytics job.');
    });
  });
});