// src/services/queueService.workers.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { Job } from 'bullmq';

// --- Hoisted Mocks ---
const mocks = vi.hoisted(() => {
  // This object will store the processor functions captured from the worker constructors.
  const capturedProcessors: Record<string, (job: Job) => Promise<unknown>> = {};

  return {
    sendEmail: vi.fn(),
    unlink: vi.fn(),
    processFlyerJob: vi.fn(),
    capturedProcessors,
    // Mock the Worker constructor to capture the processor function. It must be a
    // `function` and not an arrow function so it can be called with `new`.
    MockWorker: vi.fn(function(name: string, processor: (job: Job) => Promise<unknown>) {
      if (processor) {
        capturedProcessors[name] = processor;
      }
      // Return a mock worker instance, though it's not used in this test file.
      return { on: vi.fn(), close: vi.fn() };
    }),
  };
});

// --- Mock Modules ---
vi.mock('./emailService.server', async (importOriginal) => {
  const actual = await importOriginal<typeof import('./emailService.server')>();
  return {
    ...actual,
    // We only need to mock the specific function being called by the worker.
    // The rest of the module can retain its original implementation if needed elsewhere.
    sendEmail: mocks.sendEmail,
  };
});

// The workers use an `fsAdapter`. We can mock the underlying `fsPromises`
// that the adapter is built from in queueService.server.ts.
vi.mock('node:fs/promises', () => ({
  default: {
    unlink: mocks.unlink,
    // Add other fs functions if needed by other tests
    readdir: vi.fn(),
  },
}));

vi.mock('./logger.server', () => ({
  logger: {
    info: vi.fn(),
    error: vi.fn(),
    warn: vi.fn(),
    debug: vi.fn(),
    child: vi.fn().mockReturnThis(),
  },
}));

// Mock bullmq to capture the processor functions passed to the Worker constructor
vi.mock('bullmq', () => ({
  Worker: mocks.MockWorker,
  // FIX: Use a standard function for the mock constructor to allow `new Queue(...)` to work.
  Queue: vi.fn(function() {
    return { add: vi.fn() };
  }),
}));

// Mock flyerProcessingService.server as flyerWorker depends on it
vi.mock('./flyerProcessingService.server', () => ({
  FlyerProcessingService: class {
    processJob = mocks.processFlyerJob;
  },
}));

// Mock flyerDataTransformer as it's a dependency of FlyerProcessingService
vi.mock('./flyerDataTransformer', () => ({
  FlyerDataTransformer: class {
    transform = vi.fn(); // Mock transform method
  },
}));

// Import the module under test AFTER the mocks are set up.
// This will trigger the instantiation of the workers.
import './queueService.server';

// Destructure the captured processors for easier use in tests.
const {
  'flyer-processing': flyerProcessor,
  'email-sending': emailProcessor,
  'analytics-reporting': analyticsProcessor,
  'file-cleanup': cleanupProcessor,
  'weekly-analytics-reporting': weeklyAnalyticsProcessor,
} = mocks.capturedProcessors;

// Helper to create a mock BullMQ Job object
const createMockJob = <T>(data: T): Job<T> => {
  return {
    id: 'job-1',
    data,
    updateProgress: vi.fn().mockResolvedValue(undefined),
    log: vi.fn().mockResolvedValue(undefined),
    opts: { attempts: 3 },
    attemptsMade: 1,
    trace: vi.fn().mockResolvedValue(undefined),
    moveToCompleted: vi.fn().mockResolvedValue(undefined),
    moveToFailed: vi.fn().mockResolvedValue(undefined),
  } as unknown as Job<T>;
};

describe('Queue Workers', () => {
  beforeEach(() => {
    vi.clearAllMocks();
    // Reset default mock implementations for hoisted mocks
    mocks.sendEmail.mockResolvedValue(undefined);
    mocks.unlink.mockResolvedValue(undefined);
    mocks.processFlyerJob.mockResolvedValue({ flyerId: 123 }); // Default success for flyer processing
  });

  describe('flyerWorker', () => {
    it('should call flyerProcessingService.processJob with the job data', async () => {
      const jobData = { filePath: '/tmp/flyer.pdf', originalFileName: 'flyer.pdf', checksum: 'abc' };
      const job = createMockJob(jobData);

      await flyerProcessor(job);

      expect(mocks.processFlyerJob).toHaveBeenCalledTimes(1);
      expect(mocks.processFlyerJob).toHaveBeenCalledWith(job);
    });

    it('should re-throw an error if flyerProcessingService.processJob fails', async () => {
      const job = createMockJob({ filePath: '/tmp/fail.pdf', originalFileName: 'fail.pdf', checksum: 'def' });
      const processingError = new Error('Flyer processing failed');
      mocks.processFlyerJob.mockRejectedValue(processingError);

      await expect(flyerProcessor(job)).rejects.toThrow('Flyer processing failed');
    });
  });

  describe('emailWorker', () => {
    it('should call emailService.sendEmail with the job data', async () => {
      const jobData = {
        to: 'test@example.com',
        subject: 'Test Email',
        html: '<p>Hello</p>',
        text: 'Hello',
      };
      const job = createMockJob(jobData);

      await emailProcessor(job);

      expect(mocks.sendEmail).toHaveBeenCalledTimes(1);
      // The implementation passes the logger as the second argument
      expect(mocks.sendEmail).toHaveBeenCalledWith(jobData, expect.anything());
    });

    it('should re-throw an error if sendEmail fails', async () => {
      const job = createMockJob({ to: 'fail@example.com', subject: 'fail', html: '', text: '' });
      const emailError = new Error('SMTP server is down');
      mocks.sendEmail.mockRejectedValue(emailError);

      await expect(emailProcessor(job)).rejects.toThrow('SMTP server is down');
    });
  });

  describe('analyticsWorker', () => {
    it('should complete successfully for a valid report date', async () => {
      vi.useFakeTimers();
      const job = createMockJob({ reportDate: '2024-01-01' });

      const promise = analyticsProcessor(job);
      // Advance timers to simulate the 10-second task completing
      await vi.advanceTimersByTimeAsync(10000);
      await promise; // Wait for the promise to resolve

      // No error should be thrown
      expect(true).toBe(true);
      vi.useRealTimers();
    });

    it('should throw an error if reportDate is "FAIL"', async () => {
      const job = createMockJob({ reportDate: 'FAIL' });

      await expect(analyticsProcessor(job)).rejects.toThrow('This is a test failure for the analytics job.');
    });
  });

  describe('cleanupWorker', () => {
    it('should call unlink for each path provided in the job data', async () => {
      const jobData = {
        flyerId: 123,
        paths: ['/tmp/file1.jpg', '/tmp/file2.pdf'],
      };
      const job = createMockJob(jobData);
      mocks.unlink.mockResolvedValue(undefined);

      await cleanupProcessor(job);

      expect(mocks.unlink).toHaveBeenCalledTimes(2);
      expect(mocks.unlink).toHaveBeenCalledWith('/tmp/file1.jpg');
      expect(mocks.unlink).toHaveBeenCalledWith('/tmp/file2.pdf');
    });

    it('should not throw an error if a file is already deleted (ENOENT)', async () => {
      const jobData = {
        flyerId: 123,
        paths: ['/tmp/existing.jpg', '/tmp/already-deleted.jpg'],
      };
      const job = createMockJob(jobData);
      // Use the built-in NodeJS.ErrnoException type for mock system errors.
      const enoentError: NodeJS.ErrnoException = new Error('File not found');
      enoentError.code = 'ENOENT';

      // First call succeeds, second call fails with ENOENT
      mocks.unlink
        .mockResolvedValueOnce(undefined)
        .mockRejectedValueOnce(enoentError);

      // The processor should complete without throwing
      await expect(cleanupProcessor(job)).resolves.toBeUndefined();

      expect(mocks.unlink).toHaveBeenCalledTimes(2);
    });

    it('should re-throw an error for issues other than ENOENT (e.g., permissions)', async () => {
      const jobData = {
        flyerId: 123,
        paths: ['/tmp/protected-file.jpg'],
      };
      const job = createMockJob(jobData);
      // Use the built-in NodeJS.ErrnoException type for mock system errors.
      const permissionError: NodeJS.ErrnoException = new Error('Permission denied');
      permissionError.code = 'EACCES';

      mocks.unlink.mockRejectedValue(permissionError);

      await expect(cleanupProcessor(job)).rejects.toThrow('Permission denied');
    });
  });

  describe('weeklyAnalyticsWorker', () => {
    it('should complete successfully for a valid report date', async () => {
      vi.useFakeTimers();
      const job = createMockJob({ reportYear: 2024, reportWeek: 1 });

      const promise = weeklyAnalyticsProcessor(job);
      // Advance timers to simulate the 30-second task completing
      await vi.advanceTimersByTimeAsync(30000);
      await promise; // Wait for the promise to resolve

      // No error should be thrown
      expect(true).toBe(true);
      vi.useRealTimers();
    });

    it('should re-throw an error if the job fails', async () => {
      vi.useFakeTimers();
      const job = createMockJob({ reportYear: 2024, reportWeek: 1 });
      // Mock the internal logic to throw an error
      const originalSetTimeout = setTimeout;
      vi.spyOn(global, 'setTimeout').mockImplementation((callback, ms) => {
        if (ms === 30000) { // Target the simulated delay
          throw new Error('Weekly analytics job failed');
        }
        return originalSetTimeout(callback, ms);
      });

      await expect(weeklyAnalyticsProcessor(job)).rejects.toThrow('Weekly analytics job failed');
      vi.useRealTimers();
      vi.restoreAllMocks(); // Restore setTimeout mock
    });
  });
});
