/* ============================================================
   Session 67 — the model price must never leave the server for an unentitled
   viewer, and the FAIR leg must never be gated.

   `GET /api/snapshot/:sport` is PUBLIC. Session 66 gated model_odds on
   /api/analyze; this endpoint bypassed that gate and served the model price
   (and p_win, and an invertible ev_pct) to anonymous callers on every graded
   row. These tests lock the fix shut.
   ============================================================ */

const { stripModelPrice, MODEL_FIELDS, MARKET_FIELDS, entitledToModelPrice } = require('../../src/utils/snapshotGating');
const { resolveTierFromRequest } = require('../../src/utils/requestTier');

// A real shape, from a live /api/snapshot/mlb row.
const ROW = Object.freeze({
  player_name: 'Josh Bell', stat_type: 'hits', line: 0.5, direction: 'over', grade: 'B',
  book_odds: -210, fair_odds: -173, fair_prob: 0.633, overround: 0.068, devig_method: 'multiplicative',
  model_odds: -311, p_win: 0.757, ev_pct: 11.7, value: false, takeable: false,
  projection: 0.8, archetype: 'DRIVER',
});

describe('stripModelPrice — free/anonymous never receive the model price', () => {
  it('removes EVERY model-derived field for free', () => {
    const [out] = stripModelPrice([ROW], 'free');
    for (const f of MODEL_FIELDS) expect(out[f]).toBeUndefined();
  });

  it('removes them for anonymous / unknown / garbage tiers (fails closed)', () => {
    for (const tier of [undefined, null, '', 'anonymous', 'nonsense', 'FREE ']) {
      const [out] = stripModelPrice([ROW], tier);
      expect(out.model_odds).toBeUndefined();
      expect(out.p_win).toBeUndefined();
    }
  });

  it('strips ev_pct too — it is INVERTIBLE back to p_win', () => {
    // ev is a function of (p_win, book_odds); book_odds is public, so leaving
    // ev behind hands over the model price in a different base.
    const [out] = stripModelPrice([ROW], 'free');
    expect(out.ev_pct).toBeUndefined();
    expect(MODEL_FIELDS).toContain('ev_pct');
  });

  it('KEEPS every market field — the fair leg is never the paywall', () => {
    const [out] = stripModelPrice([ROW], 'free');
    for (const f of MARKET_FIELDS) expect(out[f]).toBe(ROW[f]);
    expect(out.fair_odds).toBe(-173);
  });

  it('flags the lock so a gated price is not mistaken for a missing one', () => {
    const [out] = stripModelPrice([ROW], 'free');
    expect(out.model_price_locked).toBe(true);
  });

  it('does NOT flag a lock on a row with no price story to lock onto', () => {
    const [out] = stripModelPrice([{ ...ROW, book_odds: null, fair_odds: null }], 'free');
    expect(out.model_price_locked).toBeUndefined();
  });

  it('analyst and desk receive the full triplet, untouched', () => {
    for (const tier of ['analyst', 'desk']) {
      const [out] = stripModelPrice([ROW], tier);
      expect(out.model_odds).toBe(-311);
      expect(out.p_win).toBe(0.757);
      expect(out.ev_pct).toBe(11.7);
      expect(out.model_price_locked).toBeUndefined();
    }
    expect(entitledToModelPrice('analyst')).toBe(true);
    expect(entitledToModelPrice('free')).toBe(false);
  });

  it('never mutates the input rows', () => {
    const input = [{ ...ROW }];
    stripModelPrice(input, 'free');
    expect(input[0].model_odds).toBe(-311);
  });

  it('survives junk rows without throwing', () => {
    expect(() => stripModelPrice([null, undefined, 'x', 7], 'free')).not.toThrow();
    expect(stripModelPrice(null, 'free')).toBeNull();
  });
});

describe('resolveTierFromRequest — a public endpoint that still gates', () => {
  const noSupabase = { getSupabaseServiceClient: () => null };

  it('anonymous (no header) is free', async () => {
    expect(await resolveTierFromRequest({ headers: {} }, noSupabase)).toBe('free');
  });

  it('a malformed or empty bearer is free', async () => {
    expect(await resolveTierFromRequest({ headers: { authorization: 'Basic x' } }, noSupabase)).toBe('free');
    expect(await resolveTierFromRequest({ headers: { authorization: 'Bearer ' } }, noSupabase)).toBe('free');
  });

  it('FAILS CLOSED — a thrown resolver yields free, never an entitled tier', async () => {
    const boom = { getSupabaseServiceClient: () => { throw new Error('supabase down'); } };
    expect(await resolveTierFromRequest({ headers: { authorization: 'Bearer abc' } }, boom)).toBe('free');
  });

  it('an invalid token is free', async () => {
    const bad = {
      getSupabaseServiceClient: () => ({
        auth: { getUser: async () => ({ data: null, error: new Error('bad token') }) },
      }),
    };
    expect(await resolveTierFromRequest({ headers: { authorization: 'Bearer abc' } }, bad)).toBe('free');
  });

  it('a valid token resolves the real tier', async () => {
    const good = {
      getSupabaseServiceClient: () => ({
        auth: { getUser: async () => ({ data: { user: { id: 'u1' } }, error: null }) },
        from: () => ({ select: () => ({ eq: () => ({ single: async () => ({ data: { tier: 'desk' } }) }) }) }),
      }),
    };
    expect(await resolveTierFromRequest({ headers: { authorization: 'Bearer good' } }, good)).toBe('desk');
  });
});

describe('the route wiring', () => {
  const fs = require('fs');
  const path = require('path');
  const src = fs.readFileSync(path.join(__dirname, '..', '..', 'src', 'routes', 'snapshot.js'), 'utf8');

  it('gates BOTH response paths (snapshot and the grades fallback)', () => {
    const gated = src.match(/grades: gate\(/g) || [];
    expect(gated.length).toBe(2);
  });

  it('never shared-caches a response that varies by entitlement', () => {
    // A CDN handing a paid payload to an anonymous viewer would defeat the gate.
    expect(src).toMatch(/req\.headers\.authorization \? 'private, max-age=30' : 'public, max-age=30'/);
    // Scoped to the /:sport handler — /summary carries counts only (no price
    // data), so it stays legitimately public-cacheable.
    const bySport = src.slice(src.indexOf("router.get('/:sport'"));
    expect(bySport).not.toMatch(/res\.set\('Cache-Control', 'public, max-age=30'\)/);
  });

  it('the hero-prop route applies the SAME gate (it reads Redis directly)', () => {
    const hero = fs.readFileSync(path.join(__dirname, '..', '..', 'src', 'routes', 'heroProp.js'), 'utf8');
    // The hero bypassed routes/snapshot.js entirely, so it needed its own strip
    // or the landing page kept serving the model price to anonymous visitors.
    expect(hero).toContain('stripModelPrice');
    expect(hero).toContain('resolveTierFromRequest');
    expect(hero).not.toMatch(/res\.set\('Cache-Control', 'public, max-age=300'\);\n\s*return res\.json\(hero\)/);
  });

  it('the browser proxy forwards the bearer token', () => {
    const proxy = fs.readFileSync(
      path.join(__dirname, '..', '..', 'web', 'src', 'app', 'api', 'snapshot', '[sport]', 'route.ts'),
      'utf8',
    );
    expect(proxy).toMatch(/Authorization: auth/);
    expect(proxy).toMatch(/private, max-age=30/);
  });
});
