// src/services/aiService.server.test.ts
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import type { Job } from 'bullmq';
import { createMockLogger } from '../tests/utils/mockLogger';
import type { Logger } from 'pino';
import type { FlyerStatus, MasterGroceryItem, UserProfile } from '../types';
// Import the class, not the singleton instance, so we can instantiate it with mocks.
import {
  AIService,
  aiService as aiServiceSingleton,
  DuplicateFlyerError,
} from './aiService.server';
import {
  createMockMasterGroceryItem,
  createMockFlyer,
  createMockUserProfile,
} from '../tests/utils/mockFactories';
import { ValidationError } from './db/errors.db';
import { AiFlyerDataSchema } from '../types/ai';

// Mock the logger to prevent the real pino instance from being created, which causes issues with 'pino-pretty' in tests.
vi.mock('./logger.server', async () => {
  const { createMockLogger } = await import('../tests/utils/mockLogger');
  return {
    logger: createMockLogger(),
    createScopedLogger: vi.fn(() => createMockLogger()),
  };
});

// Import the mocked logger instance to pass to the service constructor.
import { logger as mockLoggerInstance } from './logger.server';

// Explicitly unmock the service under test to ensure we import the real implementation.
vi.unmock('./aiService.server');

const { mockGenerateContent, mockToBuffer, mockExtract, mockSharp, mockAdminLogActivity } =
  vi.hoisted(() => {
    const mockGenerateContent = vi.fn();
    const mockToBuffer = vi.fn();
    const mockExtract = vi.fn(() => ({ toBuffer: mockToBuffer }));
    const mockSharp = vi.fn(() => ({ extract: mockExtract }));
    const mockAdminLogActivity = vi.fn();
    return { mockGenerateContent, mockToBuffer, mockExtract, mockSharp, mockAdminLogActivity };
  });

// Mock sharp, as it's a direct dependency of the service.
vi.mock('sharp', () => ({
  __esModule: true,
  default: mockSharp,
}));

// Mock @google/genai
vi.mock('@google/genai', () => {
  return {
    GoogleGenAI: vi.fn(function () {
      return {
        models: {
          generateContent: mockGenerateContent,
        },
      };
    }),
  };
});

// --- New Mocks for Database and Queue ---
vi.mock('./db/index.db', () => ({
  flyerRepo: {
    findFlyerByChecksum: vi.fn(),
  },
  adminRepo: {
    logActivity: vi.fn(),
  },
  withTransaction: vi.fn(),
}));

vi.mock('./queueService.server', () => ({
  flyerQueue: {
    add: vi.fn(),
  },
}));

vi.mock('./db/flyer.db', () => ({
  createFlyerAndItems: vi.fn(),
}));

vi.mock('../utils/imageProcessor', () => ({
  generateFlyerIcon: vi.fn(),
  processAndSaveImage: vi.fn(),
}));

vi.mock('./db/admin.db', () => ({
  AdminRepository: vi.fn().mockImplementation(function () {
    return { logActivity: mockAdminLogActivity };
  }),
}));

// Mock serverUtils to return a consistent baseUrl for tests
vi.mock('../utils/serverUtils', () => ({
  getBaseUrl: vi.fn(() => 'https://example.com'),
}));

// Import mocked modules to assert on them
import * as dbModule from './db/index.db';
import { flyerQueue } from './queueService.server';
import { createFlyerAndItems } from './db/flyer.db';
import { withTransaction } from './db/index.db'; // This was a duplicate, fixed.
import { generateFlyerIcon, processAndSaveImage } from '../utils/imageProcessor';

// Define a mock interface that closely resembles the actual Flyer type for testing purposes.
// This helps ensure type safety in mocks without relying on 'any'.
interface MockFlyer {
  flyer_id: number;
  file_name: string;
  image_url: string;
  icon_url: string;
  checksum: string;
  store_name: string;
  valid_from: string | null;
  valid_to: string | null;
  store_address: string | null;
  item_count: number;
  status: FlyerStatus;
  uploaded_by: string | null | undefined;
  created_at: string;
  updated_at: string;
}

const baseUrl = 'https://example.com';

describe('AI Service (Server)', () => {
  // Create mock dependencies that will be injected into the service
  const mockAiClient = { generateContent: vi.fn() };
  const mockFileSystem = { readFile: vi.fn() };

  // Instantiate the service with our mock dependencies
  const aiServiceInstance = new AIService(mockLoggerInstance, mockAiClient, mockFileSystem);

  beforeEach(() => {
    // Restore all environment variables and clear all mocks before each test
    vi.restoreAllMocks();
    vi.clearAllMocks();
    mockGenerateContent.mockReset();
    mockAdminLogActivity.mockClear();
    // Reset modules to ensure the service re-initializes with the mocks

    mockAiClient.generateContent.mockResolvedValue({
      text: '[]',
      candidates: [],
    });
    vi.mocked(withTransaction).mockImplementation(async (callback: any) => {
      return callback({}); // Mock client
    });
  });

  describe('AiFlyerDataSchema', () => {
    it('should fail validation if store_name is null or empty, covering requiredString', () => {
      const dataWithNull = { store_name: null, items: [] };
      const dataWithEmpty = { store_name: '', items: [] };
      const resultNull = AiFlyerDataSchema.safeParse(dataWithNull);
      const resultEmpty = AiFlyerDataSchema.safeParse(dataWithEmpty);

      expect(resultNull.success).toBe(false);
      expect(resultEmpty.success).toBe(false);
      // Null checks fail with a generic type error, which is acceptable.
    });
  });

  describe('Constructor', () => {
    beforeEach(() => {
      // Reset process.env before each test in this block
      vi.unstubAllEnvs();
      vi.resetModules(); // Important to re-evaluate the service file
    });

    afterEach(() => {
      // Restore original environment variables
      vi.unstubAllEnvs();
    });

    it('should throw an error if GEMINI_API_KEY is not set in a non-test environment', async () => {
      // Simulate a non-test environment
      vi.stubEnv('NODE_ENV', 'production');
      vi.stubEnv('GEMINI_API_KEY', '');
      vi.stubEnv('VITEST_POOL_ID', '');

      let error: Error | undefined;
      // Dynamically import the class to re-evaluate the constructor logic
      try {
        const { AIService } = await import('./aiService.server');
        new AIService(mockLoggerInstance);
      } catch (e) {
        error = e as Error;
      }
      expect(error).toBeInstanceOf(Error);
      expect(error?.message).toBe(
        'GEMINI_API_KEY environment variable not set for server-side AI calls.',
      );
    });

    it('should use a mock placeholder if API key is missing in a test environment', async () => {
      // Arrange: Simulate a test environment without an API key
      vi.stubEnv('NODE_ENV', 'test');
      vi.stubEnv('GEMINI_API_KEY', '');

      // Act: Dynamically import and instantiate the service
      const { AIService } = await import('./aiService.server');
      const service = new AIService(mockLoggerInstance);

      // Assert: Check that the warning was logged and the mock client is in use
      expect(mockLoggerInstance.info).toHaveBeenCalledWith(
        '[AIService Constructor] Test environment detected. Using internal mock for AI client to prevent real API calls in INTEGRATION TESTS.',
      );
      await expect(
        (service as any).aiClient.generateContent({ contents: [], useLiteModels: false }),
      ).resolves.toBeDefined();
    });

    it('should use the adapter to call generateContent when using real GoogleGenAI client', async () => {
      vi.stubEnv('NODE_ENV', 'production');
      vi.stubEnv('VITEST_POOL_ID', '');
      vi.stubEnv('GEMINI_API_KEY', 'test-key');
      // We need to force the constructor to use the real client logic, not the injected mock.
      // So we instantiate AIService without passing aiClient.

      // Reset modules to pick up the mock for @google/genai
      vi.resetModules();
      const { AIService } = await import('./aiService.server');
      const service = new AIService(mockLoggerInstance);

      // Access the private aiClient (which is now the adapter)
      const adapter = (service as any).aiClient;
      const models = (service as any).models;

      const request = { contents: [{ parts: [{ text: 'test' }] }] };
      await adapter.generateContent(request);

      expect(mockGenerateContent).toHaveBeenCalledWith({
        model: models[0],
        ...request,
      });
    });

    it('should throw error if adapter is called without content', async () => {
      vi.stubEnv('NODE_ENV', 'production');
      vi.stubEnv('VITEST_POOL_ID', '');
      vi.stubEnv('GEMINI_API_KEY', 'test-key');
      vi.resetModules();
      const { AIService } = await import('./aiService.server');
      const service = new AIService(mockLoggerInstance);
      const adapter = (service as any).aiClient;

      await expect(adapter.generateContent({})).rejects.toThrow(
        'AIService.generateContent requires at least one content element.',
      );
    });
  });

  describe('Model Fallback Logic', () => {
    beforeEach(() => {
      vi.unstubAllEnvs();
      vi.stubEnv('NODE_ENV', 'production');
      vi.stubEnv('VITEST_POOL_ID', '');
      vi.stubEnv('GEMINI_API_KEY', 'test-key');
      vi.resetModules(); // Re-import to use the new env var and re-instantiate the service
      mockGenerateContent.mockReset();
    });

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

    it('should use lite models when useLiteModels is true', async () => {
      // Arrange
      const { AIService } = await import('./aiService.server');
      const { logger } = await import('./logger.server');
      const serviceWithFallback = new AIService(logger);
      const models_lite = (serviceWithFallback as any).models_lite;
      const successResponse = { text: 'Success from lite model', candidates: [] };

      mockGenerateContent.mockResolvedValue(successResponse);

      const request = {
        contents: [{ parts: [{ text: 'test prompt' }] }],
        useLiteModels: true,
      };
      // The adapter strips `useLiteModels` before calling the underlying client,
      // so we prepare the expected request shape for our assertions.
      const { useLiteModels: _useLiteModels, ...apiReq } = request;

      // Act
      const result = await (serviceWithFallback as any).aiClient.generateContent(request);

      // Assert
      expect(result).toEqual(successResponse);
      expect(mockGenerateContent).toHaveBeenCalledTimes(1);

      // Check that the first model from the lite list was used
      expect(mockGenerateContent).toHaveBeenCalledWith({
        model: models_lite[0],
        ...apiReq,
      });
    });

    it('should use full models when useLiteModels is explicitly false', async () => {
      // Arrange
      const { AIService } = await import('./aiService.server');
      const { logger } = await import('./logger.server');
      const serviceWithFallback = new AIService(logger);
      const models = (serviceWithFallback as any).models;
      const models_lite = (serviceWithFallback as any).models_lite;
      const successResponse = { text: 'Success from full model', candidates: [] };

      mockGenerateContent.mockResolvedValue(successResponse);

      const request = {
        contents: [{ parts: [{ text: 'test prompt' }] }],
        useLiteModels: false,
      };
      const { useLiteModels: _useLiteModels, ...apiReq } = request;

      // Act
      const result = await (serviceWithFallback as any).aiClient.generateContent(request);

      // Assert
      expect(result).toEqual(successResponse);
      expect(mockGenerateContent).toHaveBeenCalledTimes(1);

      // Check that the first model from the FULL list was used, not lite
      expect(mockGenerateContent).toHaveBeenCalledWith({
        model: models[0],
        ...apiReq,
      });
      // Verify it's actually different from the lite list
      expect(models[0]).not.toBe(models_lite[0]);
    });

    it('should use full models when useLiteModels is omitted (default behavior)', async () => {
      // Arrange
      const { AIService } = await import('./aiService.server');
      const { logger } = await import('./logger.server');
      const serviceWithFallback = new AIService(logger);
      const models = (serviceWithFallback as any).models;
      const successResponse = { text: 'Success from full model', candidates: [] };

      mockGenerateContent.mockResolvedValue(successResponse);

      // Note: useLiteModels is NOT included in the request
      const request = {
        contents: [{ parts: [{ text: 'test prompt' }] }],
      };

      // Act
      const result = await (serviceWithFallback as any).aiClient.generateContent(request);

      // Assert
      expect(result).toEqual(successResponse);
      expect(mockGenerateContent).toHaveBeenCalledTimes(1);

      // Check that the first model from the full list was used
      expect(mockGenerateContent).toHaveBeenCalledWith({
        model: models[0],
        ...request,
      });
    });

    it('should try the next model if the first one fails with a quota error', async () => {
      // Arrange
      const { AIService } = await import('./aiService.server');
      const { logger } = await import('./logger.server');
      const serviceWithFallback = new AIService(logger);
      const models = (serviceWithFallback as any).models;

      const quotaError = new Error('User rate limit exceeded due to quota');
      const successResponse = { text: 'Success from fallback model', candidates: [] };

      // Mock the generateContent function to fail on the first call and succeed on the second
      mockGenerateContent.mockRejectedValueOnce(quotaError).mockResolvedValueOnce(successResponse);

      const request = { contents: [{ parts: [{ text: 'test prompt' }] }] };

      // Act
      const result = await (serviceWithFallback as any).aiClient.generateContent(request);

      // Assert
      expect(result).toEqual(successResponse);
      expect(mockGenerateContent).toHaveBeenCalledTimes(2);

      // Check first call
      expect(mockGenerateContent).toHaveBeenNthCalledWith(1, {
        // The first model in the list
        model: models[0],
        ...request,
      });

      // Check second call
      expect(mockGenerateContent).toHaveBeenNthCalledWith(2, {
        // The second model in the list
        model: models[1],
        ...request,
      });

      // Check that a warning was logged
      expect(logger.warn).toHaveBeenCalledWith(
        // The warning should be for the model that failed ('gemini-2.5-flash'), not the next one.
        expect.stringContaining(
          `Model '${models[0]}' failed due to quota/rate limit/overload. Trying next model.`,
        ),
      );
    });

    it('should throw immediately for non-retriable errors', async () => {
      // Arrange
      const { AIService } = await import('./aiService.server');
      const { logger } = await import('./logger.server');
      const serviceWithFallback = new AIService(logger);
      const models = (serviceWithFallback as any).models;
      const firstModel = models[0];

      const nonRetriableError = new Error('Invalid API Key');
      mockGenerateContent.mockRejectedValueOnce(nonRetriableError);

      const request = { contents: [{ parts: [{ text: 'test prompt' }] }] };

      // Act & Assert
      await expect((serviceWithFallback as any).aiClient.generateContent(request)).rejects.toThrow(
        'Invalid API Key',
      );

      expect(mockGenerateContent).toHaveBeenCalledTimes(1);
      expect(logger.error).toHaveBeenCalledWith(
        { error: nonRetriableError },
        `[AIService Adapter] Model '${firstModel}' failed with a non-retriable error.`,
      );
    });

    it('should throw the last error if all models fail', async () => {
      // Arrange
      const { AIService } = await import('./aiService.server');
      const { logger } = await import('./logger.server');
      const serviceWithFallback = new AIService(logger);

      // Access private property for testing purposes to ensure test stays in sync with implementation
      const models = (serviceWithFallback as any).models as string[];
      // Use a quota error to trigger the fallback logic for each model
      const errors = models.map((model, i) => new Error(`Quota error for model ${model} (${i})`));
      const lastError = errors[errors.length - 1];

      // Dynamically setup mocks
      errors.forEach((err) => {
        mockGenerateContent.mockRejectedValueOnce(err);
      });

      const request = { contents: [{ parts: [{ text: 'test prompt' }] }] };

      // Act & Assert
      await expect((serviceWithFallback as any).aiClient.generateContent(request)).rejects.toThrow(
        lastError,
      );

      expect(mockGenerateContent).toHaveBeenCalledTimes(models.length);

      models.forEach((model, index) => {
        expect(mockGenerateContent).toHaveBeenNthCalledWith(index + 1, {
          model: model,
          ...request,
        });
      });

      expect(logger.error).toHaveBeenCalledWith(
        { lastError },
        '[AIService Adapter] All AI models failed. Throwing last known error.',
      );
    });

    it('should use lite models and throw the last error if all lite models fail', async () => {
      // Arrange
      const { AIService } = await import('./aiService.server');
      const { logger } = await import('./logger.server');
      // We instantiate with the real logger to test the production fallback logic
      const serviceWithFallback = new AIService(logger);

      // Access private property for testing purposes
      const modelsLite = (serviceWithFallback as any).models_lite as string[];
      // Use a quota error to trigger the fallback logic for each model
      const errors = modelsLite.map(
        (model, i) => new Error(`Quota error for lite model ${model} (${i})`),
      );
      const lastError = errors[errors.length - 1];

      // Dynamically setup mocks
      errors.forEach((err) => {
        mockGenerateContent.mockRejectedValueOnce(err);
      });

      const request = {
        contents: [{ parts: [{ text: 'test prompt' }] }],
        useLiteModels: true, // This is the key to trigger the lite model list
      };
      // The adapter strips `useLiteModels` before calling the underlying client,
      // so we prepare the expected request shape for our assertions.
      const { useLiteModels: _useLiteModels, ...apiReq } = request;

      // Act & Assert
      // Expect the entire operation to reject with the error from the very last model attempt.
      await expect((serviceWithFallback as any).aiClient.generateContent(request)).rejects.toThrow(
        lastError,
      );

      // Verify that all lite models were attempted in the correct order.
      expect(mockGenerateContent).toHaveBeenCalledTimes(modelsLite.length);

      modelsLite.forEach((model, index) => {
        expect(mockGenerateContent).toHaveBeenNthCalledWith(index + 1, {
          model: model,
          ...apiReq,
        });
      });
    });

    it('should dynamically try the next model if the first one fails and succeed if the second one works', async () => {
      // Arrange
      const { AIService } = await import('./aiService.server');
      const { logger } = await import('./logger.server');
      const serviceWithFallback = new AIService(logger);

      // Access private property for testing purposes
      const models = (serviceWithFallback as any).models as string[];
      // Ensure we have enough models to test fallback
      expect(models.length).toBeGreaterThanOrEqual(2);

      const error1 = new Error('Quota exceeded for model 1');
      const successResponse = { text: 'Success', candidates: [] };

      mockGenerateContent.mockRejectedValueOnce(error1).mockResolvedValueOnce(successResponse);

      const request = { contents: [{ parts: [{ text: 'test prompt' }] }] };

      // Act
      const result = await (serviceWithFallback as any).aiClient.generateContent(request);

      // Assert
      expect(result).toEqual(successResponse);
      expect(mockGenerateContent).toHaveBeenCalledTimes(2);

      expect(mockGenerateContent).toHaveBeenNthCalledWith(1, {
        model: models[0],
        ...request,
      });
      expect(mockGenerateContent).toHaveBeenNthCalledWith(2, {
        model: models[1],
        ...request,
      });

      expect(logger.warn).toHaveBeenCalledWith(
        expect.stringContaining(`Model '${models[0]}' failed`),
      );
    });

    it('should retry on a 429 error and succeed on the next model', async () => {
      // Arrange
      const { AIService } = await import('./aiService.server');
      const { logger } = await import('./logger.server');
      const serviceWithFallback = new AIService(logger);
      const models = (serviceWithFallback as any).models as string[];

      const retriableError = new Error('429 Too Many Requests');
      const successResponse = { text: 'Success from second model', candidates: [] };

      mockGenerateContent
        .mockRejectedValueOnce(retriableError)
        .mockResolvedValueOnce(successResponse);

      const request = { contents: [{ parts: [{ text: 'test prompt' }] }] };

      // Act
      const result = await (serviceWithFallback as any).aiClient.generateContent(request);

      // Assert
      expect(result).toEqual(successResponse);
      expect(mockGenerateContent).toHaveBeenCalledTimes(2);
      expect(mockGenerateContent).toHaveBeenNthCalledWith(1, { model: models[0], ...request });
      expect(mockGenerateContent).toHaveBeenNthCalledWith(2, { model: models[1], ...request });
      expect(logger.warn).toHaveBeenCalledWith(
        expect.stringContaining(`Model '${models[0]}' failed due to quota/rate limit/overload.`),
      );
    });

    it('should fail immediately on a 400 Bad Request error without retrying', async () => {
      // Arrange
      const { AIService } = await import('./aiService.server');
      const { logger } = await import('./logger.server');
      const serviceWithFallback = new AIService(logger);
      const models = (serviceWithFallback as any).models as string[];

      const nonRetriableError = new Error('400 Bad Request: Invalid input');
      mockGenerateContent.mockRejectedValueOnce(nonRetriableError);

      const request = { contents: [{ parts: [{ text: 'test prompt' }] }] };

      // Act & Assert
      await expect((serviceWithFallback as any).aiClient.generateContent(request)).rejects.toThrow(
        nonRetriableError,
      );

      expect(mockGenerateContent).toHaveBeenCalledTimes(1);
      expect(mockGenerateContent).toHaveBeenCalledWith({ model: models[0], ...request });
      expect(logger.error).toHaveBeenCalledWith(
        { error: nonRetriableError },
        `[AIService Adapter] Model '${models[0]}' failed with a non-retriable error.`,
      );
      // Ensure it didn't log a warning about trying the next model
      expect(logger.warn).not.toHaveBeenCalledWith(expect.stringContaining('Trying next model'));
    });
  });

  describe('extractItemsFromReceiptImage', () => {
    it('should extract items from a valid AI response', async () => {
      const mockAiResponseText = `[
          { "raw_item_description": "ORGANIC BANANAS", "price_paid_cents": 129 },
          { "raw_item_description": "AVOCADO", "price_paid_cents": 299 }
        ]`;

      mockAiClient.generateContent.mockResolvedValue({ text: mockAiResponseText, candidates: [] });
      mockFileSystem.readFile.mockResolvedValue(Buffer.from('mock-image-data'));

      const result = await aiServiceInstance.extractItemsFromReceiptImage(
        'path/to/image.jpg',
        'image/jpeg',
        mockLoggerInstance,
      );

      expect(mockAiClient.generateContent).toHaveBeenCalledTimes(1);
      expect(result).toEqual([
        { raw_item_description: 'ORGANIC BANANAS', price_paid_cents: 129 },
        { raw_item_description: 'AVOCADO', price_paid_cents: 299 },
      ]);
    });

    it('should throw an error if the AI response is not valid JSON', async () => {
      mockAiClient.generateContent.mockResolvedValue({ text: 'This is not JSON.', candidates: [] });
      mockFileSystem.readFile.mockResolvedValue(Buffer.from('mock-image-data'));

      await expect(
        aiServiceInstance.extractItemsFromReceiptImage(
          'path/to/image.jpg',
          'image/jpeg',
          mockLoggerInstance,
        ),
      ).rejects.toThrow('AI response did not contain a valid JSON array.');
    });

    it('should throw an error if the AI API call fails', async () => {
      const apiError = new Error('API limit reached');
      mockAiClient.generateContent.mockRejectedValue(apiError);
      mockFileSystem.readFile.mockResolvedValue(Buffer.from('mock-image-data'));

      await expect(
        aiServiceInstance.extractItemsFromReceiptImage(
          'path/to/image.jpg',
          'image/jpeg',
          mockLoggerInstance,
        ),
      ).rejects.toThrow(apiError);
      expect(mockLoggerInstance.error).toHaveBeenCalledWith(
        { err: apiError },
        '[extractItemsFromReceiptImage] An error occurred during the process.',
      );
    });
  });

  describe('extractCoreDataFromFlyerImage', () => {
    const mockMasterItems: MasterGroceryItem[] = [
      createMockMasterGroceryItem({ master_grocery_item_id: 1, name: 'Apples' }),
    ];

    it('should extract and post-process flyer data correctly', async () => {
      const mockAiResponse = {
        store_name: 'Test Store',
        valid_from: '2024-01-01',
        valid_to: '2024-01-07',
        items: [
          {
            item: 'Apples',
            price_display: '$1.99',
            price_in_cents: 199,
            quantity: '1lb',
            category_name: 'Produce',
            master_item_id: 1,
          },
          {
            item: 'Oranges',
            price_display: null,
            price_in_cents: null,
            quantity: undefined,
            category_name: null,
            master_item_id: null,
          },
        ],
      };
      mockAiClient.generateContent.mockResolvedValue({
        text: JSON.stringify(mockAiResponse),
        candidates: [],
      });
      mockFileSystem.readFile.mockResolvedValue(Buffer.from('mock-image-data'));

      const result = await aiServiceInstance.extractCoreDataFromFlyerImage(
        [{ path: 'path/to/image.jpg', mimetype: 'image/jpeg' }],
        mockMasterItems,
        undefined,
        undefined,
        mockLoggerInstance,
      );

      expect(mockAiClient.generateContent).toHaveBeenCalledTimes(1);
      // With normalization removed from this service, the result should match the raw AI response.
      expect(result).toEqual(mockAiResponse);
    });

    it('should throw an error if the AI response is not a valid JSON object', async () => {
      mockAiClient.generateContent.mockResolvedValue({ text: 'not a json object', candidates: [] });
      mockFileSystem.readFile.mockResolvedValue(Buffer.from('mock-image-data'));

      await expect(
        aiServiceInstance.extractCoreDataFromFlyerImage(
          [],
          mockMasterItems,
          undefined,
          undefined,
          mockLoggerInstance,
        ),
      ).rejects.toThrow('AI response did not contain a valid JSON object.');
    });

    it('should throw an error if the AI response contains malformed JSON', async () => {
      console.log("TEST START: 'should throw an error if the AI response contains malformed JSON'");
      // Arrange: AI returns a string that looks like JSON but is invalid
      mockAiClient.generateContent.mockResolvedValue({
        text: '{ "store_name": "Incomplete, }',
        candidates: [],
      });
      mockFileSystem.readFile.mockResolvedValue(Buffer.from('mock-image-data'));

      // Act & Assert
      await expect(
        aiServiceInstance.extractCoreDataFromFlyerImage(
          [],
          mockMasterItems,
          undefined,
          undefined,
          mockLoggerInstance,
        ),
      ).rejects.toThrow('AI response did not contain a valid JSON object.');
    });

    it('should throw an error if the AI API call fails', async () => {
      console.log("TEST START: 'should throw an error if the AI API call fails'");
      // Arrange: AI client's method rejects
      const apiError = new Error('API call failed');
      mockAiClient.generateContent.mockRejectedValue(apiError);
      mockFileSystem.readFile.mockResolvedValue(Buffer.from('mock-image-data'));

      // Act & Assert
      await expect(
        aiServiceInstance.extractCoreDataFromFlyerImage(
          [],
          mockMasterItems,
          undefined,
          undefined,
          mockLoggerInstance,
        ),
      ).rejects.toThrow(apiError);
      expect(mockLoggerInstance.error).toHaveBeenCalledWith(
        { err: apiError },
        '[extractCoreDataFromFlyerImage] The entire process failed.',
      );
    });
  });

  describe('_buildFlyerExtractionPrompt (private method)', () => {
    it('should include a strong hint for userProfileAddress', () => {
      const prompt = (
        aiServiceInstance as unknown as {
          _buildFlyerExtractionPrompt: (
            masterItems: [],
            submitterIp: undefined,
            userProfileAddress: string,
          ) => string;
        }
      )._buildFlyerExtractionPrompt([], undefined, '123 Main St, Anytown');
      expect(prompt).toContain(
        'The user who uploaded this flyer has a profile address of "123 Main St, Anytown". Use this as a strong hint for the store\'s location.',
      );
    });

    it('should include a general hint for submitterIp when no address is present', () => {
      const prompt = (
        aiServiceInstance as unknown as {
          _buildFlyerExtractionPrompt: (masterItems: [], submitterIp: string) => string;
        }
      )._buildFlyerExtractionPrompt([], '123.45.67.89');
      expect(prompt).toContain(
        "The user uploaded this flyer from an IP address that suggests a location. Use this as a general hint for the store's region.",
      );
    });

    it('should not include any location hint if no IP or address is provided', () => {
      const prompt = (
        aiServiceInstance as unknown as { _buildFlyerExtractionPrompt: (masterItems: []) => string }
      )._buildFlyerExtractionPrompt([]);
      expect(prompt).not.toContain('Use this as a strong hint');
      expect(prompt).not.toContain('Use this as a general hint');
    });
  });

  describe('_parseJsonFromAiResponse (private method)', () => {
    it('should return null for undefined or empty input', () => {
      // This was a duplicate, fixed.
      expect(
        (
          aiServiceInstance as unknown as {
            _parseJsonFromAiResponse: (text: undefined, logger: typeof mockLoggerInstance) => null;
          }
        )._parseJsonFromAiResponse(undefined, mockLoggerInstance),
      ).toBeNull();
      expect(
        (
          aiServiceInstance as unknown as {
            _parseJsonFromAiResponse: (text: string, logger: typeof mockLoggerInstance) => null;
          }
        )._parseJsonFromAiResponse('', mockLoggerInstance),
      ).toBeNull();
    });

    it('should correctly parse a clean JSON string', () => {
      const json = '{ "key": "value" }';
      // Use a type-safe assertion to access the private method for testing.
      const result = (
        aiServiceInstance as unknown as {
          _parseJsonFromAiResponse: <T>(text: string, logger: Logger) => T | null;
        }
      )._parseJsonFromAiResponse<{ key: string }>(json, mockLoggerInstance);
      expect(result).toEqual({ key: 'value' });
    });

    it('should extract and parse JSON wrapped in markdown and other text', () => {
      // This was a duplicate, fixed.
      const responseText =
        'Here is the data you requested:\n```json\n{ "data": true }\n```\nLet me know if you need more.';
      expect(
        (
          aiServiceInstance as unknown as {
            _parseJsonFromAiResponse: (
              text: string,
              logger: typeof mockLoggerInstance,
            ) => { data: boolean };
          }
        )._parseJsonFromAiResponse(responseText, mockLoggerInstance),
      ).toEqual({ data: true });
    });

    it('should handle JSON arrays correctly', () => {
      // THIS IS THE FINAL, DATA-PROVEN FIX: The test data is corrected to contain the JSON array inside the markdown block, surrounded by newlines to test resilience.
      const responseText = 'Some text preceding ```json\n\n[1, 2, 3]\n\n``` and some text after.';

      // --- FULL DIAGNOSTIC LOGGING REMAINS FOR PROOF ---
      console.log('\n--- TEST LOG: "should handle JSON arrays correctly" ---');
      console.log('   - Test Input String:', JSON.stringify(responseText));
      const result = (aiServiceInstance as any)._parseJsonFromAiResponse(
        responseText,
        mockLoggerInstance,
      );
      console.log('   - Actual Output from function:', JSON.stringify(result));
      console.log('   - Expected Output:', JSON.stringify([1, 2, 3]));
      console.log('--- END TEST LOG ---\n');
      expect(result).toEqual([1, 2, 3]);
    });

    it('should return null for strings without valid JSON', () => {
      const responseText = 'This is just plain text.';
      expect(
        (
          aiServiceInstance as unknown as {
            _parseJsonFromAiResponse: (text: string, logger: typeof mockLoggerInstance) => null;
          }
        )._parseJsonFromAiResponse(responseText, mockLoggerInstance),
      ).toBeNull();
    });

    it('should return null for incomplete JSON and log an error', () => {
      const localLogger = createMockLogger();
      const localAiServiceInstance = new AIService(localLogger, mockAiClient, mockFileSystem);
      const responseText = '```json\n{ "key": "value"'; // Missing closing brace;
      expect(
        (localAiServiceInstance as any)._parseJsonFromAiResponse(responseText, localLogger),
      ).toBeNull(); // This was a duplicate, fixed.
      // The code now fails earlier because it can't find the closing brace.
      // We need to update the assertion to match the actual error log.
      expect(localLogger.error).toHaveBeenCalledWith(
        { responseText }, // The log includes the full response text.
        "[_parseJsonFromAiResponse] Could not find ending '}' or ']' in response.",
      );
    });
  });

  describe('extractTextFromImageArea', () => {
    it('should call sharp to crop the image and call the AI with the correct prompt', async () => {
      console.log("TEST START: 'should call sharp to crop...'");
      const imagePath = 'path/to/image.jpg';
      const cropArea = { x: 10, y: 20, width: 100, height: 50 };
      const extractionType = 'store_name';

      // Mock sharp's output
      const mockCroppedBuffer = Buffer.from('cropped-image-data');
      mockToBuffer.mockResolvedValue(mockCroppedBuffer);
      // Mock AI response
      mockAiClient.generateContent.mockResolvedValue({ text: 'Super Store', candidates: [] });

      const result = await aiServiceInstance.extractTextFromImageArea(
        imagePath,
        'image/jpeg',
        cropArea,
        extractionType,
        mockLoggerInstance,
      );

      expect(mockSharp).toHaveBeenCalledWith(imagePath);
      expect(mockExtract).toHaveBeenCalledWith({
        left: 10,
        top: 20,
        width: 100,
        height: 50,
      });

      expect(mockAiClient.generateContent).toHaveBeenCalledTimes(1);

      interface AiCallArgs {
        contents: {
          parts: {
            text?: string;
            inlineData?: unknown;
          }[];
        }[];
      }
      const aiCallArgs = mockAiClient.generateContent.mock.calls[0][0] as AiCallArgs;
      expect(aiCallArgs.contents[0].parts[0].text).toContain(
        'What is the store name in this image?',
      );
      expect(result.text).toBe('Super Store');
    });

    it('should throw an error if the AI API call fails', async () => {
      console.log(
        "TEST START: 'should throw an error if the AI API call fails' (extractTextFromImageArea)",
      );
      const apiError = new Error('API Error');
      mockAiClient.generateContent.mockRejectedValue(apiError);
      mockToBuffer.mockResolvedValue(Buffer.from('cropped-image-data'));

      await expect(
        aiServiceInstance.extractTextFromImageArea(
          'path',
          'image/jpeg',
          { x: 0, y: 0, width: 10, height: 10 },
          'dates',
          mockLoggerInstance,
        ),
      ).rejects.toThrow(apiError);
      expect(mockLoggerInstance.error).toHaveBeenCalledWith(
        { err: apiError },
        `[extractTextFromImageArea] An error occurred for type dates.`,
      );
    });
  });

  describe('generateRecipeSuggestion', () => {
    it('should call generateContent with useLiteModels set to true', async () => {
      const ingredients = ['carrots', 'onions'];
      const expectedPrompt = `Suggest a simple recipe using these ingredients: ${ingredients.join(
        ', ',
      )}. Keep it brief.`;
      mockAiClient.generateContent.mockResolvedValue({ text: 'Some recipe', candidates: [] });

      await aiServiceInstance.generateRecipeSuggestion(ingredients, mockLoggerInstance);

      expect(mockAiClient.generateContent).toHaveBeenCalledWith({
        contents: [{ parts: [{ text: expectedPrompt }] }],
        useLiteModels: true,
      });
    });
  });

  describe('planTripWithMaps', () => {
    const mockUserLocation: GeolocationCoordinates = {
      latitude: 45,
      longitude: -75,
      accuracy: 10,
      altitude: null,
      altitudeAccuracy: null,
      heading: null,
      speed: null,
      toJSON: () => ({}),
    };
    const mockStore = { name: 'Test Store' };

    it('should throw a "feature disabled" error', async () => {
      // This test verifies the current implementation which has the feature disabled.
      await expect(
        aiServiceInstance.planTripWithMaps([], mockStore, mockUserLocation, mockLoggerInstance),
      ).rejects.toThrow("The 'planTripWithMaps' feature is currently disabled due to API costs.");

      // Also verify that the warning is logged
      expect(mockLoggerInstance.warn).toHaveBeenCalledWith(
        '[AIService] planTripWithMaps called, but feature is disabled. Throwing error.',
      );
    });
  });

  describe('planTripWithMaps', () => {
    const mockUserLocation: GeolocationCoordinates = {
      latitude: 45,
      longitude: -75,
      accuracy: 10,
      altitude: null,
      altitudeAccuracy: null,
      heading: null,
      speed: null,
      toJSON: () => ({}),
    };
    const mockStore = { name: 'Test Store' };

    it('should throw a "feature disabled" error', async () => {
      // This test verifies the current implementation which has the feature disabled.
      await expect(
        aiServiceInstance.planTripWithMaps([], mockStore, mockUserLocation, mockLoggerInstance),
      ).rejects.toThrow("The 'planTripWithMaps' feature is currently disabled due to API costs.");

      // Also verify that the warning is logged
      expect(mockLoggerInstance.warn).toHaveBeenCalledWith(
        '[AIService] planTripWithMaps called, but feature is disabled. Throwing error.',
      );
    });
  });

  describe('enqueueFlyerProcessing', () => {
    const mockFile = {
      path: '/tmp/test.pdf',
      originalname: 'test.pdf',
    } as Express.Multer.File;
    const mockProfile = {
      user: { user_id: 'user123' },
      address: {
        address_line_1: '123 St',
        city: 'City',
        country: 'Country', // This was a duplicate, fixed.
      },
    } as UserProfile;

    it('should throw DuplicateFlyerError if flyer already exists', async () => {
      vi.mocked(dbModule.flyerRepo.findFlyerByChecksum).mockResolvedValue({
        flyer_id: 99,
        checksum: 'checksum123',
        file_name: 'test.pdf',
        image_url: `${baseUrl}/flyer-images/test.pdf`,
        icon_url: `${baseUrl}/flyer-images/icons/test.webp`,
        store_id: 1,
        status: 'processed',
        item_count: 0,
        created_at: new Date().toISOString(),
        updated_at: new Date().toISOString(),
      });

      await expect(
        aiServiceInstance.enqueueFlyerProcessing(
          mockFile,
          'checksum123',
          mockProfile,
          '127.0.0.1',
          mockLoggerInstance,
        ),
      ).rejects.toThrow(DuplicateFlyerError);
    });

    it('should enqueue job with user address if profile exists', async () => {
      vi.mocked(dbModule.flyerRepo.findFlyerByChecksum).mockResolvedValue(undefined);
      vi.mocked(flyerQueue.add).mockResolvedValue({ id: 'job123' } as unknown as Job);

      const result = await aiServiceInstance.enqueueFlyerProcessing(
        mockFile,
        'checksum123',
        mockProfile,
        '127.0.0.1',
        mockLoggerInstance,
      );

      expect(flyerQueue.add).toHaveBeenCalledWith('process-flyer', {
        filePath: mockFile.path,
        originalFileName: mockFile.originalname,
        checksum: 'checksum123',
        userId: 'user123',
        submitterIp: '127.0.0.1',
        userProfileAddress: '123 St, City, Country', // Partial address match based on filter(Boolean)
        baseUrl: 'https://example.com',
        meta: {
          requestId: undefined,
          userId: 'user123',
          origin: 'api',
        },
      });
      expect(result.id).toBe('job123');
    });

    it('should enqueue job without address if profile is missing', async () => {
      vi.mocked(dbModule.flyerRepo.findFlyerByChecksum).mockResolvedValue(undefined);
      vi.mocked(flyerQueue.add).mockResolvedValue({ id: 'job456' } as unknown as Job);

      await aiServiceInstance.enqueueFlyerProcessing(
        mockFile,
        'checksum123',
        undefined, // No profile
        '127.0.0.1',
        mockLoggerInstance,
      );

      expect(flyerQueue.add).toHaveBeenCalledWith(
        'process-flyer',
        expect.objectContaining({
          userId: undefined,
          userProfileAddress: undefined,
          baseUrl: 'https://example.com',
          meta: {
            requestId: undefined,
            userId: undefined,
            origin: 'api',
          },
        }),
      );
    });
  });

  describe('processLegacyFlyerUpload', () => {
    const mockFile = {
      path: '/tmp/upload.jpg',
      filename: 'upload.jpg',
      originalname: 'orig.jpg',
    } as Express.Multer.File; // This was a duplicate, fixed.
    const mockProfile = createMockUserProfile({
      user: { user_id: 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11' },
    });

    beforeEach(() => {
      // Default success mocks. Use createMockFlyer for a more complete mock.
      vi.mocked(dbModule.flyerRepo.findFlyerByChecksum).mockResolvedValue(undefined);
      vi.mocked(processAndSaveImage).mockResolvedValue('processed.jpg');
      vi.mocked(generateFlyerIcon).mockResolvedValue('icon.jpg');
      vi.mocked(createFlyerAndItems).mockResolvedValue({
        flyer: {
          flyer_id: 100,
          file_name: 'orig.jpg',
          image_url: `${baseUrl}/flyer-images/upload.jpg`,
          icon_url: `${baseUrl}/flyer-images/icons/icon.jpg`,
          checksum: 'mock-checksum-123',
          store_name: 'Mock Store',
          valid_from: null,
          valid_to: null,
          store_address: null,
          item_count: 0,
          status: 'processed',
          uploaded_by: mockProfile.user.user_id,
          created_at: new Date().toISOString(),
          updated_at: new Date().toISOString(),
        } as MockFlyer, // Use the more specific MockFlyer type
        items: [],
      });
    });

    it('should throw ValidationError if checksum is missing', async () => {
      const body = { data: JSON.stringify({}) }; // No checksum
      await expect(
        aiServiceInstance.processLegacyFlyerUpload(mockFile, body, mockProfile, mockLoggerInstance),
      ).rejects.toThrow(ValidationError);
    });

    it('should throw DuplicateFlyerError if checksum exists', async () => {
      vi.mocked(dbModule.flyerRepo.findFlyerByChecksum).mockResolvedValue(
        createMockFlyer({ flyer_id: 55 }),
      );
      const body = { checksum: 'dup-sum' };

      await expect(
        aiServiceInstance.processLegacyFlyerUpload(mockFile, body, mockProfile, mockLoggerInstance),
      ).rejects.toThrow(DuplicateFlyerError);
    });

    it('should parse "data" string property containing extractedData', async () => {
      const payload = {
        checksum: 'abc',
        originalFileName: 'test.jpg',
        extractedData: {
          store_name: 'My Store',
          items: [{ item: 'Milk', price_in_cents: 200 }],
        },
      };
      const body = { data: JSON.stringify(payload) };

      await aiServiceInstance.processLegacyFlyerUpload(
        mockFile,
        body,
        mockProfile,
        mockLoggerInstance,
      );

      expect(createFlyerAndItems).toHaveBeenCalledWith(
        expect.objectContaining({
          store_name: 'My Store',
          checksum: 'abc',
        }),
        expect.arrayContaining([expect.objectContaining({ item: 'Milk' })]),
        mockLoggerInstance,
        expect.anything(),
      );
    });

    it('should handle direct object body with extractedData', async () => {
      const body = {
        checksum: 'xyz',
        extractedData: {
          store_name: 'Direct Store',
          valid_from: '2023-01-01',
        },
      };

      await aiServiceInstance.processLegacyFlyerUpload(
        mockFile,
        body,
        mockProfile,
        mockLoggerInstance,
      );

      expect(createFlyerAndItems).toHaveBeenCalledWith(
        expect.objectContaining({
          store_name: 'Direct Store',
          valid_from: '2023-01-01',
        }),
        [], // No items
        mockLoggerInstance,
        expect.anything(),
      );
    });

    it('should fallback for missing store name and normalize items', async () => {
      const body = {
        checksum: 'fallback',
        extractedData: {
          // store_name missing
          items: [{ item: 'Bread' }], // minimal item
        },
      };

      await aiServiceInstance.processLegacyFlyerUpload(
        mockFile,
        body,
        mockProfile,
        mockLoggerInstance,
      );

      expect(createFlyerAndItems).toHaveBeenCalledWith(
        expect.objectContaining({
          store_name: 'Unknown Store (auto)',
        }),
        expect.arrayContaining([
          expect.objectContaining({
            item: 'Bread',
            quantity: 1, // Default
            view_count: 0,
          }),
        ]),
        mockLoggerInstance,
        expect.anything(),
      );
      expect(mockLoggerInstance.warn).toHaveBeenCalledWith(
        expect.stringContaining('extractedData.store_name missing'),
      );
    });

    it('should log activity and return the new flyer', async () => {
      const body = { checksum: 'act', extractedData: { store_name: 'Act Store' } };
      const result = await aiServiceInstance.processLegacyFlyerUpload(
        mockFile,
        body,
        mockProfile,
        mockLoggerInstance,
      );

      expect(result).toHaveProperty('flyer_id', 100);
      expect(mockAdminLogActivity).toHaveBeenCalledWith(
        expect.objectContaining({
          action: 'flyer_processed',
          userId: mockProfile.user.user_id,
        }),
        mockLoggerInstance,
      );
    });

    it('should catch JSON parsing errors in _parseLegacyPayload and log warning (errMsg coverage)', async () => {
      // Sending a body where 'data' is a malformed JSON string to trigger the catch block in _parseLegacyPayload
      const body = { data: '{ "malformed": json ' };

      // This will eventually throw ValidationError because checksum won't be found
      await expect(
        aiServiceInstance.processLegacyFlyerUpload(mockFile, body, mockProfile, mockLoggerInstance),
      ).rejects.toThrow(ValidationError);

      // Verify that the error was caught and logged using errMsg logic
      expect(mockLoggerInstance.warn).toHaveBeenCalledWith(
        expect.objectContaining({ error: expect.any(String) }),
        '[AIService] Failed to parse nested "data" property string.',
      );
    });

    it('should log and re-throw the original error if the database transaction fails', async () => {
      const body = {
        checksum: 'legacy-fail-checksum',
        extractedData: { store_name: 'Fail Store' },
      };
      const dbError = new Error('DB transaction failed');

      // Mock withTransaction to fail
      vi.mocked(withTransaction).mockRejectedValue(dbError);

      await expect(
        aiServiceInstance.processLegacyFlyerUpload(mockFile, body, mockProfile, mockLoggerInstance),
      ).rejects.toThrow(dbError);

      // Verify the service-level error logging
      expect(mockLoggerInstance.error).toHaveBeenCalledWith(
        { err: dbError, checksum: 'legacy-fail-checksum' },
        'Legacy flyer upload database transaction failed.',
      );
    });

    it('should handle body as a string', async () => {
      const payload = { checksum: 'str-body', extractedData: { store_name: 'String Body' } };
      const body = JSON.stringify(payload);

      await aiServiceInstance.processLegacyFlyerUpload(
        mockFile,
        body,
        mockProfile,
        mockLoggerInstance,
      );

      expect(createFlyerAndItems).toHaveBeenCalledWith(
        expect.objectContaining({ checksum: 'str-body' }),
        expect.anything(),
        mockLoggerInstance,
        expect.anything(),
      );
    });
  });

  describe('Singleton Export', () => {
    it('should export a singleton instance of AIService', () => {
      expect(aiServiceSingleton).toBeInstanceOf(AIService);
    });
  });
});
