process.env.SPORT = 'nba';
process.env.VYNDR_INTERNAL_KEY = 'unit-test-internal-key';
process.env.VYNDR_API_URL = 'http://localhost:3001';
process.env.BUFFER_MS = '5';  // tests can't afford the real 30s

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

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

const mockBatchCapture = jest.fn();
jest.mock('../../src/services/adapters/oddsPapiAdapter', () => ({
  batchCapture: (...args) => mockBatchCapture(...args),
  configured: () => true,
}));

// The poller-internal limiters carry state between tests and the modest
// 2-tokens-per-minute ESPN budget exhausts within three test cases. Replace
// with a no-op so tests run in parallel without blocking on refill.
jest.mock('../../src/utils/rateLimiter', () => ({
  createLimiter: () => ({ waitForToken: async () => true, snapshot: () => ({}) }),
  createCircuitBreaker: () => ({ call: async (fn) => fn(), snapshot: () => ({}) }),
  API_BUDGETS: {
    sharpApi: { tokensPerInterval: 100, interval: 60_000 },
    espn: { tokensPerInterval: 100, interval: 60_000 },
    mlbStats: { tokensPerInterval: 100, interval: 60_000 },
    oddsPapi: { tokensPerInterval: 100, interval: 60_000 },
    openRouter: { tokensPerInterval: 100, interval: 60_000 },
  },
}));

const poller = require('../../poller/poller');
const { getSportConfig } = require('../../src/config/sports');

beforeEach(() => {
  mockAxios.get.mockReset();
  mockAxios.post.mockReset();
  mockBatchCapture.mockReset();
  mockRedisSet.mockReset();
  mockCache.current.clear();
  mockRedisSet.mockResolvedValue('OK');
});

describe('poller helpers', () => {
  test('isFinalStatus accepts FINAL, FINAL_OT, FINAL_SHOOTOUT', () => {
    expect(poller.isFinalStatus('STATUS_FINAL')).toBe(true);
    expect(poller.isFinalStatus('STATUS_FINAL_OT')).toBe(true);
    expect(poller.isFinalStatus('STATUS_FINAL_SHOOTOUT')).toBe(true);
    expect(poller.isFinalStatus('STATUS_HALFTIME')).toBe(false);
  });

  test('isVoidStatus catches postponed + canceled (both spellings)', () => {
    expect(poller.isVoidStatus('STATUS_POSTPONED')).toBe(true);
    expect(poller.isVoidStatus('STATUS_CANCELED')).toBe(true);
    expect(poller.isVoidStatus('STATUS_CANCELLED')).toBe(true);
    expect(poller.isVoidStatus('STATUS_FINAL')).toBe(false);
  });

  test('inGameHours wraps past midnight when gameEndHourET >= 24', () => {
    // Test logic indirectly with a stub configurable hour. Test that
    // configured ranges include reasonable hours.
    const ncaab = getSportConfig('ncaab');
    expect(ncaab.gameStartHourET).toBeLessThan(ncaab.gameEndHourET);
  });

  test('validateBoxScore — basketball valid', () => {
    const valid = {
      boxscore: {
        players: [
          { statistics: [{ athletes: [] }] },
          { statistics: [{ athletes: [] }] },
        ],
      },
    };
    expect(poller.validateBoxScore(valid, getSportConfig('nba'))).toMatchObject({ valid: true });
  });

  test('validateBoxScore — rejects empty data + missing players', () => {
    expect(poller.validateBoxScore(null, getSportConfig('nba'))).toMatchObject({ valid: false });
    expect(poller.validateBoxScore({ boxscore: {} }, getSportConfig('nba'))).toMatchObject({ valid: false });
  });

  test('validateBoxScore — MLB Stats API shape', () => {
    const valid = { liveData: { boxscore: { teams: { home: {}, away: {} } } } };
    expect(poller.validateBoxScore(valid, getSportConfig('mlb'))).toMatchObject({ valid: true });
  });
});

describe('handleGame — status transitions', () => {
  test('STATUS_IN_PROGRESS first sighting triggers OddsPapi capture', async () => {
    const sportCfg = getSportConfig('nba');
    await poller.handleGame(
      { id: 'game-tip', name: 'STATUS_IN_PROGRESS', competitions: [{}] },
      sportCfg,
    );
    expect(mockBatchCapture).toHaveBeenCalledWith('nba', 'game-tip');
  });

  test('STATUS_IN_PROGRESS second sighting does NOT trigger capture again', async () => {
    const sportCfg = getSportConfig('nba');
    mockCache.current.set('game:gx:status', 'STATUS_IN_PROGRESS');
    await poller.handleGame({ id: 'gx', name: 'STATUS_IN_PROGRESS' }, sportCfg);
    expect(mockBatchCapture).not.toHaveBeenCalled();
  });

  test('STATUS_FINAL triggers POST to /api/grading/resolve', async () => {
    const sportCfg = getSportConfig('nba');
    mockAxios.get.mockResolvedValue({
      status: 200,
      data: {
        boxscore: {
          players: [{ statistics: [{ athletes: [] }] }, { statistics: [{ athletes: [] }] }],
        },
      },
    });
    mockAxios.post.mockResolvedValue({ status: 200, data: { resolved: 5, voided: 0 } });
    await poller.handleGame({ id: 'gf', name: 'STATUS_FINAL' }, sportCfg);
    expect(mockAxios.post).toHaveBeenCalledWith(
      'http://localhost:3001/api/grading/resolve',
      expect.objectContaining({ gameId: 'gf', sport: 'nba' }),
      expect.objectContaining({
        headers: expect.objectContaining({ 'X-VYNDR-Internal-Key': 'unit-test-internal-key' }),
      }),
    );
  });

  test('STATUS_FINAL_OT also triggers resolution', async () => {
    const sportCfg = getSportConfig('nba');
    mockAxios.get.mockResolvedValue({
      status: 200,
      data: { boxscore: { players: [{ statistics: [{}] }, { statistics: [{}] }] } },
    });
    mockAxios.post.mockResolvedValue({ status: 200, data: { resolved: 3, voided: 0 } });
    await poller.handleGame({ id: 'g-ot', name: 'STATUS_FINAL_OT' }, sportCfg);
    expect(mockAxios.post).toHaveBeenCalled();
  });

  test('STATUS_POSTPONED sends a void payload', async () => {
    mockAxios.post.mockResolvedValue({ status: 200, data: { resolved: 0, voided: 7 } });
    await poller.handleGame({ id: 'pp', name: 'STATUS_POSTPONED' }, getSportConfig('nba'));
    expect(mockAxios.post).toHaveBeenCalledWith(
      'http://localhost:3001/api/grading/resolve',
      expect.objectContaining({ void: true, reason: 'status_postponed' }),
      expect.anything(),
    );
  });

  test('lock NX prevents double resolution', async () => {
    const sportCfg = getSportConfig('nba');
    mockRedisSet.mockResolvedValue(null); // simulates someone else holding the lock
    mockAxios.post.mockResolvedValue({ status: 200, data: { resolved: 0 } });
    await poller.handleGame({ id: 'locked', name: 'STATUS_FINAL' }, sportCfg);
    expect(mockAxios.post).not.toHaveBeenCalled();
  });

  test('invalid box score blocks the POST + sends ntfy', async () => {
    const sportCfg = getSportConfig('nba');
    mockAxios.get.mockResolvedValue({ status: 200, data: { boxscore: { players: [] } } });
    await poller.handleGame({ id: 'bad-box', name: 'STATUS_FINAL' }, sportCfg);
    expect(mockAxios.post.mock.calls.filter((c) => c[0].endsWith('/api/grading/resolve'))).toHaveLength(0);
  });

  test('VYNDR_INTERNAL_KEY is never inlined into logs', () => {
    // The poller source must not console.log the key directly.
    const fs = require('fs');
    const src = fs.readFileSync(require.resolve('../../poller/poller.js'), 'utf8');
    expect(src).not.toMatch(/console\.[a-z]+\([^)]*VYNDR_INTERNAL_KEY/);
  });
});

describe('postResolution retry', () => {
  test('returns successful payload on first try', async () => {
    mockAxios.post.mockResolvedValue({ status: 200, data: { resolved: 4 } });
    const res = await poller.postResolution({ gameId: 'g1', sport: 'nba', boxScore: {} });
    expect(res).toMatchObject({ resolved: 4 });
  });
});
