'use strict';

// Session S7 (a1) — THE VYNDR REPORT assembly + send. Everything injected:
// fixtures stand in for the snapshot cache, the streaks engine inputs, and
// the ledger aggregate. No network, no Redis.

const {
  buildDailyReport,
  sendDailyReport,
  subscribe,
  __internals,
} = require('../../src/services/newsletterService');

// ---------------------------------------------------------------- fixtures

const SNAPSHOT_MLB = {
  sport: 'mlb',
  updated_at: '2026-07-11T14:00:00Z',
  grades: [
    { player: 'Aaron Judge', stat_type: 'total_bases', line: 1.5, direction: 'over', grade: 'A+', confidence: 82, archetype: 'BOMBER' },
    { player: 'Mookie Betts', stat_type: 'hits', line: 1.5, direction: 'over', grade: 'B', confidence: 64 },
    { player: 'Luis Castillo', stat_type: 'pitcher_strikeouts', line: 6.5, direction: 'under', grade: 'A', confidence: 71, archetype: 'WHIFF' },
  ],
  deltas: [],
};

const ROSTER_MLB = [
  {
    name: 'Seiya Suzuki',
    team: 'Chicago Cubs',
    games: [
      { date: '2026-07-10', hits: 2, opponent: 'STL' },
      { date: '2026-07-09', hits: 1, opponent: 'STL' },
      { date: '2026-07-08', hits: 3, opponent: 'PIT' },
      { date: '2026-07-07', hits: 1, opponent: 'PIT' },
      { date: '2026-07-06', hits: 2, opponent: 'CIN' },
      { date: '2026-07-05', hits: 0, opponent: 'CIN' },
    ],
  },
];

const AGG_BUILDING = { window_days: 30, min_sample: 20, settled: 4, hits: 3, misses: 1, pushes: 0, hit_pct: null, pending: 17, beat_close_pct: null };
const AGG_REAL = { window_days: 30, min_sample: 20, settled: 25, hits: 15, misses: 9, pushes: 1, hit_pct: 63, pending: 6, beat_close_pct: 58 };

function deps(overrides = {}) {
  return {
    cacheGet: async (key) => (key === 'snapshot:mlb:latest' ? SNAPSHOT_MLB : null),
    loadRosterLogs: async (sport) => (sport === 'mlb' ? ROSTER_MLB : []),
    computeStreaks: require('../../src/services/streaksService').computeStreaks,
    applyLens: require('../../src/services/streakLens').applyLens,
    getModelAggregate: async () => AGG_BUILDING,
    now: () => new Date('2026-07-11T15:00:00Z'),
    ...overrides,
  };
}

// VOICE v1.1 lint — the banned list the templates must never emit.
// "lock"/"tail"/"fade" as words (not substrings — "blocked"/"detail" are fine).
const BANNED_WORDS = /\b(lock|locks|tail|tails|fade|fades|guarantee[ds]?|free money|can't lose)\b/i;
const HYPE_EMOJI = /[\u{1F525}\u{1F4B0}\u{1F680}\u{1F4AF}\u{1F512}]/u;

describe('buildDailyReport — VOICE lint', () => {
  let report;
  beforeAll(async () => {
    report = await buildDailyReport(['mlb'], deps());
  });

  test('no exclamation points anywhere — subject, html, text', () => {
    expect(report.subject).not.toContain('!');
    expect(report.text).not.toContain('!');
    // html may contain !important? we never use it — assert none at all
    expect(report.html).not.toContain('!');
  });

  test('no banned vocabulary, no hype emoji', () => {
    for (const part of [report.subject, report.text, report.html]) {
      expect(part).not.toMatch(BANNED_WORDS);
      expect(part).not.toMatch(HYPE_EMOJI);
    }
  });

  test('subject carries the pipeline signal count and the ET date', () => {
    expect(report.subject).toBe('THE VYNDR REPORT — Sat, Jul 11 · 3 signals');
  });

  test('signals come only from the snapshot fixture, o/u for sides', () => {
    expect(report.text).toContain('Aaron Judge — total bases o1.5 · A+ · BOMBER');
    expect(report.text).toContain('Luis Castillo — pitcher strikeouts u6.5 · A · WHIFF');
    expect(report.counts.signals).toBe(3);
  });

  test('A+ sorts ahead of B regardless of array order', () => {
    const judge = report.text.indexOf('Aaron Judge');
    const betts = report.text.indexOf('Mookie Betts');
    expect(judge).toBeGreaterThan(-1);
    expect(betts).toBeGreaterThan(judge);
  });

  test('STREAK WATCH renders the real computed streak through the lens', () => {
    expect(report.text).toContain('STREAK WATCH');
    expect(report.text).toContain('Seiya Suzuki');
    // Lens: built-vs opponents from the game log — real, not composed.
    expect(report.text).toMatch(/built vs/);
    expect(report.counts.streaks).toBeGreaterThan(0);
  });

  test('RG footer present in html AND text: 21+, 1-800-GAMBLER, unsubscribe', () => {
    for (const part of [report.text, report.html]) {
      expect(part).toContain('21+');
      expect(part).toContain('1-800-GAMBLER');
      expect(part).toContain('{{ UnsubscribeURL }}');
    }
  });
});

describe('buildDailyReport — the record gate (n≥20 lives upstream)', () => {
  test('hit_pct null → RECORD BUILDING with the pending count, never a %', async () => {
    const report = await buildDailyReport(['mlb'], deps());
    expect(report.text).toContain('RECORD BUILDING · 17 pending');
    expect(report.text).not.toMatch(/\d+%.*misses included/);
  });

  test('hit_pct present → the real record line, misses included', async () => {
    const report = await buildDailyReport(['mlb'], deps({ getModelAggregate: async () => AGG_REAL }));
    expect(report.text).toContain('Last 30 days: 15-9 (63%), misses included, n=24');
    expect(report.text).toContain('beat the close 58%');
    expect(report.text).not.toContain('RECORD BUILDING');
  });

  test('aggregate throws → building line, never a crash', async () => {
    const report = await buildDailyReport(['mlb'], deps({ getModelAggregate: async () => { throw new Error('supabase down'); } }));
    expect(report.text).toContain('RECORD BUILDING · 0 pending');
  });
});

describe('buildDailyReport — empty pipeline is a valid state', () => {
  test('no snapshot, no roster → honest empty wire, zero counts', async () => {
    const report = await buildDailyReport(['mlb'], deps({
      cacheGet: async () => null,
      loadRosterLogs: async () => [],
    }));
    expect(report.counts.signals).toBe(0);
    expect(report.counts.streaks).toBe(0);
    expect(report.text).toContain('No graded slate at send time');
    expect(report.subject).toBe('THE VYNDR REPORT — Sat, Jul 11'); // no invented number
    // Compliance footer still present even on an empty report.
    expect(report.text).toContain('1-800-GAMBLER');
  });
});

describe('sendDailyReport — env-gated, empty-refusing', () => {
  const ENV = {
    LISTMONK_URL: 'http://127.0.0.1:9000',
    LISTMONK_USER: 'vyndr-api',
    LISTMONK_TOKEN: 'tok123',
    LISTMONK_LIST_ID: '3',
  };

  test('no env → graceful no-op, no fetch', async () => {
    const fetchImpl = jest.fn();
    const res = await sendDailyReport({ env: {}, fetchImpl, deps: deps() });
    expect(res).toEqual({ ok: false, reason: 'not configured' });
    expect(fetchImpl).not.toHaveBeenCalled();
  });

  test('empty report → refuses to send even with env set', async () => {
    const fetchImpl = jest.fn();
    const res = await sendDailyReport({
      env: ENV, fetchImpl,
      deps: deps({ cacheGet: async () => null, loadRosterLogs: async () => [] }),
    });
    expect(res.ok).toBe(false);
    expect(res.reason).toBe('empty report');
    expect(fetchImpl).not.toHaveBeenCalled();
  });

  test('creates then starts the campaign with the token auth scheme', async () => {
    const calls = [];
    const fetchImpl = jest.fn(async (url, opts) => {
      calls.push({ url, opts });
      if (url.endsWith('/api/campaigns')) {
        return { ok: true, status: 200, json: async () => ({ data: { id: 42 } }) };
      }
      return { ok: true, status: 200, json: async () => ({}) };
    });
    const res = await sendDailyReport({ env: ENV, fetchImpl, deps: deps() });
    expect(res.ok).toBe(true);
    expect(res.campaignId).toBe(42);

    const create = calls[0];
    expect(create.url).toBe('http://127.0.0.1:9000/api/campaigns');
    expect(create.opts.headers.Authorization).toBe('token vyndr-api:tok123');
    const body = JSON.parse(create.opts.body);
    expect(body.lists).toEqual([3]);
    expect(body.subject).toContain('THE VYNDR REPORT');
    expect(body.body).toContain('{{ UnsubscribeURL }}');
    expect(body.altbody).toContain('1-800-GAMBLER');

    const start = calls[1];
    expect(start.url).toBe('http://127.0.0.1:9000/api/campaigns/42/status');
    expect(JSON.parse(start.opts.body)).toEqual({ status: 'running' });
  });

  test('listmonk create failure → honest error, no start call', async () => {
    const fetchImpl = jest.fn(async () => ({ ok: false, status: 500, json: async () => ({}) }));
    const res = await sendDailyReport({ env: ENV, fetchImpl, deps: deps() });
    expect(res.ok).toBe(false);
    expect(res.reason).toBe('listmonk create 500');
    expect(fetchImpl).toHaveBeenCalledTimes(1);
  });
});

describe('subscribe — double opt-in forwarding', () => {
  const ENV = {
    LISTMONK_URL: 'http://127.0.0.1:9000/',
    LISTMONK_USER: 'vyndr-api',
    LISTMONK_TOKEN: 'tok123',
    LISTMONK_LIST_ID: '3',
  };

  test('no env → not configured, no fetch', async () => {
    const fetchImpl = jest.fn();
    const res = await subscribe('kev@example.com', { env: {}, fetchImpl });
    expect(res).toEqual({ ok: false, reason: 'not configured' });
    expect(fetchImpl).not.toHaveBeenCalled();
  });

  test('forwards with preconfirm_subscriptions:false (Listmonk sends the confirmation)', async () => {
    const fetchImpl = jest.fn(async () => ({ ok: true, status: 200, json: async () => ({}) }));
    const res = await subscribe('KEV@Example.com ', { env: ENV, fetchImpl });
    expect(res).toEqual({ ok: true });
    const [url, opts] = fetchImpl.mock.calls[0];
    expect(url).toBe('http://127.0.0.1:9000/api/subscribers'); // trailing slash trimmed
    const body = JSON.parse(opts.body);
    expect(body.email).toBe('kev@example.com');
    expect(body.preconfirm_subscriptions).toBe(false);
    expect(body.lists).toEqual([3]);
    expect(body.status).toBe('enabled');
  });

  test('409 already-subscribed is ok (idempotent, no enumeration)', async () => {
    const fetchImpl = jest.fn(async () => ({ ok: false, status: 409, json: async () => ({}) }));
    const res = await subscribe('kev@example.com', { env: ENV, fetchImpl });
    expect(res).toEqual({ ok: true });
  });

  test('network failure → calm reason, never throws', async () => {
    const fetchImpl = jest.fn(async () => { throw new Error('ECONNREFUSED'); });
    const res = await subscribe('kev@example.com', { env: ENV, fetchImpl });
    expect(res.ok).toBe(false);
    expect(res.reason).toBe('ECONNREFUSED');
  });
});

describe('__internals', () => {
  test('listmonkConfig requires all four env vars', () => {
    expect(__internals.listmonkConfig({})).toBeNull();
    expect(__internals.listmonkConfig({ LISTMONK_URL: 'x', LISTMONK_USER: 'u', LISTMONK_TOKEN: 't' })).toBeNull();
    expect(__internals.listmonkConfig({ LISTMONK_URL: 'x', LISTMONK_USER: 'u', LISTMONK_TOKEN: 't', LISTMONK_LIST_ID: 'nope' })).toBeNull();
    expect(__internals.listmonkConfig({ LISTMONK_URL: 'http://x/', LISTMONK_USER: 'u', LISTMONK_TOKEN: 't', LISTMONK_LIST_ID: '7' }))
      .toEqual({ url: 'http://x', user: 'u', token: 't', listId: 7 });
  });

  test('recordLine gates on hit_pct, not on settled count', () => {
    expect(__internals.recordLine(null)).toBe('RECORD BUILDING · 0 pending');
    expect(__internals.recordLine({ hit_pct: null, pending: 12 })).toBe('RECORD BUILDING · 12 pending');
    expect(__internals.recordLine({ hit_pct: 61, hits: 14, misses: 9, window_days: 30, beat_close_pct: null }))
      .toBe('Last 30 days: 14-9 (61%), misses included, n=23');
  });

  test('statLabel humanizes stat keys', () => {
    expect(__internals.statLabel('total_bases')).toBe('total bases');
    expect(__internals.statLabel('PITCHER_STRIKEOUTS')).toBe('pitcher strikeouts');
  });
});
