// Session 60 (night2/D) — Phase 2.5 intraday refresh. Direction is the
// signal: WITH the grade = STEAM (no re-grade); AGAINST ≥1.0 = re-grade
// that prop only (holds → VALUE, drops → PUBLIC revision). All lines are
// real feed values; the refresh recaptures the close.

const { runIntradayRefresh, inSlateHours, __internals } = require('../../src/services/intradayRefreshService');
const { signedDelta, trackHistory, HISTORY_CAP } = __internals;

const NOW = '2026-07-11T20:00:00.000Z';

function harness({ grades, oddsProps, analyzeResult }) {
  const store = new Map([[`snapshot:mlb:latest`, { sport: 'mlb', updated_at: NOW, grades, deltas: [] }]]);
  const calls = { analyze: [], revisions: [], closing: 0, ticker: [] };
  const deps = {
    cacheGet: async (k) => store.get(k) ?? null,
    cacheSet: async (k, v) => { store.set(k, v); },
    getOdds: async () => ({ props: oddsProps }),
    analyze: async (prop) => { calls.analyze.push(prop); return analyzeResult; },
    ledger: {
      applyRevision: async (sport, rev) => { calls.revisions.push(rev); return { revised: true }; },
      captureClosing: async () => { calls.closing += 1; return { updated: 0 }; },
    },
    pushTickerItems: async (events) => { calls.ticker.push(...events); },
    now: () => NOW,
  };
  return { deps, store, calls };
}

const GRADE = {
  player: 'Aaron Judge', stat_type: 'home_runs', line: 0.5, direction: 'over',
  grade: 'A', confidence: 78, gradedAt: { line: 0.5, odds: -115, timestamp: NOW },
};
const prop = (line) => ({ player: 'Aaron Judge', stat_type: 'home_runs', line, book: 'draftkings' });

describe('signedDelta — relative to the graded side', () => {
  test('over: rising line = positive (market chasing the over)', () => {
    expect(signedDelta('over', 1.5, 2.5)).toBe(1);
    expect(signedDelta('over', 1.5, 1.0)).toBe(-0.5);
  });
  test('under: falling line = positive', () => {
    expect(signedDelta('under', 1.5, 1.0)).toBe(0.5);
    expect(signedDelta('under', 1.5, 2.5)).toBe(-1);
  });
  test('missing values → null, never a fabricated zero', () => {
    expect(signedDelta('over', null, 2)).toBeNull();
  });
});

describe('runIntradayRefresh — directional handling', () => {
  test('moved WITH the grade → STEAM, NO re-grade', async () => {
    const { deps, store, calls } = harness({ grades: [GRADE], oddsProps: [prop(1.5)], analyzeResult: null });
    const res = await runIntradayRefresh('mlb', deps);
    expect(res).toMatchObject({ status: 'ok', steam: 1, value: 0, revised: 0 });
    expect(calls.analyze).toHaveLength(0); // steam never re-grades
    const snap = store.get('snapshot:mlb:latest');
    expect(snap.grades[0].movement).toMatchObject({ kind: 'steam', delta: 1, currentLine: 1.5 });
    expect(calls.ticker.length).toBeGreaterThan(0); // ≥1.0 move → ticker MOVE
    expect(calls.ticker[0].text).toContain('o0.5 → o1.5');
  });

  test('moved AGAINST ≥1.0, re-grade HOLDS → VALUE (better entry)', async () => {
    const { deps, store, calls } = harness({
      grades: [{ ...GRADE, gradedAt: { line: 2.5, odds: -110, timestamp: NOW }, line: 2.5 }],
      oddsProps: [prop(1.5)],
      analyzeResult: { grade: 'A', confidence: 70 }, // same grade at the new line
    });
    const res = await runIntradayRefresh('mlb', deps);
    expect(res).toMatchObject({ value: 1, revised: 0 });
    expect(calls.analyze).toHaveLength(1);
    expect(calls.analyze[0].line).toBe(1.5); // re-graded at the REAL current line
    expect(store.get('snapshot:mlb:latest').grades[0].movement.kind).toBe('value');
    expect(calls.revisions).toHaveLength(0);
  });

  test('moved AGAINST ≥1.0, grade DROPS → PUBLIC revision (original preserved)', async () => {
    const { deps, store, calls } = harness({
      grades: [{ ...GRADE, gradedAt: { line: 2.5, odds: -110, timestamp: NOW }, line: 2.5 }],
      oddsProps: [prop(1.5)],
      analyzeResult: { grade: 'C', confidence: 40 },
    });
    const res = await runIntradayRefresh('mlb', deps);
    expect(res).toMatchObject({ revised: 1 });
    const g = store.get('snapshot:mlb:latest').grades[0];
    expect(g.grade).toBe('C');
    expect(g.revised_from_grade).toBe('A'); // the original, forever
    expect(calls.revisions[0]).toMatchObject({ newGrade: 'C', fromGrade: 'A', line: 2.5, side: 'over' });
  });

  test('a second drop keeps the ORIGINAL revised_from_grade', async () => {
    const already = { ...GRADE, grade: 'B', revised_from_grade: 'A', gradedAt: { line: 2.5, odds: null, timestamp: NOW }, line: 2.5 };
    const { deps, store } = harness({ grades: [already], oddsProps: [prop(1.5)], analyzeResult: { grade: 'C' } });
    await runIntradayRefresh('mlb', deps);
    expect(store.get('snapshot:mlb:latest').grades[0].revised_from_grade).toBe('A');
  });

  test('an insufficient re-read HOLDS the original grade (never blanks it)', async () => {
    const { deps, store } = harness({
      grades: [{ ...GRADE, gradedAt: { line: 2.5, odds: null, timestamp: NOW }, line: 2.5 }],
      oddsProps: [prop(1.5)],
      analyzeResult: { grade: null, insufficient_data: true },
    });
    const res = await runIntradayRefresh('mlb', deps);
    expect(res.value).toBe(1);
    expect(store.get('snapshot:mlb:latest').grades[0].grade).toBe('A');
  });

  test('prop gone from the feed → frozen untouched; close recaptured every run', async () => {
    const { deps, store, calls } = harness({ grades: [GRADE], oddsProps: [prop(0.5)], analyzeResult: null });
    const res = await runIntradayRefresh('mlb', deps);
    expect(res.checked).toBe(1);
    expect(store.get('snapshot:mlb:latest').grades[0].movement).toBeNull(); // <0.5 noise
    expect(calls.closing).toBe(1); // refresh-fidelity closing capture
  });

  test('no snapshot → honest skip, zero quota spent', async () => {
    const calls = { odds: 0 };
    const res = await runIntradayRefresh('mlb', {
      cacheGet: async () => null,
      cacheSet: async () => {},
      getOdds: async () => { calls.odds += 1; return { props: [] }; },
      ledger: {}, pushTickerItems: async () => {}, now: () => NOW,
    });
    expect(res.status).toBe('skipped');
    expect(calls.odds).toBe(0);
  });
});

// S6 (A1 board) — sparkline fuel: {t, line} history capture on each grade's
// movement tracking, persisted inside the snapshot write-back (no new keys).
describe('trackHistory — real {t, line} points, seeded, deduped, capped', () => {
  const LOCK_TS = '2026-07-11T14:00:00.000Z';
  const g = (extra = {}) => ({ ...GRADE, gradedAt: { line: 0.5, odds: -115, timestamp: LOCK_TS }, ...extra });

  test('first capture seeds the LOCKED line at its own timestamp, then the move', () => {
    const h = trackHistory(g(), 1.5, NOW);
    expect(h).toEqual([{ t: LOCK_TS, line: 0.5 }, { t: NOW, line: 1.5 }]);
  });

  test('a flat line never appends a duplicate point (a point means it MOVED)', () => {
    const h = trackHistory(g(), 0.5, NOW); // current == locked
    expect(h).toEqual([{ t: LOCK_TS, line: 0.5 }]);
    const again = trackHistory(g({ history: h }), 0.5, '2026-07-11T21:00:00.000Z');
    expect(again).toHaveLength(1);
  });

  test('an unparseable current line appends nothing (real points only)', () => {
    expect(trackHistory(g(), null, NOW)).toBeUndefined();
    const prior = [{ t: LOCK_TS, line: 0.5 }];
    expect(trackHistory(g({ history: prior }), 'n/a', NOW)).toBe(prior);
  });

  test('capped at 24 points, newest kept', () => {
    const prior = Array.from({ length: HISTORY_CAP }, (_, i) => ({ t: `t${i}`, line: i }));
    const h = trackHistory(g({ history: prior }), 99, NOW);
    expect(h).toHaveLength(HISTORY_CAP);
    expect(h[HISTORY_CAP - 1]).toEqual({ t: NOW, line: 99 });
    expect(h[0]).toEqual({ t: 't1', line: 1 }); // oldest point aged out
  });
});

describe('runIntradayRefresh — history rides the snapshot write-back', () => {
  test('a steam move persists history on the grade (locked point + current)', async () => {
    const { deps, store } = harness({ grades: [GRADE], oddsProps: [prop(1.5)], analyzeResult: null });
    await runIntradayRefresh('mlb', deps);
    const snap = store.get('snapshot:mlb:latest');
    expect(snap.grades[0].history).toEqual([
      { t: NOW, line: 0.5 }, // seeded from the lock (gradedAt.timestamp === NOW here)
      { t: NOW, line: 1.5 },
    ]);
    // grades:{sport} carries the same enriched grades.
    expect(store.get('grades:mlb').grades[0].history).toHaveLength(2);
  });

  test('a flat run keeps a single seeded point — the sparkline stays hidden (<3)', async () => {
    const { deps, store } = harness({ grades: [GRADE], oddsProps: [prop(0.5)], analyzeResult: null });
    await runIntradayRefresh('mlb', deps);
    expect(store.get('snapshot:mlb:latest').grades[0].history).toEqual([{ t: NOW, line: 0.5 }]);
  });

  test('a prop gone from the feed leaves prior history untouched', async () => {
    const withHist = { ...GRADE, stat_type: 'hits', history: [{ t: 'x', line: 1 }] };
    const { deps, store } = harness({ grades: [withHist], oddsProps: [prop(1.5)], analyzeResult: null });
    await runIntradayRefresh('mlb', deps);
    const frozen = store.get('snapshot:mlb:latest').grades.find((g) => g.stat_type === 'hits');
    expect(frozen.history).toEqual([{ t: 'x', line: 1 }]);
  });
});

describe('inSlateHours — noon–midnight ET', () => {
  test('3 PM ET yes, 4 AM ET no', () => {
    expect(inSlateHours(new Date('2026-07-11T19:00:00Z'))).toBe(true);  // 3 PM EDT
    expect(inSlateHours(new Date('2026-07-11T08:00:00Z'))).toBe(false); // 4 AM EDT
  });
});
