'use strict';

// Session 56 — pipeline alerting (ntfy) + retry-once + missed-cron detection.

const opsNotify = require('../../src/utils/opsNotify');
const snapshot = require('../../src/services/snapshotService');
const { mostRecentExpectedSlot, isSnapshotOverdue } = require('../../src/snapshotScheduler');

function memCache(seed = {}) {
  const store = { ...seed };
  return { store, cacheGet: async (k) => (k in store ? store[k] : null), cacheSet: async (k, v) => { store[k] = v; return true; } };
}

describe('opsNotify', () => {
  test('POSTs to the ntfy topic with title/priority/tags headers', async () => {
    const calls = [];
    const fetchImpl = async (url, opts) => { calls.push({ url, opts }); return { ok: true }; };
    const res = await opsNotify.notify('hello', { title: 'VYNDR', priority: 'high', tags: ['x'], fetchImpl });
    expect(res.sent).toBe(true);
    expect(calls[0].url).toMatch(/\/vyndr-pipeline-kev2026$/);
    expect(calls[0].opts.method).toBe('POST');
    expect(calls[0].opts.headers.Title).toBe('VYNDR');
    expect(calls[0].opts.headers.Priority).toBe('high');
    expect(calls[0].opts.headers.Tags).toBe('x');
    expect(calls[0].opts.body).toBe('hello');
  });

  test('never throws on fetch failure', async () => {
    const fetchImpl = async () => { throw new Error('network down'); };
    const res = await opsNotify.notify('x', { fetchImpl });
    expect(res.sent).toBe(false);
    expect(res.reason).toBe('network down');
  });

  test('disabled under NODE_ENV=test without an injected fetch', async () => {
    const res = await opsNotify.notify('x');
    expect(res.sent).toBe(false);
    expect(res.reason).toBe('disabled');
  });
});

describe('runSnapshot — retry + alerts', () => {
  const baseDeps = (cache) => ({
    ...cache,
    gradeAndCacheSlate: async (_s, _p, opts) => { opts.cacheSet('grades', { grades: [{ player: 'A', stat_type: 'hits', line: 1.5, direction: 'over', grade: 'A', confidence: 90 }] }); },
    resolveStats: async () => ({ found: false }),
    classify: () => ({ primary: null }),
    now: () => '2026-07-10T15:00:00Z',
    nowMs: () => 1000,
    sleep: async () => {},        // no real delay in tests
    retryDelayMs: 0,
  });

  test('retries once on a thrown odds error, then succeeds', async () => {
    const cache = memCache();
    let n = 0;
    const notes = [];
    const deps = {
      ...baseDeps(cache),
      getOdds: async () => { n += 1; if (n === 1) throw new Error('propline 503'); return { props: [{ player: 'A', stat_type: 'hits', line: 1.5 }], provider: 'propline' }; },
      notify: async (msg) => { notes.push(msg); return { sent: true }; },
    };
    const r = await snapshot.runSnapshot('mlb', deps);
    expect(n).toBe(2);                       // one retry
    expect(r.status).toBe('ok');
    expect(notes.some((m) => m.includes('✅') && m.includes('MLB'))).toBe(true);
  });

  test('alerts failure when both attempts throw', async () => {
    const cache = memCache();
    const notes = [];
    const deps = {
      ...baseDeps(cache),
      getOdds: async () => { throw new Error('down'); },
      notify: async (msg) => { notes.push(msg); return { sent: true }; },
    };
    const r = await snapshot.runSnapshot('mlb', deps);
    expect(r.status).toBe('error');
    expect(notes.some((m) => m.includes('❌') && m.includes('FAILED'))).toBe(true);
  });

  test('alerts stale (low priority) on an empty slate, no retry', async () => {
    const cache = memCache();
    let n = 0;
    const notes = [];
    const deps = {
      ...baseDeps(cache),
      getOdds: async () => { n += 1; return { props: [] }; },
      notify: async (msg) => { notes.push(msg); return { sent: true }; },
    };
    const r = await snapshot.runSnapshot('mlb', deps);
    expect(n).toBe(1);                        // empty is NOT a failure → no retry
    expect(r.status).toBe('skipped');
    expect(notes.some((m) => m.includes('⚠️') && m.includes('0 props'))).toBe(true);
  });
});

describe('missed-cron detection', () => {
  const HOURS = [14, 19, 22, 1, 3];
  test('mostRecentExpectedSlot returns the latest scheduled hour at/before now', () => {
    const now = new Date('2026-07-10T16:30:00Z');
    const slot = mostRecentExpectedSlot(now, HOURS);
    expect(slot.getUTCHours()).toBe(14);
    expect(slot.getUTCDate()).toBe(10);
  });

  test('overdue when now is >30m past a slot the last snapshot predates', () => {
    const now = new Date('2026-07-10T16:30:00Z'); // 2.5h past the 14:00 slot
    const lastTs = '2026-07-10T10:00:00Z';        // before 14:00 → the 14:00 run was missed
    expect(isSnapshotOverdue(lastTs, now, HOURS, 30)).toBe(true);
  });

  test('not overdue within the grace window', () => {
    const now = new Date('2026-07-10T14:20:00Z'); // only 20m past the slot
    expect(isSnapshotOverdue('2026-07-10T10:00:00Z', now, HOURS, 30)).toBe(false);
  });

  test('not overdue when the last snapshot is fresh (ran the slot)', () => {
    const now = new Date('2026-07-10T16:30:00Z');
    const lastTs = '2026-07-10T14:01:00Z';        // ran the 14:00 slot
    expect(isSnapshotOverdue(lastTs, now, HOURS, 30)).toBe(false);
  });

  test('never overdue on cold boot (no prior snapshot)', () => {
    const now = new Date('2026-07-10T16:30:00Z');
    expect(isSnapshotOverdue(null, now, HOURS, 30)).toBe(false);
  });
});
