// src/services/flyerProcessingService.server.test.ts
import { describe, it, expect, vi, beforeEach, type Mocked } from 'vitest';
import { Job } from 'bullmq';
import type { Dirent } from 'node:fs';
import type { Logger } from 'pino';
import { z } from 'zod';
import { AiFlyerDataSchema } from './flyerProcessingService.server';
import type { Flyer, FlyerInsert } from '../types';

export interface FlyerJobData {
  filePath: string;
  originalFileName: string;
  checksum: string;
  userId?: string;
  submitterIp?: string;
  userProfileAddress?: string;
}

// 1. Create hoisted mocks FIRST
const mocks = vi.hoisted(() => ({
  unlink: vi.fn(),
  readdir: vi.fn(),
  execAsync: vi.fn(),
}));

// 2. Mock modules using the hoisted variables
vi.mock('util', () => ({ promisify: () => mocks.execAsync }));
vi.mock('node:fs/promises', async (importOriginal) => {
  const actual = await importOriginal<typeof import('node:fs/promises')>();
  return {
    ...actual,
    default: actual, // Ensure default export exists
    unlink: mocks.unlink,
    readdir: mocks.readdir,
  };
});

// Import service and dependencies (FlyerJobData already imported from types above)
import { FlyerProcessingService } from './flyerProcessingService.server';
import * as aiService from './aiService.server';
import * as db from './db/index.db';
import { createFlyerAndItems } from './db/flyer.db';
import * as imageProcessor from '../utils/imageProcessor';
import { FlyerDataTransformer } from './flyerDataTransformer';
import { AiDataValidationError, PdfConversionError } from './processingErrors';

// Mock dependencies
vi.mock('./aiService.server', () => ({
  aiService: {
    extractCoreDataFromFlyerImage: vi.fn(),
  },
}));
vi.mock('./db/flyer.db', () => ({
  createFlyerAndItems: vi.fn(),
}));
vi.mock('./db/index.db', () => ({
  personalizationRepo: { getAllMasterItems: vi.fn() },
  adminRepo: { logActivity: vi.fn() },
}));
vi.mock('../utils/imageProcessor', () => ({
  generateFlyerIcon: vi.fn().mockResolvedValue('icon-test.webp'),
}));
vi.mock('./logger.server', () => ({
  logger: { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn(), child: vi.fn().mockReturnThis() }
}));

const mockedAiService = aiService as Mocked<typeof aiService>;
const mockedDb = db as Mocked<typeof db>;
const mockedImageProcessor = imageProcessor as Mocked<typeof imageProcessor>;

describe('FlyerProcessingService', () => {
  let service: FlyerProcessingService;
  const mockCleanupQueue = {
    add: vi.fn(),
  };

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

    // Spy on the real transformer's method and provide a mock implementation.
    // This is more robust than mocking the entire class constructor.
    vi.spyOn(FlyerDataTransformer.prototype, 'transform').mockResolvedValue({
      flyerData: { file_name: 'test.jpg', image_url: 'test.jpg', icon_url: 'icon.webp', checksum: 'checksum-123', store_name: 'Mock Store' } as FlyerInsert,
      itemsForDb: [],
    });

    // Default mock implementation for the promisified exec
    mocks.execAsync.mockResolvedValue({ stdout: 'success', stderr: '' });

    // Default mock for readdir returns an empty array of Dirent-like objects.
    mocks.readdir.mockResolvedValue([]);

    // Mock the file system adapter that will be passed to the service
    const mockFs = {
      readdir: mocks.readdir,
      unlink: mocks.unlink,
    };

    // Instantiate the service with all its dependencies mocked
    service = new FlyerProcessingService(
      mockedAiService.aiService,
      mockedDb,
      mockFs,
      mocks.execAsync,
      mockCleanupQueue,
      new FlyerDataTransformer()
    );

    // Provide default successful mock implementations for dependencies
    vi.mocked(mockedAiService.aiService.extractCoreDataFromFlyerImage).mockResolvedValue({
      store_name: 'Mock Store',
      valid_from: '2024-01-01',
      valid_to: '2024-01-07',
      store_address: '123 Mock St',
      items: [{ item: 'Test Item', price_display: '$1.99', price_in_cents: 199, quantity: 'each', category_name: 'Test Category', master_item_id: 1 }],
    });
    vi.mocked(createFlyerAndItems).mockResolvedValue({
      flyer: { flyer_id: 1, file_name: 'test.jpg', image_url: 'test.jpg', item_count: 1, created_at: new Date().toISOString() } as Flyer,
      items: [],
    });
    mockedImageProcessor.generateFlyerIcon.mockResolvedValue('icon-test.jpg');
    vi.mocked(mockedDb.adminRepo.logActivity).mockResolvedValue();
    // FIX: Provide a default mock for getAllMasterItems to prevent a TypeError on `.length`.
    vi.mocked(mockedDb.personalizationRepo.getAllMasterItems).mockResolvedValue([]);
  });

  const createMockJob = (data: Partial<FlyerJobData>): Job<FlyerJobData> => {
    return {
      id: 'job-1',
      data: {
        filePath: '/tmp/flyer.jpg',
        originalFileName: 'flyer.jpg',
        checksum: 'checksum-123',
        ...data,
      },
      updateProgress: vi.fn(),
      opts: { attempts: 3 },
      attemptsMade: 1,
    } as unknown as Job<FlyerJobData>;
  };

  describe('processJob (Orchestrator)', () => {
    it('should process an image file successfully and enqueue a cleanup job', async () => {
      const job = createMockJob({ filePath: '/tmp/flyer.jpg', originalFileName: 'flyer.jpg' });

      const result = await service.processJob(job);

      expect(result).toEqual({ flyerId: 1 });
      expect(mockedAiService.aiService.extractCoreDataFromFlyerImage).toHaveBeenCalledTimes(1);
      expect(createFlyerAndItems).toHaveBeenCalledTimes(1);
      expect(mockedDb.adminRepo.logActivity).toHaveBeenCalledTimes(1);
      expect(mocks.execAsync).not.toHaveBeenCalled();
      expect(mockCleanupQueue.add).toHaveBeenCalledWith(
        'cleanup-flyer-files',
        { flyerId: 1, paths: ['/tmp/flyer.jpg'] },
        expect.any(Object)
      );
    });

    it('should convert a PDF, process its images, and enqueue a cleanup job for all files', async () => {
      const job = createMockJob({ filePath: '/tmp/flyer.pdf', originalFileName: 'flyer.pdf' });

      // Mock readdir to return Dirent-like objects for the converted files
      mocks.readdir.mockResolvedValue([
        { name: 'flyer-1.jpg' },
        { name: 'flyer-2.jpg' },
      ] as Dirent[]);

      await service.processJob(job);

      // Verify that pdftocairo was called
      expect(mocks.execAsync).toHaveBeenCalledWith(
        expect.stringContaining('pdftocairo -jpeg -r 150')
      );
      // Verify AI service was called with the converted images
      expect(mockedAiService.aiService.extractCoreDataFromFlyerImage).toHaveBeenCalledWith(
        expect.arrayContaining([
          expect.objectContaining({ path: expect.stringContaining('flyer-1.jpg') }),
          expect.objectContaining({ path: expect.stringContaining('flyer-2.jpg') }),
        ]),
        expect.any(Array),
        undefined, // submitterIp
        undefined, // userProfileAddress
        expect.any(Object) // The job-specific logger
      );
      expect(createFlyerAndItems).toHaveBeenCalledTimes(1);
      // Verify cleanup job includes original PDF and both generated images
      expect(mockCleanupQueue.add).toHaveBeenCalledWith(
        'cleanup-flyer-files',
        { flyerId: 1, paths: ['/tmp/flyer.pdf', expect.stringContaining('flyer-1.jpg'), expect.stringContaining('flyer-2.jpg')] },
        expect.any(Object)
      );
    });

    it('should throw an error and not enqueue cleanup if the AI service fails', async () => {
      const job = createMockJob({});
      const aiError = new Error('AI model exploded');
      vi.mocked(mockedAiService.aiService.extractCoreDataFromFlyerImage).mockRejectedValue(aiError);

      await expect(service.processJob(job)).rejects.toThrow('AI model exploded');

      expect(job.updateProgress).toHaveBeenCalledWith({ message: 'Error: AI model exploded' });
      expect(mockCleanupQueue.add).not.toHaveBeenCalled();
    });

    it('should throw PdfConversionError and not enqueue cleanup if PDF conversion fails', async () => {
      const job = createMockJob({ filePath: '/tmp/bad.pdf', originalFileName: 'bad.pdf' });
      const conversionError = new PdfConversionError('Conversion failed', 'pdftocairo error');
      // Make the conversion step fail
      mocks.execAsync.mockRejectedValue(conversionError);

      await expect(service.processJob(job)).rejects.toThrow(conversionError);

      expect(job.updateProgress).toHaveBeenCalledWith({ message: 'Error: Conversion failed' });
      expect(mockCleanupQueue.add).not.toHaveBeenCalled();
    });

    it('should throw AiDataValidationError and not enqueue cleanup if AI validation fails', async () => {
      const { logger } = await import('./logger.server');
      const job = createMockJob({});
      const validationError = new AiDataValidationError('Validation failed', {}, {});
      // Make the AI extraction step fail with a validation error
      vi.mocked(mockedAiService.aiService.extractCoreDataFromFlyerImage).mockRejectedValue(validationError);

      await expect(service.processJob(job)).rejects.toThrow(validationError);

      // Verify the specific error handling logic in the catch block
      expect(logger.error).toHaveBeenCalledWith({ err: validationError, validationErrors: {}, rawData: {} }, 'AI Data Validation failed.');
      expect(job.updateProgress).toHaveBeenCalledWith({ message: 'Error: Validation failed' });
      expect(mockCleanupQueue.add).not.toHaveBeenCalled();
    });

    it('should throw an error if the database service fails', async () => {
      const job = createMockJob({});
      const dbError = new Error('Database transaction failed');
      vi.mocked(createFlyerAndItems).mockRejectedValue(dbError);

      await expect(service.processJob(job)).rejects.toThrow('Database transaction failed');

      expect(job.updateProgress).toHaveBeenCalledWith({ message: 'Error: Database transaction failed' });
      expect(mockCleanupQueue.add).not.toHaveBeenCalled();
    });

    it('should log a warning and not enqueue cleanup if the job fails but a flyer ID was somehow generated', async () => {
      const job = createMockJob({});
      vi.mocked(createFlyerAndItems).mockRejectedValue(new Error('DB Error'));
      await expect(service.processJob(job)).rejects.toThrow();
      expect(mockCleanupQueue.add).not.toHaveBeenCalled();
    });
  });

  describe('_enqueueCleanup (private method)', () => {
    it('should enqueue a cleanup job with the correct parameters', async () => {
      const { logger } = await import('./logger.server');
      const flyerId = 42;
      const paths = ['/tmp/file1.jpg', '/tmp/file2.pdf'];
      
      // Access and call the private method for testing
      await (service as unknown as { _enqueueCleanup: (flyerId: number, paths: string[], logger: Logger) => Promise<void> })._enqueueCleanup(flyerId, paths, logger);

      expect(mockCleanupQueue.add).toHaveBeenCalledWith(
        'cleanup-flyer-files',
        { flyerId, paths },
        { jobId: `cleanup-flyer-${flyerId}`, removeOnComplete: true }
      );
    });

    it('should not call the queue if the paths array is empty', async () => {
      const { logger } = await import('./logger.server');
      // Access and call the private method with an empty array
      await (service as unknown as { _enqueueCleanup: (flyerId: number, paths: string[], logger: Logger) => Promise<void> })._enqueueCleanup(123, [], logger);

      expect(mockCleanupQueue.add).not.toHaveBeenCalled();
    });
  });

  describe('_saveProcessedFlyerData (private method)', () => {
    it('should transform data, create flyer in DB, and log activity', async () => {
      const { logger } = await import('./logger.server');
      // Arrange
      const mockExtractedData = {
        store_name: 'Test Store',
        valid_from: '2024-01-01',
        valid_to: '2024-01-07',
        store_address: '123 Mock St',
        items: [{ item: 'Test Item', price_display: '$1.99', price_in_cents: 199, quantity: 'each', category_name: 'Test Category', master_item_id: 1 }],
      };
      const mockImagePaths = [{ path: '/tmp/flyer.jpg', mimetype: 'image/jpeg' }];
      const mockJobData = {
        filePath: '/tmp/flyer.jpg',
        originalFileName: 'flyer.jpg',
        checksum: 'checksum-123',
        userId: 'user-abc',
      };

      // The DB create function is also mocked in beforeEach.
      // Create a complete mock that satisfies the Flyer type.
      const mockNewFlyer: Flyer = {
        flyer_id: 1,
        file_name: 'flyer.jpg',
        image_url: '/flyer-images/flyer.jpg',
        icon_url: '/flyer-images/icons/icon-flyer.webp',
        checksum: 'checksum-123',
        store_id: 1,
        item_count: 1,
        created_at: new Date().toISOString(),
      };
      vi.mocked(createFlyerAndItems).mockResolvedValue({ flyer: mockNewFlyer, items: [] });

      // Act: Access and call the private method for testing
      const result = await (service as unknown as { _saveProcessedFlyerData: (extractedData: z.infer<typeof AiFlyerDataSchema>, imagePaths: { path: string; mimetype: string }[], jobData: FlyerJobData, logger: Logger) => Promise<Flyer> })._saveProcessedFlyerData(mockExtractedData, mockImagePaths, mockJobData, logger);

      // Assert
      // 1. Transformer was called correctly
      expect(FlyerDataTransformer.prototype.transform).toHaveBeenCalledWith(mockExtractedData, mockImagePaths, mockJobData.originalFileName, mockJobData.checksum, mockJobData.userId, logger);

      // 2. DB function was called with the transformed data
      // The data comes from the mock defined in `beforeEach`.
      expect(createFlyerAndItems).toHaveBeenCalledWith(
        expect.objectContaining({ store_name: 'Mock Store', checksum: 'checksum-123' }),
        [], // itemsForDb from the mock
        logger
      );

      // 3. Activity was logged with all expected fields
      expect(mockedDb.adminRepo.logActivity).toHaveBeenCalledWith({
        userId: 'user-abc',
        action: 'flyer_processed' as const,
        displayText: 'Processed a new flyer for Mock Store.', // This was a duplicate, fixed.
        details: { flyerId: 1, storeName: 'Mock Store' },
      }, logger);

      // 4. The method returned the new flyer
      expect(result).toEqual(mockNewFlyer);
    });
  });

  describe('_convertPdfToImages (private method)', () => {
    it('should call pdftocairo and return sorted image paths on success', async () => {
      const { logger } = await import('./logger.server');
      const job = createMockJob({ filePath: '/tmp/test.pdf' });
      // Mock readdir to return unsorted Dirent-like objects
      mocks.readdir.mockResolvedValue([
        { name: 'test-10.jpg' },
        { name: 'test-1.jpg' },
        { name: 'test-2.jpg' },
        { name: 'other-file.txt' },
      ] as Dirent[]);

      // Access and call the private method for testing
      const imagePaths = await (service as unknown as { _convertPdfToImages: (filePath: string, job: Job, logger: Logger) => Promise<string[]> })._convertPdfToImages('/tmp/test.pdf', job, logger);

      expect(mocks.execAsync).toHaveBeenCalledWith(
        'pdftocairo -jpeg -r 150 "/tmp/test.pdf" "/tmp/test"'
      );
      expect(job.updateProgress).toHaveBeenCalledWith({ message: 'Converting PDF to images...' });
      // Verify that the paths are correctly sorted numerically
      expect(imagePaths).toEqual([
        '/tmp/test-1.jpg',
        '/tmp/test-2.jpg',
        '/tmp/test-10.jpg',
      ]);
    });

    it('should throw PdfConversionError if no images are generated', async () => {
      const { logger } = await import('./logger.server');
      const job = createMockJob({ filePath: '/tmp/empty.pdf' });
      // Mock readdir to return no matching files
      mocks.readdir.mockResolvedValue([]);

      await expect((service as unknown as { _convertPdfToImages: (filePath: string, job: Job, logger: Logger) => Promise<string[]> })._convertPdfToImages('/tmp/empty.pdf', job, logger))
        .rejects.toThrow('PDF conversion resulted in 0 images for file: /tmp/empty.pdf');
    });

    it('should re-throw an error if the exec command fails', async () => {
      const { logger } = await import('./logger.server');
      const job = createMockJob({ filePath: '/tmp/bad.pdf' });
      const commandError = new Error('pdftocairo not found');
      mocks.execAsync.mockRejectedValue(commandError);

      await expect((service as unknown as { _convertPdfToImages: (filePath: string, job: Job, logger: Logger) => Promise<string[]> })._convertPdfToImages('/tmp/bad.pdf', job, logger))
        .rejects.toThrow(commandError);
    });
  });
});
