const { getOdds, getCacheKey, getQuotaKey, updateQuota, getQuotaRemaining, CACHE_TTL, getConfiguredCacheTTL } = require('../../src/services/oddsService');

// Mock Redis
const mockRedis = {
  get: jest.fn(),
  set: jest.fn(),
  hset: jest.fn(),
  hgetall: jest.fn(),
  expire: jest.fn(),
};
jest.mock('../../src/utils/redis', () => ({
  getRedisClient: () => mockRedis,
  // Session 20 — the provider gateway + quotaTracker pull from
  // cacheGet/cacheSet/isDegraded. We surface them as degraded-mode
  // no-ops here so the gateway fails OPEN in tests (lets every call
  // through without touching Redis), which preserves the legacy
  // axios-mock-driven assertions in this file.
  cacheGet: jest.fn(async () => null),
  cacheSet: jest.fn(async () => true),
  cacheDel: jest.fn(async () => true),
  isDegraded: jest.fn(() => true),
}));

// Mock axios
jest.mock('axios');
const axios = require('axios');

// Set API key for tests
process.env.ODDS_API_KEY = 'test-api-key';

const MOCK_EVENT = {
  id: 'event-1',
  sport_key: 'basketball_nba',
  home_team: 'Denver Nuggets',
  away_team: 'Los Angeles Lakers',
  commence_time: '2026-03-21T19:00:00Z',
};

const MOCK_EVENT_WITH_ODDS = {
  ...MOCK_EVENT,
  bookmakers: [
    {
      key: 'draftkings',
      title: 'DraftKings',
      markets: [
        {
          key: 'player_points',
          last_update: '2026-03-21T14:28:00Z',
          outcomes: [
            { name: 'Over', description: 'Nikola Jokic', price: -110, point: 26.5 },
            { name: 'Under', description: 'Nikola Jokic', price: -110, point: 26.5 },
          ],
        },
      ],
    },
  ],
};

function mockAxiosSuccess() {
  axios.get
    .mockResolvedValueOnce({
      data: [MOCK_EVENT],
      headers: { 'x-requests-remaining': '490', 'x-requests-used': '10' },
    })
    .mockResolvedValueOnce({
      data: MOCK_EVENT_WITH_ODDS,
      headers: { 'x-requests-remaining': '489', 'x-requests-used': '11' },
    });
}

beforeEach(() => {
  jest.clearAllMocks();
  mockRedis.get.mockResolvedValue(null);
  mockRedis.set.mockResolvedValue('OK');
  mockRedis.hset.mockResolvedValue(1);
  mockRedis.hgetall.mockResolvedValue({});
  mockRedis.expire.mockResolvedValue(1);
});

describe('oddsService', () => {
  describe('getOdds - cache hit', () => {
    it('returns cached data when cache is fresh (no API call made)', async () => {
      const cachedData = {
        updated_at: '2026-03-21T14:00:00Z',
        props: [{ player: 'Jokic', stat_type: 'points' }],
      };
      mockRedis.get.mockResolvedValue(JSON.stringify(cachedData));
      mockRedis.hgetall.mockResolvedValue({ remaining: '450' });

      const result = await getOdds('nba');

      expect(result.source).toBe('cache');
      expect(result.props).toEqual(cachedData.props);
      expect(result.quota_remaining).toBe(450);
      expect(axios.get).not.toHaveBeenCalled();
    });
  });

  describe('getOdds - cache miss', () => {
    it('calls Odds API when cache is empty', async () => {
      mockAxiosSuccess();

      const result = await getOdds('nba');

      expect(result.source).toBe('live');
      expect(result.props.length).toBeGreaterThan(0);
      expect(result.props[0].player).toBe('Nikola Jokic');
      expect(axios.get).toHaveBeenCalledTimes(2); // events + 1 event odds
    });

    it('stores normalized data in Redis after successful fetch', async () => {
      mockAxiosSuccess();

      await getOdds('nba');

      expect(mockRedis.set).toHaveBeenCalledWith(
        expect.stringMatching(/^odds:nba:/),
        expect.any(String),
        'EX',
        CACHE_TTL
      );
    });
  });

  describe('getOdds - API failure', () => {
    it('returns stale cache on API failure', async () => {
      // First call: no cache -> will try API
      mockRedis.get
        .mockResolvedValueOnce(null) // initial cache check
        .mockResolvedValueOnce(JSON.stringify({ // stale cache fallback
          updated_at: '2026-03-21T12:00:00Z',
          props: [{ player: 'Stale Jokic' }],
        }));

      axios.get.mockRejectedValue(new Error('API timeout'));

      const result = await getOdds('nba');

      expect(result.source).toBe('cache');
      expect(result.stale).toBe(true);
      expect(result.props[0].player).toBe('Stale Jokic');
    });

    it('throws 503 when API fails and no cache exists', async () => {
      mockRedis.get.mockResolvedValue(null);
      axios.get.mockRejectedValue(new Error('API down'));

      await expect(getOdds('nba')).rejects.toMatchObject({
        message: 'Odds service unavailable.',
        statusCode: 503,
      });
    });

    // Session 19 — diagnostic log line. The 503 client-facing
    // message is intentionally vague (no upstream leak); the log
    // line is the operator's only signal. This test pins the log
    // shape so a future "clean up logs" PR can't silently delete it.
    it('logs upstream status + message before throwing 503', async () => {
      const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
      mockRedis.get.mockResolvedValue(null);
      const apiErr = new Error('Request failed with status code 401');
      apiErr.code = 'ERR_BAD_REQUEST';
      apiErr.response = { status: 401, data: { message: 'Invalid API key' } };
      axios.get.mockRejectedValue(apiErr);

      await expect(getOdds('nba')).rejects.toMatchObject({ statusCode: 503 });

      const logged = errorSpy.mock.calls.map((c) => c.join(' ')).join('\n');
      expect(logged).toMatch(/oddsService/);
      expect(logged).toMatch(/nba/);
      expect(logged).toMatch(/upstream_status=401/);
      expect(logged).toMatch(/Invalid API key/);
      errorSpy.mockRestore();
    });
  });

  describe('getOdds - quota management', () => {
    it('tracks quota from response headers', async () => {
      mockAxiosSuccess();

      await getOdds('nba');

      expect(mockRedis.hset).toHaveBeenCalledWith(
        expect.stringMatching(/^odds:quota:/),
        'remaining', '489',
        'used', '11',
        'last_checked', expect.any(String)
      );
    });

    it('proceeds when tracker is under the BLOCK threshold (e.g. 80%)', async () => {
      // Session 22 — WARN threshold (80%) is informational only; the
      // call should still proceed. This pins that the tracker uses
      // BLOCK (95%) not WARN (80%) as the pre-flight gate.
      const redisMock = require('../../src/utils/redis');
      redisMock.isDegraded.mockReturnValueOnce(false);
      redisMock.cacheGet.mockImplementationOnce(async (key) => {
        if (typeof key === 'string' && key.startsWith('quota:odds-api:')) {
          return { used: 400, limit: 500 }; // 80% — WARN but not BLOCK
        }
        return null;
      });
      mockAxiosSuccess();
      const result = await getOdds('nba');
      expect(result.source).toBe('live');
      expect(axios.get).toHaveBeenCalled();
    });

    it('attaches quotaStatus to the 429 error for operator inspection', async () => {
      const redisMock = require('../../src/utils/redis');
      redisMock.isDegraded.mockReturnValueOnce(false);
      redisMock.cacheGet.mockImplementationOnce(async (key) => {
        if (typeof key === 'string' && key.startsWith('quota:odds-api:')) {
          return { used: 480, limit: 500 }; // 96% — BLOCK
        }
        return null;
      });
      try {
        await getOdds('nba');
        throw new Error('expected reject');
      } catch (err) {
        expect(err.statusCode).toBe(429);
        expect(err.quotaStatus).toBeDefined();
        expect(err.quotaStatus.allowed).toBe(false);
        expect(err.quotaStatus.used).toBe(480);
      }
    });

    it('blocks fetches when the tracker is at the BLOCK threshold', async () => {
      // Session 22 — block decision moved from the legacy
      // `getQuotaRemaining` hash to the Session 20 tracker. Bring
      // the tracker out of degraded-mode for this test and seed
      // the counter at 95% via the cacheGet mock so the new
      // `quotaStatus.allowed` branch trips.
      const redisMock = require('../../src/utils/redis');
      redisMock.isDegraded.mockReturnValueOnce(false);
      redisMock.cacheGet.mockImplementationOnce(async (key) => {
        if (typeof key === 'string' && key.startsWith('quota:odds-api:')) {
          return { used: 475, limit: 500 };
        }
        return null;
      });

      await expect(getOdds('nba')).rejects.toMatchObject({
        message: 'Odds data temporarily unavailable. Try again later.',
        statusCode: 429,
      });
      expect(axios.get).not.toHaveBeenCalled();
    });
  });

  describe('utility functions', () => {
    it('getCacheKey uses UTC date', () => {
      const key = getCacheKey('nba');
      expect(key).toMatch(/^odds:nba:\d{4}-\d{2}-\d{2}$/);
    });

    it('getQuotaKey uses UTC year-month', () => {
      const key = getQuotaKey();
      expect(key).toMatch(/^odds:quota:\d{4}-\d{2}$/);
    });

    it('CACHE_TTL defaults to 3600 (1 hour) at module load', () => {
      // Session 22 — TTL bumped from 900 to 3600. The module reads
      // the env once at load; this test is informational about the
      // default. Overrides happen via ODDS_CACHE_TTL_SECONDS in
      // Coolify — see the comment block in oddsService.js.
      expect(CACHE_TTL).toBe(3600);
    });

    describe('getConfiguredCacheTTL (Session 22 — env-driven TTL)', () => {
      const origEnv = process.env.ODDS_CACHE_TTL_SECONDS;
      afterEach(() => {
        if (origEnv === undefined) delete process.env.ODDS_CACHE_TTL_SECONDS;
        else process.env.ODDS_CACHE_TTL_SECONDS = origEnv;
      });

      it('returns 3600 when env is unset', () => {
        delete process.env.ODDS_CACHE_TTL_SECONDS;
        expect(getConfiguredCacheTTL()).toBe(3600);
      });

      it('honors a valid override (e.g. 7200 for free-tier with many sports)', () => {
        process.env.ODDS_CACHE_TTL_SECONDS = '7200';
        expect(getConfiguredCacheTTL()).toBe(7200);
      });

      it('falls back to default when override is non-numeric', () => {
        process.env.ODDS_CACHE_TTL_SECONDS = 'forever';
        expect(getConfiguredCacheTTL()).toBe(3600);
      });

      it('rejects override <60 (would shred credits) — defaults instead', () => {
        process.env.ODDS_CACHE_TTL_SECONDS = '30';
        expect(getConfiguredCacheTTL()).toBe(3600);
      });

      it('rejects override >86400 (would hold stale forever) — defaults instead', () => {
        process.env.ODDS_CACHE_TTL_SECONDS = '99999';
        expect(getConfiguredCacheTTL()).toBe(3600);
      });
    });
  });
});
