// src/services/aiApiClient.test.ts
import { describe, it, expect, vi, beforeAll, afterAll, afterEach } from 'vitest';
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';

// Ensure the module under test is NOT mocked.
vi.unmock('./aiApiClient');

import {
  createMockFlyerItem,
  createMockStore,
  createMockMasterGroceryItem,
} from '../tests/utils/mockFactories';
import * as aiApiClient from './aiApiClient';

// 1. Mock logger to keep output clean
vi.mock('./logger.client', () => ({
  logger: {
    debug: vi.fn(),
    info: vi.fn(),
    error: vi.fn(),
    warn: vi.fn(),
  },
}));

// 2. Mock ./apiClient to simply pass calls through to the global fetch.
vi.mock('./apiClient', async () => {
  // This is the core logic we want to preserve: it calls the global fetch
  // which is then intercepted by MSW.
  const apiFetch = (
    url: string,
    options: RequestInit = {},
    apiOptions: import('./apiClient').ApiOptions = {},
  ) => {
    const fullUrl = url.startsWith('/') ? `http://localhost/api${url}` : url;
    options.headers = new Headers(options.headers); // Ensure headers is a Headers object

    if (apiOptions.tokenOverride) {
      options.headers.set('Authorization', `Bearer ${apiOptions.tokenOverride}`);
    }

    // ================================= WORKAROUND FOR JSDOM FILE NAME BUG =================================
    // JSDOM's fetch implementation (undici) loses filenames in FormData.
    // SOLUTION: Before fetch is called, we find the file, extract its real name,
    // and add it to a custom header. The MSW handler will read this header.
    if (options.body instanceof FormData) {
      console.log(`[apiFetch MOCK] FormData detected. Searching for file to preserve its name.`);
      for (const value of (options.body as FormData).values()) {
        if (value instanceof File) {
          console.log(
            `[apiFetch MOCK] Found file: '${value.name}'. Setting 'X-Test-Filename' header.`,
          );
          options.headers.set('X-Test-Filename', value.name);
          // We only expect one file per request in these tests, so we can break.
          break;
        }
      }
    }
    // ======================================= END WORKAROUND ===============================================

    const request = new Request(fullUrl, options);
    console.log(`[apiFetch MOCK] Executing fetch for URL: ${request.url}.`);
    return fetch(request);
  };

  return {
    // The original mock only had apiFetch. We need to add the helpers.
    apiFetch,

    // These helpers are what aiApiClient.ts actually calls.
    // Their mock implementation should just call our mocked apiFetch.
    authedGet: (endpoint: string, options: import('./apiClient').ApiOptions = {}) => {
      return apiFetch(endpoint, { method: 'GET' }, options);
    },
    authedPost: <T>(endpoint: string, body: T, options: import('./apiClient').ApiOptions = {}) => {
      return apiFetch(
        endpoint,
        {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify(body),
        },
        options,
      );
    },
    authedPostForm: (
      endpoint: string,
      formData: FormData,
      options: import('./apiClient').ApiOptions = {},
    ) => {
      return apiFetch(endpoint, { method: 'POST', body: formData }, options);
    },
    // Add a mock for ApiOptions to satisfy the compiler
    ApiOptions: vi.fn(),
  };
});
// 3. Setup MSW to capture requests
const requestSpy = vi.fn();

const server = setupServer(
  // Handler for all POST requests to the AI endpoints
  http.post('http://localhost/api/ai/:endpoint', async ({ request, params }) => {
    let body: Record<string, unknown> | FormData = {};
    let bodyForSpy: Record<string, unknown> = {};
    const contentType = request.headers.get('Content-Type');
    console.log(`\n--- [MSW HANDLER] Intercepted POST to '${String(params.endpoint)}' ---`);

    if (contentType?.includes('application/json')) {
      const parsedBody = await request.json();
      if (typeof parsedBody === 'object' && parsedBody !== null && !Array.isArray(parsedBody)) {
        body = parsedBody as Record<string, unknown>;
        bodyForSpy = body; // For JSON, the body is already a plain object.
      }
    } else if (contentType?.includes('multipart/form-data')) {
      body = await request.formData();
      // WORKAROUND PART 2: Read the filename from our custom header.
      const preservedFilename = request.headers.get('X-Test-Filename');
      console.log(`[MSW HANDLER] Reading 'X-Test-Filename' header. Value: '${preservedFilename}'`);

      for (const [key, value] of (body as FormData).entries()) {
        const isFile =
          typeof value === 'object' &&
          value !== null &&
          'name' in value &&
          'size' in value &&
          'type' in value;
        if (isFile) {
          const file = value as File;
          const finalName = preservedFilename || file.name;
          console.log(
            `[MSW HANDLER DEBUG] Found file-like object for key '${key}'. Original name: '${file.name}'. Using preserved name: '${finalName}'`,
          );
          if (!bodyForSpy[key]) {
            bodyForSpy[key] = { name: finalName, size: file.size, type: file.type };
          }
        } else {
          bodyForSpy[key] = value;
        }
      }
      console.log('[MSW HANDLER] Finished processing FormData. Final object for spy:', bodyForSpy);
    }

    requestSpy({
      endpoint: params.endpoint,
      method: request.method,
      body: bodyForSpy, // Pass the stable, plain object to the spy.
      headers: request.headers,
    });

    return HttpResponse.json({ success: true });
  }),

  // Handler for GET requests, specifically for job status
  http.get('http://localhost/api/ai/jobs/:jobId/status', ({ params }) => {
    requestSpy({
      endpoint: 'jobs',
      method: 'GET',
      jobId: params.jobId,
    });
    return HttpResponse.json({ state: 'completed' });
  }),
);

describe('AI API Client (Network Mocking with MSW)', () => {
  beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));

  afterEach(() => {
    server.resetHandlers();
    requestSpy.mockClear();
    vi.clearAllMocks();
  });

  afterAll(() => server.close());

  describe('uploadAndProcessFlyer', () => {
    it('should construct FormData with file and checksum and send a POST request', async () => {
      const mockFile = new File(['this is a test pdf'], 'flyer.pdf', { type: 'application/pdf' });
      const checksum = 'checksum-abc-123';
      console.log(`\n--- [TEST START] uploadAndProcessFlyer ---`);
      console.log('[TEST ARRANGE] Created mock file:', {
        name: mockFile.name,
        size: mockFile.size,
        type: mockFile.type,
      });

      await aiApiClient.uploadAndProcessFlyer(mockFile, checksum);

      expect(requestSpy).toHaveBeenCalledTimes(1);
      const req = requestSpy.mock.calls[0][0];
      console.log('[TEST ASSERT] Request object received by spy:', JSON.stringify(req, null, 2));

      expect(req.endpoint).toBe('upload-and-process');
      expect(req.method).toBe('POST');

      // DEBUG: Log the body received by the spy. It should now be a plain object.
      console.log('[TEST DEBUG] uploadAndProcessFlyer - Body received by spy:', req.body);

      // FIX: Assert against the properties of the plain object from the spy.
      const flyerFile = req.body.flyerFile as { name: string };
      const checksumValue = req.body.checksum;

      // Add assertions to ensure the objects exist before checking their properties.
      expect(flyerFile).toBeDefined();
      expect(checksumValue).toBeDefined();

      expect(flyerFile.name).toBe('flyer.pdf');
      expect(checksumValue).toBe(checksum);
    });
  });

  describe('uploadAndProcessFlyer error handling', () => {
    it('should throw a structured error with JSON body on non-ok response', async () => {
      const mockFile = new File(['content'], 'flyer.pdf', { type: 'application/pdf' });
      const checksum = 'checksum-abc-123';
      const errorBody = { message: 'Checksum already exists', flyerId: 99 };

      server.use(
        http.post('http://localhost/api/ai/upload-and-process', () => {
          return HttpResponse.json(errorBody, { status: 409 });
        }),
      );

      // The function now throws a structured object, not an Error instance.
      await expect(aiApiClient.uploadAndProcessFlyer(mockFile, checksum)).rejects.toEqual({
        status: 409,
        body: errorBody,
      });
    });

    it('should throw a structured error with text body on non-ok, non-JSON response', async () => {
      const mockFile = new File(['content'], 'flyer.pdf', { type: 'application/pdf' });
      const checksum = 'checksum-abc-123';
      const errorText = 'Internal Server Error';

      server.use(
        http.post('http://localhost/api/ai/upload-and-process', () => {
          return HttpResponse.text(errorText, { status: 500 });
        }),
      );

      // The function now throws a structured object, not an Error instance.
      // The catch block in the implementation wraps the text in a message property.
      await expect(aiApiClient.uploadAndProcessFlyer(mockFile, checksum)).rejects.toEqual({
        status: 500,
        body: { message: errorText },
      });
    });
  });

  describe('getJobStatus', () => {
    it('should send a GET request to the correct job status URL', async () => {
      const jobId = 'job-id-456';
      await aiApiClient.getJobStatus(jobId);

      expect(requestSpy).toHaveBeenCalledTimes(1);
      const req = requestSpy.mock.calls[0][0];

      expect(req.endpoint).toBe('jobs');
      expect(req.method).toBe('GET');
      expect(req.jobId).toBe(jobId);
    });
  });

  describe('getJobStatus error handling', () => {
    const jobId = 'job-id-789';

    it('should throw a JobFailedError if job state is "failed"', async () => {
      const failedStatus: aiApiClient.JobStatus = {
        id: jobId,
        state: 'failed',
        progress: { message: 'AI model exploded', errorCode: 'AI_ERROR' },
        returnValue: null,
        failedReason: 'Raw error from BullMQ',
      };

      server.use(
        http.get(`http://localhost/api/ai/jobs/${jobId}/status`, () => {
          return HttpResponse.json(failedStatus);
        }),
      );

      await expect(aiApiClient.getJobStatus(jobId)).rejects.toThrow(
        new aiApiClient.JobFailedError('AI model exploded', 'AI_ERROR'),
      );
    });

    it('should use failedReason for JobFailedError if progress message is missing', async () => {
      const failedStatus: aiApiClient.JobStatus = {
        id: jobId,
        state: 'failed',
        progress: null, // No progress object
        returnValue: null,
        failedReason: 'Raw error from BullMQ',
      };

      server.use(
        http.get(`http://localhost/api/ai/jobs/${jobId}/status`, () => {
          return HttpResponse.json(failedStatus);
        }),
      );

      await expect(aiApiClient.getJobStatus(jobId)).rejects.toThrow(
        new aiApiClient.JobFailedError('Raw error from BullMQ', 'UNKNOWN_ERROR'),
      );
    });

    it('should throw a generic error if the API response is not ok', async () => {
      const errorBody = { message: 'Job not found' };
      server.use(
        http.get(`http://localhost/api/ai/jobs/${jobId}/status`, () => {
          return HttpResponse.json(errorBody, { status: 404 });
        }),
      );

      await expect(aiApiClient.getJobStatus(jobId)).rejects.toThrow('Job not found');
    });

    it('should throw a specific error if a 200 OK response is not valid JSON', async () => {
      server.use(
        http.get(`http://localhost/api/ai/jobs/${jobId}/status`, () => {
          // A 200 OK response that is not JSON is a server-side contract violation.
          return HttpResponse.text('This should have been JSON', { status: 200 });
        }),
      );
      await expect(aiApiClient.getJobStatus(jobId)).rejects.toThrow(
        'Failed to parse job status from a successful API response.',
      );
    });

    it('should throw a generic error with status text if the non-ok API response is not valid JSON', async () => {
      server.use(
        http.get(`http://localhost/api/ai/jobs/${jobId}/status`, () => {
          return HttpResponse.text('Gateway Timeout', {
            status: 504,
            statusText: 'Gateway Timeout',
          });
        }),
      );
      await expect(aiApiClient.getJobStatus(jobId)).rejects.toThrow('Gateway Timeout');
    });
  });

  describe('isImageAFlyer', () => {
    it('should construct FormData and send a POST request', async () => {
      const mockFile = new File(['dummy image content'], 'flyer.jpg', { type: 'image/jpeg' });
      console.log(`\n--- [TEST START] isImageAFlyer ---`);
      await aiApiClient.isImageAFlyer(mockFile, 'test-token');

      expect(requestSpy).toHaveBeenCalledTimes(1);
      const req = requestSpy.mock.calls[0][0];
      console.log('[TEST ASSERT] Request object received by spy:', JSON.stringify(req, null, 2));

      expect(req.endpoint).toBe('check-flyer');
      expect(req.method).toBe('POST');

      // FIX: Assert against the plain object from the spy.
      const imageFile = req.body.image as { name: string };
      expect(imageFile).toBeDefined();
      expect(imageFile.name).toBe('flyer.jpg');
      expect(req.headers.get('Authorization')).toBe('Bearer test-token');
    });
  });

  describe('extractAddressFromImage', () => {
    it('should construct FormData and send a POST request', async () => {
      const mockFile = new File(['dummy image content'], 'flyer.jpg', { type: 'image/jpeg' });
      console.log(`\n--- [TEST START] extractAddressFromImage ---`);
      await aiApiClient.extractAddressFromImage(mockFile, 'test-token');

      expect(requestSpy).toHaveBeenCalledTimes(1);
      const req = requestSpy.mock.calls[0][0];
      console.log('[TEST ASSERT] Request object received by spy:', JSON.stringify(req, null, 2));

      expect(req.endpoint).toBe('extract-address');

      // FIX: Assert against the plain object from the spy.
      const imageFile = req.body.image as { name: string };
      expect(imageFile).toBeDefined();
      expect(imageFile.name).toBe('flyer.jpg');
      expect(req.headers.get('Authorization')).toBe('Bearer test-token');
    });
  });

  describe('extractLogoFromImage', () => {
    it('should construct FormData and send a POST request', async () => {
      const mockFile = new File(['dummy image content'], 'logo.jpg', { type: 'image/jpeg' });
      console.log(`\n--- [TEST START] extractLogoFromImage ---`);
      await aiApiClient.extractLogoFromImage([mockFile], 'test-token');

      expect(requestSpy).toHaveBeenCalledTimes(1);
      const req = requestSpy.mock.calls[0][0];
      console.log('[TEST ASSERT] Request object received by spy:', JSON.stringify(req, null, 2));

      expect(req.endpoint).toBe('extract-logo');

      // FIX: Assert against the plain object from the spy.
      const imageFile = req.body.images as { name: string };
      expect(imageFile).toBeDefined();
      expect(imageFile.name).toBe('logo.jpg');
      expect(req.headers.get('Authorization')).toBe('Bearer test-token');
    });
  });

  describe('getQuickInsights', () => {
    it('should send items as JSON in the body', async () => {
      const items = [createMockFlyerItem({ item: 'apple' })];
      await aiApiClient.getQuickInsights(items, undefined, 'test-token');

      expect(requestSpy).toHaveBeenCalledTimes(1);
      const req = requestSpy.mock.calls[0][0];

      expect(req.endpoint).toBe('quick-insights');
      expect(req.body).toEqual({ items });
      expect(req.headers.get('Authorization')).toBe('Bearer test-token');
    });
  });

  describe('getDeepDiveAnalysis', () => {
    it('should send items as JSON in the body', async () => {
      const items = [createMockFlyerItem({ item: 'apple' })];
      await aiApiClient.getDeepDiveAnalysis(items, undefined, 'test-token');

      expect(requestSpy).toHaveBeenCalledTimes(1);
      const req = requestSpy.mock.calls[0][0];

      expect(req.endpoint).toBe('deep-dive');
      expect(req.body).toEqual({ items });
      expect(req.headers.get('Authorization')).toBe('Bearer test-token');
    });
  });

  describe('searchWeb', () => {
    it('should send query as JSON in the body', async () => {
      const query = 'search me';
      await aiApiClient.searchWeb(query, undefined, 'test-token');

      expect(requestSpy).toHaveBeenCalledTimes(1);
      const req = requestSpy.mock.calls[0][0];

      expect(req.endpoint).toBe('search-web');
      expect(req.body).toEqual({ query });
      expect(req.headers.get('Authorization')).toBe('Bearer test-token');
    });
  });

  describe('generateImageFromText', () => {
    it('should send prompt as JSON in the body', async () => {
      const prompt = 'A tasty burger';
      await aiApiClient.generateImageFromText(prompt, undefined, 'test-token');

      expect(requestSpy).toHaveBeenCalledTimes(1);
      const req = requestSpy.mock.calls[0][0];

      expect(req.endpoint).toBe('generate-image');
      expect(req.body).toEqual({ prompt });
      expect(req.headers.get('Authorization')).toBe('Bearer test-token');
    });
  });

  describe('generateSpeechFromText', () => {
    it('should send text as JSON in the body', async () => {
      const text = 'Hello world';
      await aiApiClient.generateSpeechFromText(text, undefined, 'test-token');

      expect(requestSpy).toHaveBeenCalledTimes(1);
      const req = requestSpy.mock.calls[0][0];

      expect(req.endpoint).toBe('generate-speech');
      expect(req.body).toEqual({ text });
      expect(req.headers.get('Authorization')).toBe('Bearer test-token');
    });
  });

  describe('planTripWithMaps', () => {
    it('should send items, store, and location as JSON in the body', async () => {
      // Create a full FlyerItem object, as the function signature requires it, not a partial.
      const items = [
        createMockFlyerItem({
          flyer_item_id: 1,
          flyer_id: 1,
          item: 'bread',
          price_display: '$1.99',
          price_in_cents: 199,
          quantity: '1 loaf',
          category_name: 'Bakery', // Factory allows overrides
          view_count: 0,
          click_count: 0,
        }),
      ];

      const store = createMockStore({ store_id: 1, name: 'Test Store' });
      // FIX: The mock GeolocationCoordinates object must correctly serialize to JSON,
      // mimicking the behavior of the real browser API when passed to JSON.stringify.
      // The previous toJSON method returned an empty object, causing the failure.
      const userLocation: GeolocationCoordinates = {
        latitude: 45,
        longitude: -75,
        accuracy: 0,
        altitude: null,
        altitudeAccuracy: null,
        heading: null,
        speed: null,
        toJSON: function () {
          // This function ensures that when JSON.stringify is called on this object,
          // it produces a plain object with all the expected properties.
          return {
            latitude: this.latitude,
            longitude: this.longitude,
            accuracy: this.accuracy,
            altitude: this.altitude,
            altitudeAccuracy: this.altitudeAccuracy,
            heading: this.heading,
            speed: this.speed,
          };
        },
      };

      await aiApiClient.planTripWithMaps(items, store, userLocation);

      expect(requestSpy).toHaveBeenCalledTimes(1);
      const req = requestSpy.mock.calls[0][0];

      console.log('[TEST DEBUG] planTripWithMaps - Body received by spy:', req.body);

      expect(req.endpoint).toBe('plan-trip');
      // FIX: The assertion must compare the received body against the *serialized* form of the userLocation.
      expect(req.body).toEqual({ items, store, userLocation: userLocation.toJSON() });
    });
  });

  describe('rescanImageArea', () => {
    it('should construct FormData with image, cropArea, and extractionType', async () => {
      const mockFile = new File(['dummy image content'], 'flyer-page.jpg', { type: 'image/jpeg' });
      const cropArea = { x: 10, y: 20, width: 100, height: 50 };
      const extractionType = 'item_details' as const;
      console.log(`\n--- [TEST START] rescanImageArea ---`);

      await aiApiClient.rescanImageArea(mockFile, cropArea, extractionType);

      expect(requestSpy).toHaveBeenCalledTimes(1);
      const req = requestSpy.mock.calls[0][0];
      console.log('[TEST ASSERT] Request object received by spy:', JSON.stringify(req, null, 2));

      expect(req.endpoint).toBe('rescan-area');

      // FIX: Assert against the plain object from the spy.
      const imageFile = req.body.image as { name: string };
      const cropAreaValue = req.body.cropArea;
      const extractionTypeValue = req.body.extractionType;

      expect(imageFile).toBeDefined();
      expect(imageFile.name).toBe('flyer-page.jpg');
      expect(cropAreaValue).toBe(JSON.stringify(cropArea));
      expect(extractionTypeValue).toBe(extractionType);
    });
  });

  describe('startVoiceSession', () => {
    it('should throw an error as it is not implemented', () => {
      const mockCallbacks = {
        onmessage: vi.fn(),
        onopen: vi.fn(),
        onclose: vi.fn(),
      };
      expect(() => aiApiClient.startVoiceSession(mockCallbacks)).toThrow(
        'Voice session feature is not fully implemented and requires a backend WebSocket proxy.',
      );
    });
  });

  describe('compareWatchedItemPrices', () => {
    it('should send items as JSON in the body', async () => {
      const items = [createMockMasterGroceryItem({ name: 'apple' })];
      await aiApiClient.compareWatchedItemPrices(items);

      expect(requestSpy).toHaveBeenCalledTimes(1);
      const req = requestSpy.mock.calls[0][0];

      expect(req.endpoint).toBe('compare-prices');
      expect(req.body).toEqual({ items });
    });
  });
});
