// weatherService (Session 15) — Open-Meteo proxy with cache + timeout.

const mockAxiosGet = jest.fn();
jest.mock('axios', () => ({ get: (...args) => mockAxiosGet(...args) }));

const mockCache = new Map();
jest.mock('../../src/utils/redis', () => ({
  cacheGet: async (k) => (mockCache.has(k) ? mockCache.get(k) : null),
  cacheSet: async (k, v) => { mockCache.set(k, v); return true; },
  cacheDel: async (k) => { mockCache.delete(k); return true; },
  isDegraded: () => false,
}));

const ws = require('../../src/services/weatherService');

beforeEach(() => {
  mockAxiosGet.mockReset();
  mockCache.clear();
});

describe('weatherService.getWeather', () => {
  test('invalid coordinates return null without touching the network', async () => {
    expect(await ws.getWeather(null, null)).toBeNull();
    expect(await ws.getWeather(NaN, 0)).toBeNull();
    expect(await ws.getWeather(undefined, undefined)).toBeNull();
    expect(mockAxiosGet).not.toHaveBeenCalled();
  });

  test('happy path — projects Open-Meteo current block to our flat shape', async () => {
    mockAxiosGet.mockResolvedValueOnce({
      data: {
        current: {
          temperature_2m: 78.5,
          wind_speed_10m: 12.3,
          wind_direction_10m: 165,
          precipitation: 0,
        },
      },
    });
    const w = await ws.getWeather(39.7559, -104.9942);
    expect(w).toMatchObject({
      temp_f: 78.5,
      wind_mph: 12.3,
      wind_dir: 165,
      precip_mm: 0,
    });
    expect(typeof w._fetched_at).toBe('string');
  });

  test('Fahrenheit + mph units requested explicitly', async () => {
    mockAxiosGet.mockResolvedValueOnce({ data: { current: {} } });
    await ws.getWeather(40, -75);
    const [, opts] = mockAxiosGet.mock.calls[0];
    expect(opts.params.temperature_unit).toBe('fahrenheit');
    expect(opts.params.wind_speed_unit).toBe('mph');
  });

  test('second call within the same hour hits cache', async () => {
    mockAxiosGet.mockResolvedValueOnce({
      data: { current: { temperature_2m: 80, wind_speed_10m: 5, wind_direction_10m: 0, precipitation: 0 } },
    });
    await ws.getWeather(39.7559, -104.9942);
    await ws.getWeather(39.7559, -104.9942);
    expect(mockAxiosGet).toHaveBeenCalledTimes(1);
  });

  test('coordinate-precision collapse — venues within ~1km share cache', async () => {
    mockAxiosGet.mockResolvedValueOnce({
      data: { current: { temperature_2m: 70, wind_speed_10m: 5, wind_direction_10m: 0, precipitation: 0 } },
    });
    // Two lat/lon pairs that round to the same 2-decimal cache key.
    await ws.getWeather(40.815, -74.075);  // round to 40.82 / -74.07
    await ws.getWeather(40.823, -74.078);  // round to 40.82 / -74.08 — different lon key, different fetch
    // 40.82/-74.07 vs 40.82/-74.08 → different keys, two fetches expected
    expect(mockAxiosGet).toHaveBeenCalledTimes(2);
  });

  test('upstream throw → returns null (graceful)', async () => {
    mockAxiosGet.mockRejectedValueOnce(new Error('timeout'));
    const w = await ws.getWeather(40, -75);
    expect(w).toBeNull();
  });

  test('upstream returns response without `current` block → null', async () => {
    mockAxiosGet.mockResolvedValueOnce({ data: {} });
    const w = await ws.getWeather(40, -75);
    expect(w).toBeNull();
  });

  test('individual missing fields default to null without crashing', async () => {
    mockAxiosGet.mockResolvedValueOnce({
      data: {
        current: { temperature_2m: 60 /* wind/precip missing */ },
      },
    });
    const w = await ws.getWeather(40, -75);
    expect(w.temp_f).toBe(60);
    expect(w.wind_mph).toBeNull();
    expect(w.wind_dir).toBeNull();
    expect(w.precip_mm).toBeNull();
  });

  test('5-second timeout configured', async () => {
    mockAxiosGet.mockResolvedValueOnce({ data: { current: {} } });
    await ws.getWeather(40, -75);
    const [, opts] = mockAxiosGet.mock.calls[0];
    expect(opts.timeout).toBe(ws.__internals.HTTP_TIMEOUT_MS);
    expect(opts.timeout).toBeLessThanOrEqual(5000);
  });
});

describe('venueCoordinates', () => {
  const venues = require('../../src/data/venueCoordinates');

  test('all 30 MLB venues defined with finite lat/lon', () => {
    const codes = Object.keys(venues.MLB_VENUES);
    expect(codes.length).toBe(30);
    for (const [code, v] of Object.entries(venues.MLB_VENUES)) {
      expect(Number.isFinite(v.lat)).toBe(true);
      expect(Number.isFinite(v.lon)).toBe(true);
      expect(typeof v.dome).toBe('boolean');
      expect(typeof v.name).toBe('string');
      // Sanity: every MLB venue is in roughly the right hemisphere.
      expect(v.lat).toBeGreaterThan(20);
      expect(v.lat).toBeLessThan(50);
      expect(v.lon).toBeLessThan(-65);
      expect(v.lon).toBeGreaterThan(-125);
      expect(code).toMatch(/^[A-Z]{2,3}$/);
    }
  });

  test('all 16 WC 2026 venues defined', () => {
    expect(Object.keys(venues.WC_VENUES).length).toBe(16);
  });

  test('dome stadiums correctly flagged', () => {
    // Tampa Bay's Tropicana is the canonical fixed-roof dome in the AL.
    expect(venues.MLB_VENUES.TB.dome).toBe(true);
    // Coors is open-air.
    expect(venues.MLB_VENUES.COL.dome).toBe(false);
    // Toronto's Rogers Centre is retractable — treated as dome.
    expect(venues.MLB_VENUES.TOR.dome).toBe(true);
  });

  test('getMlbVenue lookup', () => {
    expect(venues.getMlbVenue('NYY').name).toBe('Yankee Stadium');
    expect(venues.getMlbVenue('xyz')).toBeNull();
    expect(venues.getMlbVenue(null)).toBeNull();
  });

  test('getWcVenueCoords lookup', () => {
    expect(venues.getWcVenueCoords('MetLife Stadium').dome).toBe(false);
    expect(venues.getWcVenueCoords('Estadio Azteca').lat).toBeCloseTo(19.30, 1);
    expect(venues.getWcVenueCoords('Bogus')).toBeNull();
  });
});
