// src/services/geocodingService.server.test.ts
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { GeocodingService } from './geocodingService.server';
import { GoogleGeocodingService } from './googleGeocodingService.server';
import { NominatimGeocodingService } from './nominatimGeocodingService.server';

// --- Hoisted Mocks ---
const mocks = vi.hoisted(() => ({
  mockRedis: {
    get: vi.fn(),
    set: vi.fn(),
    scan: vi.fn(),
    del: vi.fn(),
  },
}));

// --- Mock Modules ---
vi.mock('./queueService.server', () => ({
  connection: mocks.mockRedis,
}));
vi.mock('./googleGeocodingService.server', async (importOriginal) => {
  const actual = await importOriginal<typeof import('./googleGeocodingService.server')>();
  return { ...actual, GoogleGeocodingService: vi.fn() };
});

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

import { logger } from './logger.server';

describe('Geocoding Service', () => {
  const originalEnv = process.env;
  let geocodingService: GeocodingService;
  let mockGoogleService: GoogleGeocodingService;
  let mockNominatimService: NominatimGeocodingService;

  beforeEach(() => {
    vi.clearAllMocks();
    // Restore process.env to its original state before each test
    process.env = { ...originalEnv };

    // Create a mock instance of the Google service
    mockGoogleService = { geocode: vi.fn() } as unknown as GoogleGeocodingService;
    // Create a mock instance of the dependency and the service under test
    mockNominatimService = { geocode: vi.fn() } as unknown as NominatimGeocodingService;
    geocodingService = new GeocodingService(mockGoogleService, mockNominatimService);
  });

  afterEach(() => {
    // Restore process.env after each test
    process.env = originalEnv;
  });

  describe('geocodeAddress', () => {
    const address = '123 Main St, Anytown';
    const cacheKey = `geocode:${address}`;
    const coordinates = { lat: 45.0, lng: -75.0 };

    it('should return coordinates from Redis cache if available', async () => {
      // Arrange: Mock Redis to return a cached result
      mocks.mockRedis.get.mockResolvedValue(JSON.stringify(coordinates));

      const result = await geocodingService.geocodeAddress(address, logger);

      // Assert
      expect(result).toEqual(coordinates);
      expect(mocks.mockRedis.get).toHaveBeenCalledWith(cacheKey);
      expect(mockGoogleService.geocode).not.toHaveBeenCalled();
      expect(mockNominatimService.geocode).not.toHaveBeenCalled();
    });

    it('should log an error but continue if Redis GET fails', async () => {
      // Arrange: Mock Redis 'get' to fail, but Google API to succeed
      process.env.GOOGLE_MAPS_API_KEY = 'test-key';
      mocks.mockRedis.get.mockRejectedValue(new Error('Redis down'));
      vi.mocked(mockGoogleService.geocode).mockResolvedValue(coordinates);

      // Act
      const result = await geocodingService.geocodeAddress(address, logger);

      // Assert
      expect(result).toEqual(coordinates);
      expect(logger.error).toHaveBeenCalledWith({ err: expect.any(Error), cacheKey: expect.any(String) }, 'Redis GET or JSON.parse command failed. Proceeding without cache.');
      expect(mockGoogleService.geocode).toHaveBeenCalled(); // Should still proceed to fetch
    });

    it('should proceed to fetch if cached data is invalid JSON', async () => {
      // Arrange: Mock Redis to return a malformed JSON string
      process.env.GOOGLE_MAPS_API_KEY = 'test-key';
      mocks.mockRedis.get.mockResolvedValue('{ "lat": 45.0, "lng": -75.0 '); // Missing closing brace
      vi.mocked(mockGoogleService.geocode).mockResolvedValue(coordinates);

      // Act
      const result = await geocodingService.geocodeAddress(address, logger);

      // Assert
      expect(result).toEqual(coordinates);
      expect(mocks.mockRedis.get).toHaveBeenCalledWith(cacheKey);
      // The service should log the JSON parsing error and continue
      expect(logger.error).toHaveBeenCalledWith({ err: expect.any(SyntaxError), cacheKey: expect.any(String) }, 'Redis GET or JSON.parse command failed. Proceeding without cache.');
      expect(mockGoogleService.geocode).toHaveBeenCalledTimes(1);
    });

    it('should fetch from Google, return coordinates, and cache the result on cache miss', async () => {
      // Arrange
      process.env.GOOGLE_MAPS_API_KEY = 'test-key';
      mocks.mockRedis.get.mockResolvedValue(null); // Cache miss
      vi.mocked(mockGoogleService.geocode).mockResolvedValue(coordinates);

      // Act
      const result = await geocodingService.geocodeAddress(address, logger);

      // Assert
      expect(result).toEqual(coordinates);
      expect(mockGoogleService.geocode).toHaveBeenCalledWith(address, logger);
      expect(mocks.mockRedis.set).toHaveBeenCalledWith(cacheKey, JSON.stringify(coordinates), 'EX', expect.any(Number));
      expect(mockNominatimService.geocode).not.toHaveBeenCalled();
    });

    it('should fall back to Nominatim if Google API key is missing', async () => {
      // Arrange
      delete process.env.GOOGLE_MAPS_API_KEY;
      mocks.mockRedis.get.mockResolvedValue(null);
      vi.mocked(mockNominatimService.geocode).mockResolvedValue(coordinates);

      // Act
      const result = await geocodingService.geocodeAddress(address, logger);

      // Assert
      expect(result).toEqual(coordinates);
      expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('GOOGLE_MAPS_API_KEY is not set'));
      expect(mockGoogleService.geocode).not.toHaveBeenCalled();
      expect(mockNominatimService.geocode).toHaveBeenCalledWith(address, logger);
      expect(mocks.mockRedis.set).toHaveBeenCalled();
    });

    it('should fall back to Nominatim if Google API returns a non-OK status', async () => {
      // Arrange
      process.env.GOOGLE_MAPS_API_KEY = 'test-key';
      mocks.mockRedis.get.mockResolvedValue(null);
      vi.mocked(mockGoogleService.geocode).mockResolvedValue(null); // Google returns no results
      vi.mocked(mockNominatimService.geocode).mockResolvedValue(coordinates);

      // Act
      const result = await geocodingService.geocodeAddress(address, logger);

      // Assert
      expect(result).toEqual(coordinates);
      expect(logger.info).toHaveBeenCalledWith({ address: expect.any(String), provider: 'Google' }, expect.stringContaining('Falling back to Nominatim'));
      expect(mockNominatimService.geocode).toHaveBeenCalledWith(address, logger);
    });

    it('should fall back to Nominatim if Google API fetch call fails', async () => {
      // Arrange
      process.env.GOOGLE_MAPS_API_KEY = 'test-key';
      mocks.mockRedis.get.mockResolvedValue(null);
      vi.mocked(mockGoogleService.geocode).mockRejectedValue(new Error('Network Error'));
      vi.mocked(mockNominatimService.geocode).mockResolvedValue(coordinates);

      // Act
      const result = await geocodingService.geocodeAddress(address, logger);

      // Assert
      expect(result).toEqual(coordinates);
      expect(logger.error).toHaveBeenCalledWith({ err: expect.any(Error) }, expect.stringContaining('An error occurred while calling the Google Maps Geocoding API'));
      expect(mockNominatimService.geocode).toHaveBeenCalledWith(address, logger);
    });

    it('should return null and log an error if both Google and Nominatim fail', async () => {
      // Arrange
      process.env.GOOGLE_MAPS_API_KEY = 'test-key';
      mocks.mockRedis.get.mockResolvedValue(null);
      vi.mocked(mockGoogleService.geocode).mockRejectedValue(new Error('Network Error'));
      vi.mocked(mockNominatimService.geocode).mockResolvedValue(null); // Nominatim also fails

      // Act
      const result = await geocodingService.geocodeAddress(address, logger);

      // Assert
      expect(result).toBeNull();
      expect(logger.error).toHaveBeenCalledWith({ address }, 'All geocoding providers failed.');
      expect(mocks.mockRedis.set).not.toHaveBeenCalled(); // Should not cache a null result
    });

    it('should return coordinates even if Redis SET fails', async () => {
      // Arrange
      process.env.GOOGLE_MAPS_API_KEY = 'test-key';
      mocks.mockRedis.get.mockResolvedValue(null); // Cache miss
      vi.mocked(mockGoogleService.geocode).mockResolvedValue(coordinates);
      // Mock Redis 'set' to fail
      mocks.mockRedis.set.mockRejectedValue(new Error('Redis SET failed'));

      // Act
      const result = await geocodingService.geocodeAddress(address, logger);

      // Assert
      expect(result).toEqual(coordinates); // The result should still be returned to the caller
      expect(mockGoogleService.geocode).toHaveBeenCalledTimes(1);
      expect(mocks.mockRedis.set).toHaveBeenCalledTimes(1);
      expect(logger.error).toHaveBeenCalledWith({ err: expect.any(Error), cacheKey: expect.any(String) }, 'Redis SET command failed. Result will not be cached.');
    });
  });

  describe('clearGeocodeCache', () => {
    it('should scan and delete keys in batches', async () => {
      // Arrange: Mock SCAN to run twice
      mocks.mockRedis.scan
        .mockResolvedValueOnce(['10', ['geocode:key1', 'geocode:key2']]) // First batch
        .mockResolvedValueOnce(['0', ['geocode:key3']]); // Second batch, cursor is '0' to end loop

      // Mock DEL to return the number of keys it "deleted"
      mocks.mockRedis.del
        .mockResolvedValueOnce(2)
        .mockResolvedValueOnce(1);

      // Act
      const result = await geocodingService.clearGeocodeCache(logger);

      // Assert
      expect(result).toBe(3); // 2 + 1
      expect(mocks.mockRedis.scan).toHaveBeenCalledTimes(2);
      expect(mocks.mockRedis.del).toHaveBeenCalledTimes(2);
      expect(mocks.mockRedis.del).toHaveBeenCalledWith(['geocode:key1', 'geocode:key2']);
      expect(mocks.mockRedis.del).toHaveBeenCalledWith(['geocode:key3']);
    });

    it('should return 0 if no keys match the pattern', async () => {
      // Arrange: Mock SCAN to find no keys
      mocks.mockRedis.scan.mockResolvedValueOnce(['0', []]);

      // Act
      const result = await geocodingService.clearGeocodeCache(logger);

      // Assert
      expect(result).toBe(0);
      expect(mocks.mockRedis.scan).toHaveBeenCalledTimes(1);
      expect(mocks.mockRedis.del).not.toHaveBeenCalled();
    });
  });
});