// src/services/flyerProcessingService.server.test.ts
import { describe, it, expect, vi, beforeEach, type Mocked } from 'vitest';
import { Job, UnrecoverableError } from 'bullmq';
import path from 'node:path';
import type { FlyerInsert } from '../types';
import type { CleanupJobData, FlyerJobData } from '../types/job-data';

// 1. Create hoisted mocks FIRST
const mocks = vi.hoisted(() => ({
  unlink: vi.fn(),
  rename: vi.fn(),
  readdir: vi.fn(),
  execAsync: vi.fn(),
  mockAdminLogActivity: vi.fn(),
  // Shared mock logger for verifying calls
  sharedMockLogger: {
    info: vi.fn(),
    error: vi.fn(),
    warn: vi.fn(),
    debug: vi.fn(),
    child: vi.fn().mockReturnThis(),
  },
}));

// 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,
    rename: mocks.rename,
  };
});

// Import service and dependencies (FlyerJobData already imported from types above)
import { FlyerProcessingService } from './flyerProcessingService.server';
import * as db from './db/index.db';
import { createMockFlyer } from '../tests/utils/mockFactories';
import { FlyerDataTransformer } from './flyerDataTransformer';
import {
  AiDataValidationError,
  PdfConversionError,
  UnsupportedFileTypeError,
  DatabaseError,
} from './processingErrors';
import { NotFoundError } from './db/errors.db';
import { FlyerFileHandler } from './flyerFileHandler.server';
import { FlyerAiProcessor } from './flyerAiProcessor.server';
import type { IFileSystem } from './flyerFileHandler.server';
import { generateFlyerIcon } from '../utils/imageProcessor';
import type { AIService } from './aiService.server';
import { FlyerPersistenceService } from './flyerPersistenceService.server';

// Mock image processor functions
vi.mock('../utils/imageProcessor', () => ({
  generateFlyerIcon: vi.fn(),
}));

// Mock dependencies
vi.mock('./aiService.server', () => ({
  aiService: {
    extractCoreDataFromFlyerImage: vi.fn(),
  },
}));
vi.mock('./db/index.db', () => ({
  personalizationRepo: { getAllMasterItems: vi.fn() },
  adminRepo: { logActivity: vi.fn() },
  flyerRepo: { getFlyerById: vi.fn() },
  withTransaction: vi.fn(),
}));
vi.mock('./db/admin.db', () => ({
  AdminRepository: vi.fn().mockImplementation(function () {
    return { logActivity: mocks.mockAdminLogActivity };
  }),
}));
// Use the hoisted shared mock logger instance so tests can verify calls
vi.mock('./logger.server', () => ({
  logger: mocks.sharedMockLogger,
  createScopedLogger: vi.fn(() => mocks.sharedMockLogger),
}));
vi.mock('./flyerFileHandler.server');
vi.mock('./flyerAiProcessor.server');
vi.mock('./flyerPersistenceService.server');

const mockedDb = db as Mocked<typeof db>;

describe('FlyerProcessingService', () => {
  let service: FlyerProcessingService;
  let mockFileHandler: Mocked<FlyerFileHandler>;
  let mockAiProcessor: Mocked<FlyerAiProcessor>;
  let mockPersistenceService: Mocked<FlyerPersistenceService>;
  const mockCleanupQueue = {
    add: vi.fn(),
  };

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

    // Provide a default mock implementation for withTransaction that just executes the callback.
    // This is needed for the happy path tests. Tests for transaction failures will override this.
    vi.mocked(mockedDb.withTransaction).mockImplementation(async (callback: any) => callback({}));

    // 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: 'https://example.com/test.jpg',
        icon_url: 'https://example.com/icon.webp',
        store_name: 'Mock Store',
        // Add required fields for FlyerInsert type
        status: 'processed',
        item_count: 0,
        valid_from: '2024-01-01',
        valid_to: '2024-01-07',
      } as FlyerInsert, // Cast is okay here as it's a mock value
      itemsForDb: [],
    });

    // 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: IFileSystem = {
      readdir: mocks.readdir,
      unlink: mocks.unlink,
      rename: mocks.rename,
    };

    mockFileHandler = new FlyerFileHandler(mockFs, vi.fn()) as Mocked<FlyerFileHandler>;
    mockAiProcessor = new FlyerAiProcessor(
      {} as AIService,
      mockedDb.personalizationRepo,
    ) as Mocked<FlyerAiProcessor>;
    mockPersistenceService = new FlyerPersistenceService() as Mocked<FlyerPersistenceService>;

    // Instantiate the service with all its dependencies mocked
    service = new FlyerProcessingService(
      mockFileHandler,
      mockAiProcessor,
      mockFs,
      mockCleanupQueue,
      new FlyerDataTransformer(),
      mockPersistenceService,
    );

    // Provide default successful mock implementations for dependencies
    mockAiProcessor.extractAndValidateData.mockResolvedValue({
      data: {
        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,
          },
        ],
      },
      needsReview: false,
    });
    mockFileHandler.prepareImageInputs.mockResolvedValue({
      imagePaths: [{ path: '/tmp/flyer.jpg', mimetype: 'image/jpeg' }],
      createdImagePaths: [],
    });

    mockPersistenceService.saveFlyer.mockResolvedValue(
      createMockFlyer({
        flyer_id: 1,
        file_name: 'test.jpg',
        image_url: 'https://example.com/test.jpg',
        item_count: 1,
      }),
    );
    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([]);
  });
  beforeEach(() => {
    vi.mocked(generateFlyerIcon).mockResolvedValue('icon-flyer.webp');
  });

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

  const createMockCleanupJob = (data: CleanupJobData): Job<CleanupJobData> => {
    return {
      id: `cleanup-job-${data.flyerId}`,
      data,
      opts: { attempts: 3 },
      attemptsMade: 1,
      updateProgress: vi.fn(),
    } as unknown as Job<CleanupJobData>;
  };

  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' });

      // Arrange: Mock dependencies to simulate a successful run
      mockFileHandler.prepareImageInputs.mockResolvedValue({
        imagePaths: [{ path: '/tmp/flyer-processed.jpeg', mimetype: 'image/jpeg' }],
        createdImagePaths: ['/tmp/flyer-processed.jpeg'],
      });
      vi.mocked(generateFlyerIcon).mockResolvedValue('icon-flyer.webp');

      const result = await service.processJob(job);

      expect(result).toEqual({ flyerId: 1 });

      // 1. File handler was called
      expect(mockFileHandler.prepareImageInputs).toHaveBeenCalledWith(
        job.data.filePath,
        job,
        expect.any(Object),
      );

      // 2. Optimization was called
      expect(mockFileHandler.optimizeImages).toHaveBeenCalledWith(
        expect.any(Array),
        expect.any(Object),
      );

      // 3. AI processor was called
      expect(mockAiProcessor.extractAndValidateData).toHaveBeenCalledTimes(1);

      // 4. Icon was generated from the processed image
      expect(generateFlyerIcon).toHaveBeenCalledWith(
        '/tmp/flyer-processed.jpeg',
        path.join('/tmp', 'icons'),
        expect.any(Object),
      );

      // 5. Transformer was called with the correct filenames
      expect(FlyerDataTransformer.prototype.transform).toHaveBeenCalledWith(
        expect.any(Object), // aiResult
        'flyer.jpg', // originalFileName
        'flyer-processed.jpeg', // imageFileName
        'icon-flyer.webp', // iconFileName
        'checksum-123', // checksum
        undefined, // userId
        expect.any(Object), // logger
        'https://example.com', // baseUrl
      );

      // 6. Persistence service was called
      expect(mockPersistenceService.saveFlyer).toHaveBeenCalledWith(
        expect.any(Object), // flyerData
        [], // itemsForDb
        undefined, // userId
        expect.any(Object), // logger
      );

      // 7. Cleanup job was enqueued with all generated files
      expect(mockCleanupQueue.add).toHaveBeenCalledWith(
        'cleanup-flyer-files',
        expect.objectContaining({
          flyerId: 1,
          paths: expect.arrayContaining([
            expect.stringContaining('flyer.jpg'), // original job path
            expect.stringContaining('flyer-processed.jpeg'), // from prepareImageInputs
            expect.stringContaining('icon-flyer.webp'), // from generateFlyerIcon
          ]),
        }),
        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 the file handler to return multiple created paths
      const createdPaths = ['/tmp/flyer-1.jpg', '/tmp/flyer-2.jpg'];
      mockFileHandler.prepareImageInputs.mockResolvedValue({
        imagePaths: [
          { path: '/tmp/flyer-1.jpg', mimetype: 'image/jpeg' },
          { path: '/tmp/flyer-2.jpg', mimetype: 'image/jpeg' },
        ],
        createdImagePaths: createdPaths,
      });
      vi.mocked(generateFlyerIcon).mockResolvedValue('icon-flyer-1.webp');

      await service.processJob(job);

      // Verify transaction and inner calls
      expect(mockFileHandler.prepareImageInputs).toHaveBeenCalledWith(
        '/tmp/flyer.pdf',
        job,
        expect.any(Object),
      );
      expect(mockAiProcessor.extractAndValidateData).toHaveBeenCalledTimes(1);
      // Verify icon generation was called for the first page
      expect(generateFlyerIcon).toHaveBeenCalledWith(
        '/tmp/flyer-1.jpg',
        path.join('/tmp', 'icons'),
        expect.any(Object),
      );
      // Verify cleanup job includes original PDF and all generated/processed images
      expect(mockCleanupQueue.add).toHaveBeenCalledWith(
        'cleanup-flyer-files',
        expect.objectContaining({
          flyerId: 1,
          paths: expect.arrayContaining([
            expect.stringContaining('flyer.pdf'), // original job path
            expect.stringContaining('flyer-1.jpg'), // from prepareImageInputs
            expect.stringContaining('flyer-2.jpg'), // from prepareImageInputs
            expect.stringContaining('icon-flyer-1.webp'), // from generateFlyerIcon
          ]),
        }),
        expect.any(Object),
      );
    });

    it('should throw an error and not enqueue cleanup if the AI service fails', async () => {
      const job = createMockJob({});
      const { logger } = await import('./logger.server');
      const aiError = new Error('AI model exploded');
      mockAiProcessor.extractAndValidateData.mockRejectedValue(aiError);

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

      expect(job.updateProgress).toHaveBeenCalledWith({
        errorCode: 'UNKNOWN_ERROR',
        message: 'AI model exploded',
        stages: [
          {
            name: 'Preparing Inputs',
            status: 'completed',
            critical: true,
            detail: '1 page(s) ready for AI.',
          },
          {
            name: 'Image Optimization',
            status: 'completed',
            critical: true,
            detail: 'Compressing and resizing images...',
          },
          {
            name: 'Extracting Data with AI',
            status: 'failed',
            critical: true,
            detail: 'AI model exploded',
          },
          { name: 'Transforming AI Data', status: 'skipped', critical: true },
          { name: 'Saving to Database', status: 'skipped', critical: true },
        ],
      });
      expect(mockCleanupQueue.add).not.toHaveBeenCalled();
      expect(logger.warn).toHaveBeenCalledWith(
        'Job failed. Temporary files will NOT be cleaned up to allow for manual inspection.',
      );
    });

    it('should throw UnrecoverableError for quota issues and not enqueue cleanup', async () => {
      const job = createMockJob({});
      // Simulate an AI error that contains a keyword for unrecoverable errors
      const quotaError = new Error('AI model quota exceeded');
      const { logger } = await import('./logger.server');
      mockAiProcessor.extractAndValidateData.mockRejectedValue(quotaError);

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

      expect(job.updateProgress).toHaveBeenCalledWith({
        errorCode: 'QUOTA_EXCEEDED',
        message: 'An AI quota has been exceeded. Please try again later.',
        stages: [
          {
            name: 'Preparing Inputs',
            status: 'completed',
            critical: true,
            detail: '1 page(s) ready for AI.',
          },
          {
            name: 'Image Optimization',
            status: 'completed',
            critical: true,
            detail: 'Compressing and resizing images...',
          },
          {
            name: 'Extracting Data with AI',
            status: 'failed',
            critical: true,
            detail: 'AI model quota exceeded',
          },
          { name: 'Transforming AI Data', status: 'skipped', critical: true },
          { name: 'Saving to Database', status: 'skipped', critical: true },
        ],
      });
      expect(mockCleanupQueue.add).not.toHaveBeenCalled();
      expect(logger.warn).toHaveBeenCalledWith(
        'Job failed. Temporary files will NOT be cleaned up to allow for manual inspection.',
      );
    });

    it('should throw PdfConversionError and not enqueue cleanup if PDF conversion fails', async () => {
      const job = createMockJob({ filePath: '/tmp/bad.pdf', originalFileName: 'bad.pdf' });
      const { logger } = await import('./logger.server');
      const conversionError = new PdfConversionError('Conversion failed', 'pdftocairo error');
      mockFileHandler.prepareImageInputs.mockRejectedValue(conversionError);

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

      // Use `toHaveBeenLastCalledWith` to check only the final error payload, ignoring earlier progress updates.
      expect(job.updateProgress).toHaveBeenLastCalledWith({
        errorCode: 'PDF_CONVERSION_FAILED',
        message:
          'The uploaded PDF could not be processed. It might be blank, corrupt, or password-protected.', // This was a duplicate, fixed.
        stderr: 'pdftocairo error',
        stages: [
          {
            name: 'Preparing Inputs',
            status: 'failed',
            critical: true,
            detail:
              'The uploaded PDF could not be processed. It might be blank, corrupt, or password-protected.',
          },
          { name: 'Image Optimization', status: 'skipped', critical: true },
          { name: 'Extracting Data with AI', status: 'skipped', critical: true },
          { name: 'Transforming AI Data', status: 'skipped', critical: true },
          { name: 'Saving to Database', status: 'skipped', critical: true },
        ],
      });
      expect(mockCleanupQueue.add).not.toHaveBeenCalled();
      expect(logger.warn).toHaveBeenCalledWith(
        'Job failed. Temporary files will NOT be cleaned up to allow for manual inspection.',
      );
    });

    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', {}, {});
      mockAiProcessor.extractAndValidateData.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,
          errorCode: 'AI_VALIDATION_FAILED',
          message:
            "The AI couldn't read the flyer's format. Please try a clearer image or a different flyer.",
          validationErrors: {},
          rawData: {},
          stages: expect.any(Array), // Stages will be dynamically generated
        },
        'A known processing error occurred: AiDataValidationError',
      );
      // Use `toHaveBeenLastCalledWith` to check only the final error payload.
      // FIX: The payload from AiDataValidationError includes validationErrors and rawData.
      expect(job.updateProgress).toHaveBeenLastCalledWith({
        errorCode: 'AI_VALIDATION_FAILED',
        message:
          "The AI couldn't read the flyer's format. Please try a clearer image or a different flyer.", // This was a duplicate, fixed.
        validationErrors: {},
        rawData: {},
        stages: [
          {
            name: 'Preparing Inputs',
            status: 'completed',
            critical: true,
            detail: '1 page(s) ready for AI.',
          },
          {
            name: 'Image Optimization',
            status: 'completed',
            critical: true,
            detail: 'Compressing and resizing images...',
          },
          {
            name: 'Extracting Data with AI',
            status: 'failed',
            critical: true,
            detail:
              "The AI couldn't read the flyer's format. Please try a clearer image or a different flyer.",
          },
          { name: 'Transforming AI Data', status: 'skipped', critical: true },
          { name: 'Saving to Database', status: 'skipped', critical: true },
        ],
      });
      expect(mockCleanupQueue.add).not.toHaveBeenCalled();
      expect(logger.warn).toHaveBeenCalledWith(
        'Job failed. Temporary files will NOT be cleaned up to allow for manual inspection.',
      );
    });

    it('should handle convertible image types and include original and converted files in cleanup', async () => {
      const job = createMockJob({ filePath: '/tmp/flyer.gif', originalFileName: 'flyer.gif' });
      const convertedPath = '/tmp/flyer-converted.png';

      // Mock the file handler to return the converted path
      mockFileHandler.prepareImageInputs.mockResolvedValue({
        imagePaths: [{ path: convertedPath, mimetype: 'image/png' }],
        createdImagePaths: [convertedPath],
      });
      vi.mocked(generateFlyerIcon).mockResolvedValue('icon-flyer-converted.webp');

      await service.processJob(job);

      // Verify transaction and inner calls
      expect(mockFileHandler.prepareImageInputs).toHaveBeenCalledWith(
        '/tmp/flyer.gif',
        job,
        expect.any(Object),
      );
      expect(mockAiProcessor.extractAndValidateData).toHaveBeenCalledTimes(1);
      // Verify icon generation was called for the converted image
      expect(generateFlyerIcon).toHaveBeenCalledWith(
        convertedPath,
        path.join('/tmp', 'icons'),
        expect.any(Object),
      );
      expect(mockCleanupQueue.add).toHaveBeenCalledWith(
        'cleanup-flyer-files',
        expect.objectContaining({
          flyerId: 1,
          paths: expect.arrayContaining([
            expect.stringContaining('flyer.gif'), // original job path
            expect.stringContaining('flyer-converted.png'), // from prepareImageInputs
            expect.stringContaining('icon-flyer-converted.webp'), // from generateFlyerIcon
          ]),
        }),
        expect.any(Object),
      );
    });

    it('should throw an error and not enqueue cleanup if the database service fails', async () => {
      const job = createMockJob({});
      const { logger } = await import('./logger.server');
      const dbError = new DatabaseError('Database transaction failed');

      mockPersistenceService.saveFlyer.mockRejectedValue(dbError);

      // The service wraps the generic DB error in a DatabaseError.
      await expect(service.processJob(job)).rejects.toThrow(DatabaseError);

      // The final progress update should reflect the structured DatabaseError.
      expect(job.updateProgress).toHaveBeenLastCalledWith({
        errorCode: 'DATABASE_ERROR',
        message: 'A database operation failed. Please try again later.',
        stages: [
          {
            name: 'Preparing Inputs',
            status: 'completed',
            critical: true,
            detail: '1 page(s) ready for AI.',
          },
          {
            name: 'Image Optimization',
            status: 'completed',
            critical: true,
            detail: 'Compressing and resizing images...',
          },
          {
            name: 'Extracting Data with AI',
            status: 'completed',
            critical: true,
            detail: 'Communicating with AI model...',
          },
          { name: 'Transforming AI Data', status: 'completed', critical: true },
          {
            name: 'Saving to Database',
            status: 'failed',
            critical: true,
            detail: 'A database operation failed. Please try again later.',
          },
        ],
      });
      expect(mockCleanupQueue.add).not.toHaveBeenCalled();
      expect(logger.warn).toHaveBeenCalledWith(
        'Job failed. Temporary files will NOT be cleaned up to allow for manual inspection.',
      );
    });

    it('should throw UnsupportedFileTypeError for an unsupported file type', async () => {
      const job = createMockJob({
        filePath: '/tmp/document.txt',
        originalFileName: 'document.txt',
      });
      const fileTypeError = new UnsupportedFileTypeError(
        'Unsupported file type: .txt. Supported types are PDF, JPG, PNG, WEBP, HEIC, HEIF, GIF, TIFF, SVG, BMP.',
      );
      mockFileHandler.prepareImageInputs.mockRejectedValue(fileTypeError);
      const { logger } = await import('./logger.server');

      const reportErrorSpy = vi.spyOn(service as any, '_reportErrorAndThrow');

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

      expect(reportErrorSpy).toHaveBeenCalledWith(
        fileTypeError,
        job,
        expect.any(Object),
        expect.any(Array),
      );
      expect(mockCleanupQueue.add).not.toHaveBeenCalled();
      expect(logger.warn).toHaveBeenCalledWith(
        'Job failed. Temporary files will NOT be cleaned up to allow for manual inspection.',
      );
    });

    it('should delegate to _reportErrorAndThrow if icon generation fails', async () => {
      const job = createMockJob({});
      const { logger } = await import('./logger.server');
      const iconGenError = new Error('Icon generation failed.');
      vi.mocked(generateFlyerIcon).mockRejectedValue(iconGenError);

      const reportErrorSpy = vi.spyOn(service as any, '_reportErrorAndThrow');

      await expect(service.processJob(job)).rejects.toThrow('Icon generation failed.');

      expect(reportErrorSpy).toHaveBeenCalledWith(
        iconGenError,
        job,
        expect.any(Object),
        expect.any(Array),
      );
      expect(mockCleanupQueue.add).not.toHaveBeenCalled();
      expect(logger.warn).toHaveBeenCalledWith(
        'Job failed. Temporary files will NOT be cleaned up to allow for manual inspection.',
      );
    });
  });

  describe('_reportErrorAndThrow (Error Reporting Logic)', () => {
    it('should update progress with a generic error and re-throw', async () => {
      const { logger } = await import('./logger.server');
      const job = createMockJob({});
      const genericError = new Error('A standard failure');
      const initialStages = [
        { name: 'Stage 1', status: 'completed', critical: true, detail: 'Done' },
        { name: 'Stage 2', status: 'in-progress', critical: true, detail: 'Working...' },
        { name: 'Stage 3', status: 'pending', critical: true, detail: 'Waiting...' },
      ];
      const privateMethod = (service as any)._reportErrorAndThrow;

      await expect(privateMethod(genericError, job, logger, initialStages)).rejects.toThrow(
        genericError,
      );

      expect(job.updateProgress).toHaveBeenCalledWith({
        errorCode: 'UNKNOWN_ERROR',
        message: 'A standard failure',
        stages: [
          { name: 'Stage 1', status: 'completed', critical: true, detail: 'Done' },
          { name: 'Stage 2', status: 'failed', critical: true, detail: 'A standard failure' },
          { name: 'Stage 3', status: 'skipped', critical: true },
        ],
      });
    });

    it('should use toErrorPayload for FlyerProcessingError instances', async () => {
      const { logger } = await import('./logger.server');
      const job = createMockJob({});
      const validationError = new AiDataValidationError(
        'Validation failed',
        { foo: 'bar' },
        { raw: 'data' },
      );
      const initialStages = [
        { name: 'Extracting Data with AI', status: 'in-progress', critical: true, detail: '...' },
      ];
      const privateMethod = (service as any)._reportErrorAndThrow;

      await expect(privateMethod(validationError, job, logger, initialStages)).rejects.toThrow(
        validationError,
      );

      expect(job.updateProgress).toHaveBeenCalledWith({
        errorCode: 'AI_VALIDATION_FAILED',
        message:
          "The AI couldn't read the flyer's format. Please try a clearer image or a different flyer.",
        validationErrors: { foo: 'bar' },
        rawData: { raw: 'data' },
        stages: [
          {
            name: 'Extracting Data with AI',
            status: 'failed',
            critical: true,
            detail:
              "The AI couldn't read the flyer's format. Please try a clearer image or a different flyer.",
          },
        ],
      });
    });

    it('should throw UnrecoverableError for quota messages', async () => {
      const { logger } = await import('./logger.server');
      const job = createMockJob({});
      const quotaError = new Error('RESOURCE_EXHAUSTED');
      const privateMethod = (service as any)._reportErrorAndThrow;

      await expect(privateMethod(quotaError, job, logger, [])).rejects.toThrow(UnrecoverableError);

      expect(job.updateProgress).toHaveBeenCalledWith({
        errorCode: 'QUOTA_EXCEEDED',
        message: 'An AI quota has been exceeded. Please try again later.',
        stages: [],
      });
    });

    it('should wrap and throw non-Error objects', async () => {
      const { logger } = await import('./logger.server');
      const job = createMockJob({});
      const nonError = 'just a string error';
      const privateMethod = (service as any)._reportErrorAndThrow;

      await expect(privateMethod(nonError, job, logger, [])).rejects.toThrow('just a string error');
    });

    it('should correctly identify the failed stage based on error code', async () => {
      const { logger } = await import('./logger.server');
      const job = createMockJob({});
      const pdfError = new PdfConversionError('PDF failed');
      const initialStages = [
        { name: 'Preparing Inputs', status: 'in-progress', critical: true, detail: '...' },
        { name: 'Extracting Data with AI', status: 'pending', critical: true, detail: '...' },
      ];
      const privateMethod = (service as any)._reportErrorAndThrow;

      await expect(privateMethod(pdfError, job, logger, initialStages)).rejects.toThrow(pdfError);

      expect(job.updateProgress).toHaveBeenCalledWith(
        expect.objectContaining({
          stages: [
            {
              name: 'Preparing Inputs',
              status: 'failed',
              critical: true,
              detail: expect.any(String),
            },
            { name: 'Extracting Data with AI', status: 'skipped', critical: true },
          ],
        }),
      );
    });
  });

  describe('processCleanupJob', () => {
    it('should delete all files successfully', async () => {
      const job = createMockCleanupJob({ flyerId: 1, paths: ['/tmp/file1', '/tmp/file2'] });
      mocks.unlink.mockResolvedValue(undefined);

      const result = await service.processCleanupJob(job);

      expect(mocks.unlink).toHaveBeenCalledTimes(2);
      expect(mocks.unlink).toHaveBeenCalledWith('/tmp/file1');
      expect(mocks.unlink).toHaveBeenCalledWith('/tmp/file2');
      expect(result).toEqual({ status: 'success', deletedCount: 2 });
    });

    it('should handle ENOENT errors gracefully and still succeed', async () => {
      const job = createMockCleanupJob({ flyerId: 1, paths: ['/tmp/file1', '/tmp/file2'] });
      const enoentError: NodeJS.ErrnoException = new Error('File not found');
      enoentError.code = 'ENOENT';

      mocks.unlink.mockResolvedValueOnce(undefined).mockRejectedValueOnce(enoentError);

      const result = await service.processCleanupJob(job);

      expect(mocks.unlink).toHaveBeenCalledTimes(2);
      expect(result).toEqual({ status: 'success', deletedCount: 2 });
      // Check that the warning was logged
      const { logger } = await import('./logger.server');
      expect(logger.warn).toHaveBeenCalledWith(
        'File not found during cleanup (already deleted?): /tmp/file2',
      );
    });

    it('should throw an aggregate error if a non-ENOENT error occurs', async () => {
      const job = createMockCleanupJob({
        flyerId: 1,
        paths: ['/tmp/file1', '/tmp/permission-denied'],
      });
      const permissionError: NodeJS.ErrnoException = new Error('Permission denied');
      permissionError.code = 'EACCES';

      mocks.unlink.mockResolvedValueOnce(undefined).mockRejectedValueOnce(permissionError);

      await expect(service.processCleanupJob(job)).rejects.toThrow(
        'Failed to delete 1 file(s): /tmp/permission-denied',
      );

      // Check that the error was logged
      const { logger } = await import('./logger.server');
      expect(logger.error).toHaveBeenCalledWith(
        expect.objectContaining({ err: permissionError, path: '/tmp/permission-denied' }),
        'Failed to delete temporary file.',
      );
    });

    it('should skip processing and return "skipped" if paths array is empty and paths cannot be derived', async () => {
      const job = createMockCleanupJob({ flyerId: 1, paths: [] });
      // Mock that the flyer cannot be found in the DB, so paths cannot be derived.
      vi.mocked(mockedDb.flyerRepo.getFlyerById).mockRejectedValue(new NotFoundError('Not found'));

      const result = await service.processCleanupJob(job);

      expect(mocks.unlink).not.toHaveBeenCalled();
      expect(result).toEqual({ status: 'skipped', reason: 'no paths derived' });
      const { logger } = await import('./logger.server');
      // Check for both warnings: the attempt to derive, and the final skip message.
      expect(logger.warn).toHaveBeenCalledWith(
        'Cleanup job for flyer 1 received no paths. Attempting to derive paths from DB.',
      );
      expect(logger.warn).toHaveBeenCalledWith(
        'Job received no paths and could not derive any from the database. Skipping.',
      );
    });

    it('should derive paths from DB and delete files if job paths are empty', async () => {
      const storagePath = path.join('/var', 'www', 'app', 'flyer-images');
      const expectedImagePath = path.join(storagePath, 'flyer-abc.jpg');
      const expectedIconPath = path.join(storagePath, 'icons', 'icon-flyer-abc.webp');

      const job = createMockCleanupJob({ flyerId: 1, paths: [] }); // Empty paths
      const mockFlyer = createMockFlyer({
        image_url: 'https://example.com/flyer-images/flyer-abc.jpg',
        icon_url: 'https://example.com/flyer-images/icons/icon-flyer-abc.webp',
      });
      // Mock DB call to return a flyer
      vi.mocked(mockedDb.flyerRepo.getFlyerById).mockResolvedValue(mockFlyer);
      mocks.unlink.mockResolvedValue(undefined);

      // Mock process.env.STORAGE_PATH
      vi.stubEnv('STORAGE_PATH', storagePath);

      const result = await service.processCleanupJob(job);

      expect(result).toEqual({ status: 'success', deletedCount: 2 });
      expect(mocks.unlink).toHaveBeenCalledTimes(2);
      expect(mocks.unlink).toHaveBeenCalledWith(expectedImagePath);
      expect(mocks.unlink).toHaveBeenCalledWith(expectedIconPath);
      const { logger } = await import('./logger.server');
      expect(logger.warn).toHaveBeenCalledWith(
        'Cleanup job for flyer 1 received no paths. Attempting to derive paths from DB.',
      );
    });
  });
});
