/**
 * Session 64 — model_snapshots retention.
 *
 * The point of this store is that it captures what the ledger throws away:
 * the model's INPUTS, the pre-collapse grade, and REFUSALS. These lock that.
 */

const retention = require('../../src/services/retentionService');

const CTX = {
  snapshotId: '00000000-0000-4000-8000-000000000001',
  capturedAt: '2026-07-20T03:00:00.000Z',
  cycleHourUtc: 3,
  sport: 'mlb',
  gameDate: '2026-07-19',
  gameIdFor: () => 'mlb:2026-07-19:NYY@BOS',
};

const base = {
  player: 'José Ramírez', stat_type: 'hits', line: 0.5,
  over_odds: -140, under_odds: 115, book: 'fanduel',
};

const graded = {
  player: 'José Ramírez', stat_type: 'hits', line: 0.5, direction: 'over',
  grade: 'B', _grade_11: 'B-', confidence: 57, confidence_basis: 'grade_band',
  p_win: 0.61, ev_pct: 4.6, model_odds: -156, projection: 0.9, edge_pct: 20,
  takeable: true, value: true, book_odds: -140, fair_odds: -125,
  fair_prob: 0.556, overround: 0.041, devig_method: 'multiplicative',
  _features: { l5_avg: 0.8, l20_avg: 0.7, rest_days: 1 },
};

const refused = {
  player: 'José Ramírez', stat_type: 'hits', line: 0.5, direction: 'under',
  grade: null, insufficient_data: true, suppressed: true,
  suppressed_reason: 'juiced_no_edge',
};

describe('rowsFromSides', () => {
  test('captures the feature vector — the counterfactual enabler', () => {
    const [row] = retention.rowsFromSides(base, [graded], CTX);
    expect(row.features).toEqual({ l5_avg: 0.8, l20_avg: 0.7, rest_days: 1 });
  });

  test('captures the PRE-COLLAPSE 11-step grade, not just the 4-letter', () => {
    const [row] = retention.rowsFromSides(base, [graded], CTX);
    expect(row.grade).toBe('B');
    expect(row.grade_11).toBe('B-');
  });

  test('REFUSALS are stored with their reason — the ledger drops these', () => {
    const rows = retention.rowsFromSides(base, [refused], CTX);
    expect(rows).toHaveLength(1);
    expect(rows[0].refused).toBe(true);
    expect(rows[0].refusal_reason).toBe('juiced_no_edge');
    expect(rows[0].grade).toBeNull();
  });

  test('a refusal with no features stores null, never a fabricated {}', () => {
    const [row] = retention.rowsFromSides(base, [refused], CTX);
    expect(row.features).toBeNull();
  });

  test('both sides of one prop are captured (graded AND refused)', () => {
    const rows = retention.rowsFromSides(base, [graded, refused], CTX);
    expect(rows).toHaveLength(2);
    expect(rows.map((r) => r.side).sort()).toEqual(['over', 'under']);
  });

  test('every row is stamped with a model version — eras must never mix', () => {
    const [row] = retention.rowsFromSides(base, [graded], CTX);
    expect(row.model_version).toBe(retention.MODEL_VERSION);
    expect(row.model_version).toBeTruthy();
  });

  test('player name is normalized and keyed like the ledger', () => {
    const [row] = retention.rowsFromSides(base, [graded], CTX);
    expect(row.player_key).toBe('jose ramirez');
    expect(row.player_name).toBe('José Ramírez');
  });

  test('market values carry through; absent stays absent (no Number(null)=0)', () => {
    const [row] = retention.rowsFromSides(base, [{ ...graded, book_odds: null, ev_pct: null }], CTX);
    expect(row.book_odds).toBeNull();
    expect(row.ev_pct).toBeNull();
    expect(row.over_odds).toBe(-140);   // from the prop
  });

  test('a side inherits identity from the prop it was graded from', () => {
    // A refusal often carries no echoed identity — base supplies it. This is
    // correct, not junk: the prop is what we refused.
    const [row] = retention.rowsFromSides(base, [{ direction: 'over' }], CTX);
    expect(row.player_key).toBe('jose ramirez');
    expect(row.refused).toBe(true);
    expect(row.refusal_reason).toBe('no_grade');
  });

  test('drops rows with no usable identity ANYWHERE rather than writing junk', () => {
    expect(retention.rowsFromSides({}, [{ direction: 'over' }], CTX)).toHaveLength(0);
    expect(retention.rowsFromSides({}, [{ player: 'X', direction: 'over' }], CTX)).toHaveLength(0); // no stat/line
    expect(retention.rowsFromSides(base, [null, undefined], CTX)).toHaveLength(0);
    expect(retention.rowsFromSides(base, [{ ...graded, direction: 'sideways' }], CTX)).toHaveLength(0);
  });
});

describe('createCollector', () => {
  test('accumulates across props and never throws into the grader', () => {
    const c = retention.createCollector(CTX);
    c.onGraded(base, [graded, refused]);
    c.onGraded(base, [graded]);
    expect(c.rows).toHaveLength(3);
    expect(() => c.onGraded(null, 'not-an-array')).not.toThrow();
  });
});

describe('persist — best-effort contract', () => {
  test('no rows → no-op, no error', async () => {
    expect(await retention.persist([])).toMatchObject({ attempted: 0, written: 0 });
  });

  test('no supabase client → SKIPPED, not an error (tests/local never write)', async () => {
    const r = await retention.persist([{ a: 1 }], { getClient: () => null });
    expect(r.skipped).toBe(true);
    expect(r.error).toBeNull();
  });

  test('a database error is captured and RETURNED, never thrown', async () => {
    const getClient = () => ({
      from: () => ({ upsert: async () => ({ error: { message: 'boom' } }) }),
    });
    const r = await retention.persist([{ a: 1 }], { getClient });
    expect(r.error).toBe('boom');
    expect(r.written).toBe(0);
  });

  test('a thrown client is captured, never propagated (must not break a snapshot)', async () => {
    const getClient = () => { throw new Error('no client'); };
    await expect(retention.persist([{ a: 1 }], { getClient })).resolves.toMatchObject({ error: 'no client' });
  });
});

describe('mergeEnrichment (archetype/team/opponent were always null)', () => {
  const rows = [
    { player_key: 'jose ramirez', side: 'over', archetype: null, team: null, opponent: null,
      features: { l5_avg: 0.8 }, grade: 'B', p_win: 0.61 },
    { player_key: 'jose ramirez', side: 'under', archetype: null, team: null, opponent: null,
      features: null, grade: null, refused: true },
    { player_key: 'nobody here', side: 'over', archetype: null, team: null, opponent: null },
  ];
  const enriched = [{ player: 'José Ramírez', archetype: 'TORCH', team: 'CLE', opponent: 'NYY' }];

  test('fills archetype/team/opponent from the enriched slate', () => {
    const [a] = retention.mergeEnrichment(rows, enriched);
    expect(a.archetype).toBe('TORCH');
    expect(a.team).toBe('CLE');
    expect(a.opponent).toBe('NYY');
  });

  test('NEVER mutates grade-time features or model output', () => {
    const out = retention.mergeEnrichment(rows, enriched);
    expect(out[0].features).toEqual({ l5_avg: 0.8 });
    expect(out[0].grade).toBe('B');
    expect(out[0].p_win).toBe(0.61);
    // original array untouched (pure)
    expect(rows[0].archetype).toBeNull();
  });

  test('refusals for a matched player still get team context', () => {
    const out = retention.mergeEnrichment(rows, enriched);
    expect(out[1].team).toBe('CLE');
    expect(out[1].features).toBeNull();   // still honestly absent
  });

  test('unmatched rows stay null — never guessed', () => {
    const out = retention.mergeEnrichment(rows, enriched);
    expect(out[2].archetype).toBeNull();
    expect(out[2].team).toBeNull();
  });

  test('empty/absent enrichment is a safe no-op', () => {
    expect(retention.mergeEnrichment(rows, [])).toHaveLength(3);
    expect(retention.mergeEnrichment([], enriched)).toEqual([]);
  });
});
