// Quota tracker (Session 20).
//
// Pins behavior so the gateway + scheduler can rely on it: period
// keys reflect the configured quotaType, recordCall advances the
// counter, syncFromHeaders is the truth-source override, the 80%
// warning fires once per period, and the 95% threshold flips
// `allowed` from true to false. Redis is mocked so the tests don't
// require a live server.

// Session 21 — mock axios so the ntfy POST inside recordCall
// doesn't try to hit the network. The mock is scoped per-test via
// resetAllMocks below.
jest.mock('axios', () => ({ post: jest.fn(async () => ({ status: 200 })) }));
const axios = require('axios');

jest.mock('../../src/utils/redis', () => {
  const store = new Map();
  return {
    cacheGet: jest.fn(async (key) => {
      if (!store.has(key)) return null;
      return store.get(key);
    }),
    cacheSet: jest.fn(async (key, value) => {
      // Mirror the real helper: it stores the value as JSON; cacheGet
      // returns the parsed shape. We skip serialization here and just
      // hold the object directly — same observed behavior.
      store.set(key, value);
      return true;
    }),
    cacheDel: jest.fn(async (key) => {
      store.delete(key);
      return true;
    }),
    isDegraded: jest.fn(() => false),
    __store: store,
  };
});

const redis = require('../../src/utils/redis');
const tracker = require('../../src/services/quotaTracker');

beforeEach(() => {
  redis.__store.clear();
  redis.cacheGet.mockClear();
  redis.cacheSet.mockClear();
  redis.cacheDel.mockClear();
  redis.isDegraded.mockReturnValue(false);
  axios.post.mockClear();
  delete process.env.NTFY_URL;
  process.env.ODDS_API_KEY = 'test-odds-key';
  process.env.RAPID_API_KEY = 'test-tank01-key';
  process.env.API_FOOTBALL_KEY = 'test-apifoot-key';
  process.env.FOOTBALL_DATA_API_KEY = 'test-fd-key';
});

// Helper for the ntfy block — awaits a microtask flush so the
// fire-and-forget `sendQuotaAlert(...).catch(...)` inside
// recordCall has resolved before assertions run. Without this the
// axios.post mock can be observed pre-call from inside the test.
const flushAsync = () => new Promise((r) => setImmediate(r));

describe('quotaTracker.getPeriodKey', () => {
  test('monthly produces YYYY-MM', () => {
    const key = tracker.getPeriodKey('odds-api', new Date(Date.UTC(2026, 5, 12)));
    expect(key).toBe('2026-06');
  });
  test('daily produces YYYY-MM-DD', () => {
    const key = tracker.getPeriodKey('api-football', new Date(Date.UTC(2026, 5, 12)));
    expect(key).toBe('2026-06-12');
  });
  test('per_minute produces YYYY-MM-DDTHH:MM', () => {
    const key = tracker.getPeriodKey('football-data', new Date(Date.UTC(2026, 5, 12, 15, 30)));
    expect(key).toBe('2026-06-12T15:30');
  });
  test('unknown provider returns empty string', () => {
    expect(tracker.getPeriodKey('made-up')).toBe('');
  });
});

describe('quotaTracker.recordCall', () => {
  test('counts up from zero, exposes remaining', async () => {
    const a = await tracker.recordCall('odds-api');
    expect(a.allowed).toBe(true);
    expect(a.used).toBe(1);
    expect(a.remaining).toBe(499);
    const b = await tracker.recordCall('odds-api');
    expect(b.used).toBe(2);
    expect(b.remaining).toBe(498);
  });

  test('returns allowed:false once pct hits 95%', async () => {
    // Seed the counter at 95% directly through syncFromHeaders so we
    // don't have to fire 475 recordCall iterations.
    await tracker.syncFromHeaders('odds-api', {
      'x-requests-used': '475',
      'x-requests-remaining': '25',
    });
    const status = await tracker.getQuotaStatus('odds-api');
    expect(status.used).toBe(475);
    expect(status.pct).toBeCloseTo(0.95);
    expect(status.allowed).toBe(false);
  });

  test('logs the WARN line exactly once at 80%', async () => {
    const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
    // Seed 399/500 = 79.8% — first recordCall takes us to 400/500 = 80%
    await tracker.syncFromHeaders('odds-api', {
      'x-requests-used': '399',
      'x-requests-remaining': '101',
    });
    await tracker.recordCall('odds-api');
    await tracker.recordCall('odds-api');
    await tracker.recordCall('odds-api');
    const warnings = warnSpy.mock.calls.map((c) => c.join(' ')).filter((m) => /quotaTracker/.test(m));
    expect(warnings.length).toBe(1);
    expect(warnings[0]).toMatch(/Odds API/);
    warnSpy.mockRestore();
  });

  test('unknown provider → allowed:false', async () => {
    const r = await tracker.recordCall('does-not-exist');
    expect(r.allowed).toBe(false);
    expect(r.reason).toBe('unknown_provider');
  });
});

describe('quotaTracker.rollback', () => {
  test('decrements the counter without going below zero', async () => {
    await tracker.recordCall('tank01');
    await tracker.recordCall('tank01');
    await tracker.rollback('tank01');
    let status = await tracker.getQuotaStatus('tank01');
    expect(status.used).toBe(1);
    await tracker.rollback('tank01');
    await tracker.rollback('tank01');
    status = await tracker.getQuotaStatus('tank01');
    expect(status.used).toBe(0);
  });
});

describe('quotaTracker.syncFromHeaders', () => {
  test('odds-api headers overwrite the counter (truth source)', async () => {
    await tracker.recordCall('odds-api');
    await tracker.recordCall('odds-api');
    // Local counter says 2; upstream says 50 — upstream wins.
    const synced = await tracker.syncFromHeaders('odds-api', {
      'x-requests-used': '50',
      'x-requests-remaining': '450',
    });
    expect(synced.used).toBe(50);
    expect(synced.limit).toBe(500);
    const status = await tracker.getQuotaStatus('odds-api');
    expect(status.used).toBe(50);
    expect(status.syncedAt).toBeTruthy();
  });

  test('infers used from remaining + limit when used header absent', async () => {
    const synced = await tracker.syncFromHeaders('odds-api', {
      'x-requests-remaining': '120',
      'x-quota-limit': '500',
    });
    expect(synced.used).toBe(380);
    expect(synced.limit).toBe(500);
  });

  test('returns null when no usable headers present', async () => {
    const synced = await tracker.syncFromHeaders('odds-api', { 'content-type': 'application/json' });
    expect(synced).toBeNull();
  });
});

describe('quotaTracker.getTickInterval (scheduler step function)', () => {
  test('returns 5min under 50%', () => {
    expect(tracker.getTickInterval(0)).toBe(5 * 60 * 1000);
    expect(tracker.getTickInterval(0.49)).toBe(5 * 60 * 1000);
  });
  test('returns 15min at 50%–79%', () => {
    expect(tracker.getTickInterval(0.5)).toBe(15 * 60 * 1000);
    expect(tracker.getTickInterval(0.79)).toBe(15 * 60 * 1000);
  });
  test('returns 30min at 80%–94%', () => {
    expect(tracker.getTickInterval(0.8)).toBe(30 * 60 * 1000);
    expect(tracker.getTickInterval(0.94)).toBe(30 * 60 * 1000);
  });
  test('returns null at >=95% (stop)', () => {
    expect(tracker.getTickInterval(0.95)).toBeNull();
    expect(tracker.getTickInterval(1.0)).toBeNull();
  });
});

describe('quotaTracker.shouldThrottle', () => {
  test('returns allowed:false and interval:null at exhaustion', async () => {
    await tracker.syncFromHeaders('odds-api', {
      'x-requests-used': '500',
      'x-requests-remaining': '0',
    });
    const out = await tracker.shouldThrottle('odds-api');
    expect(out.allowed).toBe(false);
    expect(out.interval).toBeNull();
  });
  test('allowed and 5min interval when healthy', async () => {
    const out = await tracker.shouldThrottle('odds-api');
    expect(out.allowed).toBe(true);
    expect(out.interval).toBe(5 * 60 * 1000);
  });
});

describe('quotaTracker degraded-mode fail-open', () => {
  test('returns allowed:true degraded:true when Redis is down', async () => {
    redis.isDegraded.mockReturnValue(true);
    const status = await tracker.getQuotaStatus('odds-api');
    expect(status.allowed).toBe(true);
    expect(status.degraded).toBe(true);
  });
  test('recordCall is a no-op when degraded', async () => {
    redis.isDegraded.mockReturnValue(true);
    const r = await tracker.recordCall('odds-api');
    expect(r.allowed).toBe(true);
    expect(r.degraded).toBe(true);
    expect(redis.cacheSet).not.toHaveBeenCalled();
  });
});

describe('quotaTracker ntfy alerts (Session 21)', () => {
  test('does NOT post to ntfy when NTFY_URL is unset', async () => {
    const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
    await tracker.syncFromHeaders('odds-api', {
      'x-requests-used': '399',
      'x-requests-remaining': '101',
    });
    await tracker.recordCall('odds-api'); // 400/500 → 80%
    await flushAsync();
    expect(axios.post).not.toHaveBeenCalled();
    warnSpy.mockRestore();
  });

  test('posts WARN to ntfy at 80% with priority 4', async () => {
    process.env.NTFY_URL = 'https://alerts.example.com/vyndr';
    const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
    await tracker.syncFromHeaders('odds-api', {
      'x-requests-used': '399',
      'x-requests-remaining': '101',
    });
    await tracker.recordCall('odds-api'); // 400/500 → 80%
    await flushAsync();
    expect(axios.post).toHaveBeenCalledTimes(1);
    const [url, body, opts] = axios.post.mock.calls[0];
    expect(url).toBe('https://alerts.example.com/vyndr');
    expect(body).toMatch(/Odds API/);
    expect(body).toMatch(/80%/);
    expect(opts.headers.Priority).toBe('4');
    expect(opts.headers.Title).toMatch(/Warning/);
    warnSpy.mockRestore();
  });

  test('posts BLOCK to ntfy at 95% with priority 5', async () => {
    process.env.NTFY_URL = 'https://alerts.example.com/vyndr';
    const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
    // Seed silently under WARN, then let a recordCall cross 95%.
    await tracker.syncFromHeaders('odds-api', {
      'x-requests-used': '390',
      'x-requests-remaining': '110',
    });
    await flushAsync();
    expect(axios.post).toHaveBeenCalledTimes(0); // seed at 78% is silent
    // Jump straight to 95% via the authoritative header sync — it must page.
    await tracker.syncFromHeaders('odds-api', {
      'x-requests-used': '475',
      'x-requests-remaining': '25',
    });
    await flushAsync();
    expect(axios.post).toHaveBeenCalledTimes(1);
    const [, body, opts] = axios.post.mock.calls[0];
    expect(body).toMatch(/BLOCKED|95%/);
    expect(opts.headers.Priority).toBe('5');
    expect(opts.headers.Title).toMatch(/BLOCKED/);
    warnSpy.mockRestore();
  });

  test('syncFromHeaders (authoritative) pages on a header-only crossing — the silent-drain fix', async () => {
    // The 500/500 drain came in entirely via header reconcile; recordCall was
    // never on the burner's path, so the counter went to 100% unpaged. The
    // authoritative writer must alert. Seed silent, then cross 80% via sync.
    process.env.NTFY_URL = 'https://alerts.example.com/vyndr';
    const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
    await tracker.syncFromHeaders('odds-api', { 'x-requests-used': '390', 'x-requests-remaining': '110' });
    await flushAsync();
    expect(axios.post).toHaveBeenCalledTimes(0);
    await tracker.syncFromHeaders('odds-api', { 'x-requests-used': '400', 'x-requests-remaining': '100' }); // 80%
    await flushAsync();
    expect(axios.post).toHaveBeenCalledTimes(1);
    expect(axios.post.mock.calls[0][2].headers.Priority).toBe('4');
    // A later sync in the same period is deduped — no alert storm.
    await tracker.syncFromHeaders('odds-api', { 'x-requests-used': '410', 'x-requests-remaining': '90' });
    await flushAsync();
    expect(axios.post).toHaveBeenCalledTimes(1);
    warnSpy.mockRestore();
  });

  test('dedupes — second recordCall in the same period does not re-post', async () => {
    process.env.NTFY_URL = 'https://alerts.example.com/vyndr';
    const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
    await tracker.syncFromHeaders('odds-api', {
      'x-requests-used': '399',
      'x-requests-remaining': '101',
    });
    await tracker.recordCall('odds-api');
    await flushAsync();
    await tracker.recordCall('odds-api');
    await flushAsync();
    await tracker.recordCall('odds-api');
    await flushAsync();
    expect(axios.post).toHaveBeenCalledTimes(1);
    warnSpy.mockRestore();
  });

  test('WARN then BLOCK each fire once across a period (separate dedupe keys)', async () => {
    process.env.NTFY_URL = 'https://alerts.example.com/vyndr';
    const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
    // Seed at 78% (silent).
    await tracker.syncFromHeaders('odds-api', {
      'x-requests-used': '390',
      'x-requests-remaining': '110',
    });
    await tracker.recordCall('odds-api'); // 391 → 78.2% — under warn
    await flushAsync();
    expect(axios.post).toHaveBeenCalledTimes(0);
    // Cross 80% → WARN (priority 4).
    await tracker.syncFromHeaders('odds-api', { 'x-requests-used': '400', 'x-requests-remaining': '100' });
    await flushAsync();
    expect(axios.post).toHaveBeenCalledTimes(1);
    expect(axios.post.mock.calls[0][2].headers.Priority).toBe('4');
    // Cross 95% → BLOCK (priority 5), a SEPARATE dedupe key so it still fires.
    await tracker.syncFromHeaders('odds-api', { 'x-requests-used': '475', 'x-requests-remaining': '25' });
    await flushAsync();
    expect(axios.post).toHaveBeenCalledTimes(2);
    expect(axios.post.mock.calls[1][2].headers.Priority).toBe('5');
    warnSpy.mockRestore();
  });

  test('axios.post failure does NOT break recordCall', async () => {
    process.env.NTFY_URL = 'https://alerts.example.com/vyndr';
    axios.post.mockRejectedValueOnce(new Error('ntfy down'));
    const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
    await tracker.syncFromHeaders('odds-api', {
      'x-requests-used': '399',
      'x-requests-remaining': '101',
    });
    const result = await tracker.recordCall('odds-api');
    await flushAsync();
    // recordCall returns the normal status even when ntfy throws.
    expect(result.allowed).toBe(true);
    expect(result.used).toBe(400);
    warnSpy.mockRestore();
  });
});

describe('quotaTracker.getAllQuotaStatuses', () => {
  test('returns one entry per configured provider', async () => {
    const statuses = await tracker.getAllQuotaStatuses();
    const ids = statuses.map((s) => s.provider).sort();
    // ODDS_API_KEY, RAPID_API_KEY, API_FOOTBALL_KEY, FOOTBALL_DATA_API_KEY
    // are set in beforeEach; ODDSPAPI_KEY and PARLAYAPI_KEY are not.
    expect(ids).toContain('odds-api');
    expect(ids).toContain('tank01');
    expect(ids).toContain('api-football');
    expect(ids).toContain('football-data');
    expect(ids).not.toContain('oddspapi');
    expect(ids).not.toContain('parlayapi');
  });
});
