// A1 S9 — Slip Reader parser suite. PURE parsers on realistic OCR-text
// fixtures per book. The contract under test: correct extraction on the
// rigid slip grammars, per-field confidence, and the NEVER-GUESS rule
// (unreadable → null + needs_review, never a fabricated value).

const fs = require('fs');
const path = require('path');
const slipReader = require('../../src/services/slipReader');
const { parseSlipText, detectBook, CONFIDENCE_THRESHOLD } = slipReader;
const { normalizeStat, splitPlayerStat, parseOdds, buildLeg } = slipReader.__internals;

const fixture = (book) =>
  fs.readFileSync(path.join(__dirname, '..', 'fixtures', 'slips', `${book}.txt`), 'utf8');

describe('slipReader — book detection', () => {
  it.each([
    ['draftkings'], ['fanduel'], ['betmgm'], ['caesars'],
  ])('detects %s from its fixture', (book) => {
    expect(detectBook(fixture(book))).toBe(book);
  });

  it('returns null for unbranded text', () => {
    expect(detectBook('Aaron Judge Over 1.5 Total Bases')).toBeNull();
  });
});

describe('slipReader — DraftKings layout', () => {
  const result = parseSlipText(fixture('draftkings'));

  it('extracts all three legs completely', () => {
    expect(result.book).toBe('draftkings');
    expect(result.legs).toHaveLength(3);
    expect(result.needs_review).toBe(false);
    expect(result.source).toBe('user_slip');
  });

  it('extracts player/stat/line/side/odds on leg 1', () => {
    const leg = result.legs[0];
    expect(leg.player).toBe('Aaron Judge');
    expect(leg.player_key).toBe('aaron judge');
    expect(leg.stat).toBe('total_bases');
    expect(leg.line).toBe(1.5);
    expect(leg.side).toBe('over');
    expect(leg.odds).toBe(-115);
    expect(leg.needs_review).toBe(false);
  });

  it('normalizes book market labels through the stat vocabulary', () => {
    // "Strikeouts Thrown" (DK pitcher label) → strikeouts
    expect(result.legs[1].stat).toBe('strikeouts');
    expect(result.legs[1].line).toBe(7.5);
    // plus-odds leg
    expect(result.legs[2].stat).toBe('home_runs');
    expect(result.legs[2].odds).toBe(320);
  });

  it('carries per-field confidences at or above threshold on clean legs', () => {
    for (const leg of result.legs) {
      for (const field of ['player', 'stat', 'line', 'side', 'odds']) {
        expect(leg.confidence[field]).toBeGreaterThanOrEqual(CONFIDENCE_THRESHOLD);
      }
    }
  });
});

describe('slipReader — FanDuel layout', () => {
  const result = parseSlipText(fixture('fanduel'));

  it('parses To Record N+ / Any Time / Over grammars', () => {
    expect(result.book).toBe('fanduel');
    expect(result.legs).toHaveLength(3);
    // "To Record 2+ Total Bases" → line 1.5 over
    expect(result.legs[0]).toMatchObject({ player: 'Aaron Judge', stat: 'total_bases', line: 1.5, side: 'over' });
    // "Any Time Home Run" → home_runs 0.5 over
    expect(result.legs[1]).toMatchObject({ player: 'Giancarlo Stanton', stat: 'home_runs', line: 0.5, side: 'over' });
    // plain Over grammar
    expect(result.legs[2]).toMatchObject({ player: 'Gerrit Cole', stat: 'strikeouts', line: 6.5, side: 'over' });
  });

  it('SGP legs without per-leg odds → odds null + needs_review (never guessed)', () => {
    for (const leg of result.legs) {
      expect(leg.odds).toBeNull();
      expect(leg.confidence.odds).toBeLessThan(CONFIDENCE_THRESHOLD);
      expect(leg.needs_review).toBe(true);
    }
    expect(result.needs_review).toBe(true);
  });
});

describe('slipReader — BetMGM layout', () => {
  const result = parseSlipText(fixture('betmgm'));

  it('parses inline "@ odds" and trailing-odds legs', () => {
    expect(result.book).toBe('betmgm');
    expect(result.legs).toHaveLength(3);
    expect(result.legs[0]).toMatchObject({ player: 'Aaron Judge', stat: 'total_bases', line: 1.5, side: 'over', odds: -115 });
    expect(result.legs[1]).toMatchObject({ player: 'Shohei Ohtani', stat: 'strikeouts', line: 7.5, side: 'under', odds: 105 });
    expect(result.legs[2]).toMatchObject({ player: 'Mookie Betts', stat: 'hits', line: 1.5, side: 'over', odds: 140 });
    expect(result.needs_review).toBe(false);
  });
});

describe('slipReader — Caesars layout', () => {
  const result = parseSlipText(fixture('caesars'));

  it('splits the ambiguous player/stat head on known stat aliases', () => {
    expect(result.book).toBe('caesars');
    expect(result.legs).toHaveLength(3);
    // "Pete Alonso Home Runs" must NOT become player "Pete Alonso Home" + stat "runs"
    expect(result.legs[1]).toMatchObject({ player: 'Pete Alonso', stat: 'home_runs', line: 0.5, side: 'over', odds: 340 });
    expect(result.legs[0]).toMatchObject({ player: 'Aaron Judge', stat: 'total_bases', odds: -115 });
    expect(result.legs[2]).toMatchObject({ player: 'Freddie Freeman', stat: 'hits', side: 'under', odds: -105 });
  });

  it('unknown stat head → stat AND player null (boundary unknowable), needs_review', () => {
    const r = parseSlipText('Caesars\nAaron Judge Fantasy Score Over 32.5 (-115)');
    expect(r.legs).toHaveLength(1);
    expect(r.legs[0].stat).toBeNull();
    expect(r.legs[0].player).toBeNull(); // boundary uncertain → below threshold → nulled
    expect(r.legs[0].needs_review).toBe(true);
    // The readable fields still come through — nothing over-nulled.
    expect(r.legs[0].line).toBe(32.5);
    expect(r.legs[0].odds).toBe(-115);
  });
});

describe('slipReader — never guess', () => {
  it('garbage text → zero legs, needs_review envelope, no fabrication', () => {
    const r = parseSlipText('completely unrelated text\nnothing to see 12345\nlorem ipsum');
    expect(r.legs).toEqual([]);
    expect(r.needs_review).toBe(true);
    expect(r.book).toBeNull();
  });

  it('empty/nullish input is safe', () => {
    expect(parseSlipText('').legs).toEqual([]);
    expect(parseSlipText(null).legs).toEqual([]);
    expect(parseSlipText(undefined).legs).toEqual([]);
  });

  it('a below-threshold field is nulled, not passed through', () => {
    const leg = buildLeg({
      player: 'judge', // lowercase single token → low confidence
      statLabel: 'Total Bases',
      line: 1.5,
      side: 'over',
      odds: -115,
    });
    expect(leg.player).toBeNull();
    expect(leg.player_key).toBeNull();
    expect(leg.needs_review).toBe(true);
    expect(leg.stat).toBe('total_bases'); // readable fields survive
  });

  it('book hint routes to the hinted layout parser', () => {
    const dk = fixture('draftkings');
    const hinted = parseSlipText(dk, 'draftkings');
    expect(hinted.book).toBe('draftkings');
    expect(hinted.legs).toHaveLength(3);
  });

  it('unknown book → best layout wins without inventing values', () => {
    const unbranded = fixture('draftkings').replace(/DraftKings Sportsbook\n/, '');
    const r = parseSlipText(unbranded);
    expect(r.legs.length).toBeGreaterThanOrEqual(3);
    for (const leg of r.legs) {
      expect(leg.player).not.toBeNull();
      expect(leg.stat).not.toBeNull();
    }
  });
});

describe('slipReader — vocabulary + helpers', () => {
  it('normalizeStat maps slip labels to canonical stat_types', () => {
    expect(normalizeStat('Total Bases')).toBe('total_bases');
    expect(normalizeStat('Strikeouts Thrown')).toBe('strikeouts');
    expect(normalizeStat('Alt Total Bases')).toBe('total_bases');
    expect(normalizeStat('Total Bases O/U')).toBe('total_bases');
    expect(normalizeStat('Pts + Reb + Ast')).toBe('pra');
    expect(normalizeStat('3 Pointers Made')).toBe('threes');
    expect(normalizeStat('Runs Batted In')).toBe('rbi');
    expect(normalizeStat('Fantasy Score')).toBeNull(); // unknown → null, never a guess
  });

  it('canonical stat_types match the scan-route vocabulary exactly', () => {
    // Mirror of VALID_STAT_TYPES in src/routes/scan.js — a drifted alias
    // here would 400 at the grade gate.
    const VALID = new Set([
      'points', 'rebounds', 'assists', 'threes', 'blocks', 'steals', 'pra', 'turnovers',
      'goals', 'shots_on_target', 'shots', 'tackles', 'cards', 'corners', 'saves',
      'goals_conceded', 'passes', 'clean_sheet',
      'strikeouts', 'hits', 'home_runs', 'rbi', 'total_bases', 'walks', 'runs',
      'earned_runs', 'innings_pitched', 'hits_allowed', 'stolen_bases', 'doubles', 'outs',
    ]);
    for (const stat of Object.values(slipReader.__internals.STAT_ALIASES)) {
      expect(VALID.has(stat)).toBe(true);
    }
  });

  it('splitPlayerStat prefers the longest alias suffix', () => {
    expect(splitPlayerStat('Pete Alonso Home Runs')).toMatchObject({ player: 'Pete Alonso', statLabel: 'Home Runs' });
    expect(splitPlayerStat('Aaron Judge Total Bases')).toMatchObject({ player: 'Aaron Judge', statLabel: 'Total Bases' });
    expect(splitPlayerStat('Aaron Judge Alt Total Bases')).toMatchObject({ player: 'Aaron Judge', statLabel: 'Total Bases' });
  });

  it('parseOdds normalizes OCR minus glyphs and rejects non-odds', () => {
    expect(parseOdds('-115')).toBe(-115);
    expect(parseOdds('−115')).toBe(-115); // unicode minus
    expect(parseOdds('+320')).toBe(320);
    expect(parseOdds('-15')).toBeNull(); // |odds| < 100 is a line, not odds
    expect(parseOdds('banana')).toBeNull();
    expect(parseOdds(null)).toBeNull();
  });

  it('player names normalize through playerName (display + key)', () => {
    const r = parseSlipText('DraftKings\nA.J. Ewing Over 1.5\nTotal Bases\n-115');
    expect(r.legs[0].player).toBe('AJ Ewing');
    expect(r.legs[0].player_key).toBe('aj ewing');
  });
});
