'use strict';

/**
 * A1 Session 9 — Slip Reader (zero-API OCR).
 *
 * Parses sportsbook bet-slip OCR text into legs the grading engine can
 * consume. Layout parsers are PURE (text in → legs out, no I/O) so they
 * unit-test on raw OCR-text fixtures; the tesseract.js image path is a
 * thin wrapper (`recognizeImage`) that lazy-requires the WASM engine.
 *
 * DATA SEMANTICS: everything extracted here is a USER-SLIP value — the
 * line/odds the user's book printed on their slip. It is never written
 * into any market cache and is labeled `source: 'user_slip'` upstream.
 *
 * NEVER GUESS: a field the parser cannot read above CONFIDENCE_THRESHOLD
 * is returned null and the leg carries `needs_review: true`. Absent
 * beats wrong (North Star operating rule).
 */

const { normalizeName, nameKey } = require('../utils/playerName');

const CONFIDENCE_THRESHOLD = 0.6;
const MAX_LEGS = 12; // mirrors the scan route's parlay cap

/* ── Stat vocabulary ─────────────────────────────────────────────
   Slip market labels → the canonical stat_type vocabulary shared by
   src/routes/scan.js VALID_STAT_TYPES + web gradeAdapter STAT_LABELS.
   Keys are lowercased, space-collapsed slip labels. */
const STAT_ALIASES = {
  // MLB batters
  'total bases': 'total_bases', 'tb': 'total_bases',
  'hits': 'hits', 'hit': 'hits',
  'home runs': 'home_runs', 'home run': 'home_runs', 'hr': 'home_runs',
  'any time home run': 'home_runs', 'anytime home run': 'home_runs',
  'to hit a home run': 'home_runs',
  'rbi': 'rbi', 'rbis': 'rbi', 'runs batted in': 'rbi',
  'runs': 'runs', 'runs scored': 'runs', 'run scored': 'runs',
  'stolen bases': 'stolen_bases', 'stolen base': 'stolen_bases',
  'doubles': 'doubles',
  'walks': 'walks', 'batter walks': 'walks',
  // MLB pitchers
  'strikeouts': 'strikeouts', 'strikeouts thrown': 'strikeouts',
  'pitcher strikeouts': 'strikeouts', 'ks': 'strikeouts', 'k s': 'strikeouts',
  'earned runs': 'earned_runs', 'earned runs allowed': 'earned_runs',
  'hits allowed': 'hits_allowed',
  'innings pitched': 'innings_pitched',
  'outs': 'outs', 'outs recorded': 'outs',
  // NBA / WNBA
  'points': 'points', 'pts': 'points',
  'rebounds': 'rebounds', 'reb': 'rebounds', 'rebs': 'rebounds',
  'assists': 'assists', 'ast': 'assists', 'asts': 'assists',
  'threes': 'threes', '3 pointers made': 'threes', 'three pointers made': 'threes',
  '3 pt made': 'threes', 'threes made': 'threes', 'made threes': 'threes', '3pm': 'threes',
  'blocks': 'blocks', 'blk': 'blocks',
  'steals': 'steals', 'stl': 'steals',
  'turnovers': 'turnovers', 'to': 'turnovers',
  'pts + reb + ast': 'pra', 'points + rebounds + assists': 'pra',
  'pts rebs asts': 'pra', 'pts reb ast': 'pra', 'pra': 'pra',
  // Soccer
  'goals': 'goals', 'goal': 'goals', 'any time goalscorer': 'goals',
  'anytime goalscorer': 'goals',
  'shots': 'shots', 'shots on target': 'shots_on_target',
  'shots on goal': 'shots_on_target',
  'tackles': 'tackles', 'cards': 'cards', 'corners': 'corners',
  'saves': 'saves', 'goalkeeper saves': 'saves',
  'goals conceded': 'goals_conceded', 'passes': 'passes',
  'clean sheet': 'clean_sheet',
};

// Display labels (mirror web/src/lib/gradeAdapter.js STAT_LABELS style).
function statLabel(stat) {
  if (!stat) return null;
  const special = { pra: 'P+R+A', rbi: 'RBI', threes: '3-Pointers' };
  if (special[stat]) return special[stat];
  return stat.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
}

/** Normalize a slip market label to the canonical stat_type, or null. */
function normalizeStat(rawLabel) {
  if (!rawLabel) return null;
  let s = String(rawLabel).toLowerCase()
    .replace(/[|]/g, '')
    .replace(/\balt\b/g, '')          // "Alt Total Bases" → "Total Bases"
    .replace(/\bo\/u\b/g, '')          // "Total Bases O/U"
    .replace(/[()]/g, ' ')
    .replace(/\s+/g, ' ')
    .trim();
  if (STAT_ALIASES[s]) return STAT_ALIASES[s];
  const underscored = s.replace(/ /g, '_');
  if (Object.values(STAT_ALIASES).includes(underscored)) return underscored;
  return null;
}

/* ── Shared token grammar ──────────────────────────────────────── */

// "Aaron Judge", "Ronald Acuña Jr.", "A.J. Ewing" — 2–3 capitalized tokens.
const NAME_PART = "[A-Z][A-Za-z'’.\\u00C0-\\u017F-]+";
const NAME_RE = new RegExp(
  `(${NAME_PART}(?:\\s${NAME_PART}){1,2}(?:\\s(?:Jr|Sr|II|III|IV)\\.?)?)`,
);

// Odds token: "+320", "-115", OCR unicode minus "−115" / dash "–115".
const ODDS_RE = /(?:^|[\s(@])([+\-−–]\s?\d{2,4})(?:[\s).]|$)/;

function parseOdds(token) {
  if (!token) return null;
  const n = parseInt(String(token).replace(/[−–]/, '-').replace(/\s/g, ''), 10);
  if (!Number.isFinite(n)) return null;
  if (Math.abs(n) < 100 || Math.abs(n) > 9999) return null; // not american odds
  return n;
}

function parseLine(token) {
  const n = Number(token);
  return Number.isFinite(n) && n >= 0 && n <= 500 ? n : null;
}

/** Confidence for an extracted player-name candidate. */
function nameConfidence(raw) {
  if (!raw) return 0;
  const parts = raw.trim().split(/\s+/);
  if (parts.length < 2) return 0.3;                 // single token — too weak
  const capitalized = parts.every((p) => /^[A-Z]/.test(p));
  return capitalized ? 0.9 : 0.5;
}

/** Build a finalized leg: normalize, threshold-null, flag needs_review. */
function buildLeg(fields) {
  const conf = {
    player: fields.player != null ? (fields.playerConfidence ?? nameConfidence(fields.player)) : 0,
    stat: 0,
    line: fields.line != null ? (fields.lineConfidence ?? 0.95) : 0,
    side: fields.side != null ? (fields.sideConfidence ?? 0.95) : 0,
    odds: fields.odds != null ? (fields.oddsConfidence ?? 0.9) : 0,
  };

  const stat = normalizeStat(fields.statLabel);
  if (stat) conf.stat = 0.95;

  const playerOk = conf.player >= CONFIDENCE_THRESHOLD;
  const norm = playerOk ? normalizeName(fields.player) : null;

  const leg = {
    player: playerOk ? norm.display : null,
    player_key: playerOk ? nameKey(fields.player) : null,
    stat: conf.stat >= CONFIDENCE_THRESHOLD ? stat : null,
    stat_label: conf.stat >= CONFIDENCE_THRESHOLD ? statLabel(stat) : null,
    raw_stat_label: fields.statLabel || null,
    line: conf.line >= CONFIDENCE_THRESHOLD ? fields.line : null,
    side: conf.side >= CONFIDENCE_THRESHOLD ? fields.side : null,
    odds: conf.odds >= CONFIDENCE_THRESHOLD ? fields.odds : null,
    confidence: {
      player: round2(conf.player), stat: round2(conf.stat), line: round2(conf.line),
      side: round2(conf.side), odds: round2(conf.odds),
    },
    needs_review: false,
  };
  leg.needs_review = leg.player == null || leg.stat == null
    || leg.line == null || leg.side == null || leg.odds == null;
  return leg;
}

function round2(n) { return Math.round(n * 100) / 100; }

function cleanLines(text) {
  return String(text || '')
    .split(/\r?\n/)
    .map((l) => l.replace(/\s+/g, ' ').trim())
    .filter(Boolean);
}

// Slip chrome that is never a leg (wager rows, headers, game rows).
const NOISE_RE = /^(wager|to win|total payout|bet id|placed|cash out|share|profit boost|same game parlay|sgp|parlay|\d+ leg|odds|stake|returns|potential|today|tomorrow|final|\$)/i;
const GAME_ROW_RE = /(@| at | vs\.? | v )/i;

/* ── DraftKings ──────────────────────────────────────────────────
   Layout: "<Player> Over 1.5" / market label on the NEXT line /
   odds on the line after ("-115"). */
function parseDraftKings(text) {
  const lines = cleanLines(text);
  const legs = [];
  const headRe = new RegExp(`^${NAME_RE.source}\\s+(Over|Under)\\s+(\\d+(?:\\.\\d+)?)$`, 'i');

  for (let i = 0; i < lines.length && legs.length < MAX_LEGS; i += 1) {
    const m = lines[i].match(headRe);
    if (!m) continue;
    const [, player, sideRaw, lineRaw] = m;

    // Market label = next non-noise, non-game line.
    let statLabelRaw = null;
    let odds = null;
    for (let j = i + 1; j <= i + 3 && j < lines.length; j += 1) {
      const l = lines[j];
      if (NOISE_RE.test(l)) break;
      const oddsMatch = l.match(/^([+\-−–]\s?\d{2,4})$/);
      if (oddsMatch) { odds = parseOdds(oddsMatch[1]); break; }
      if (!statLabelRaw && !GAME_ROW_RE.test(l) && !headRe.test(l)) statLabelRaw = l;
    }

    legs.push(buildLeg({
      player,
      statLabel: statLabelRaw,
      line: parseLine(lineRaw),
      side: sideRaw.toLowerCase(),
      odds,
    }));
  }
  return legs;
}

/* ── FanDuel ─────────────────────────────────────────────────────
   Layouts: "<Player> To Record 2+ Total Bases" (2+ → line 1.5 over),
   "<Player> Any Time Home Run", "<Player> Over 7.5 Strikeouts".
   SGP legs often carry no per-leg odds → odds null + needs_review. */
function parseFanDuel(text) {
  const lines = cleanLines(text);
  const legs = [];
  const recordRe = new RegExp(`^${NAME_RE.source}\\s+To Record\\s+(\\d+)\\+\\s+(.+)$`, 'i');
  const anytimeRe = new RegExp(`^${NAME_RE.source}\\s+((?:Any\\s?Time|Anytime) (?:Home Run|Goalscorer))$`, 'i');
  const ouRe = new RegExp(`^${NAME_RE.source}\\s+(Over|Under)\\s+(\\d+(?:\\.\\d+)?)\\s+(.+)$`, 'i');

  for (let i = 0; i < lines.length && legs.length < MAX_LEGS; i += 1) {
    const line = lines[i];
    if (NOISE_RE.test(line) || GAME_ROW_RE.test(line)) continue;

    // Trailing odds on the same line, else a bare odds token on the next.
    let body = line;
    let odds = null;
    const trailing = body.match(/\s([+\-−–]\s?\d{2,4})$/);
    if (trailing) { odds = parseOdds(trailing[1]); body = body.slice(0, trailing.index).trim(); }
    if (odds == null && lines[i + 1] && /^([+\-−–]\s?\d{2,4})$/.test(lines[i + 1])) {
      odds = parseOdds(lines[i + 1]);
    }

    let m = body.match(recordRe);
    if (m) {
      const threshold = parseInt(m[2], 10);
      legs.push(buildLeg({
        player: m[1], statLabel: m[3],
        line: Number.isFinite(threshold) ? threshold - 0.5 : null,
        lineConfidence: 0.9,
        side: 'over', sideConfidence: 0.85,
        odds,
      }));
      continue;
    }
    m = body.match(anytimeRe);
    if (m) {
      legs.push(buildLeg({
        player: m[1], statLabel: m[2],
        line: 0.5, lineConfidence: 0.9,
        side: 'over', sideConfidence: 0.85,
        odds,
      }));
      continue;
    }
    m = body.match(ouRe);
    if (m) {
      legs.push(buildLeg({
        player: m[1], statLabel: m[4],
        line: parseLine(m[3]),
        side: m[2].toLowerCase(),
        odds,
      }));
    }
  }
  return legs;
}

/* ── BetMGM ──────────────────────────────────────────────────────
   Layout: "<Player> Over 1.5 Total Bases @ -115" (inline), or the
   odds trailing without "@". */
function parseBetMGM(text) {
  const lines = cleanLines(text);
  const legs = [];
  const re = new RegExp(
    `^${NAME_RE.source}\\s+(Over|Under)\\s+(\\d+(?:\\.\\d+)?)\\s+(.+?)(?:\\s*@\\s*([+\\-−–]\\s?\\d{2,4}))?$`, 'i',
  );

  for (let i = 0; i < lines.length && legs.length < MAX_LEGS; i += 1) {
    const line = lines[i];
    if (NOISE_RE.test(line)) continue;
    // NOTE: no game-row pre-skip here — MGM leg lines carry "@ -115",
    // which the game-row heuristic would eat. The grammar (Over/Under)
    // is the filter; game rows never match it.
    const m = line.match(re);
    if (!m) continue;
    let [, player, sideRaw, lineRaw, statRaw, oddsRaw] = m;

    // Odds sometimes trail the stat label without "@".
    if (!oddsRaw) {
      const trailing = statRaw.match(/\s([+\-−–]\s?\d{2,4})$/);
      if (trailing) { oddsRaw = trailing[1]; statRaw = statRaw.slice(0, trailing.index).trim(); }
    }

    legs.push(buildLeg({
      player,
      statLabel: statRaw,
      line: parseLine(lineRaw),
      side: sideRaw.toLowerCase(),
      odds: parseOdds(oddsRaw),
    }));
  }
  return legs;
}

/* ── Caesars ─────────────────────────────────────────────────────
   Layout: "<Player> Total Bases Over 1.5 (-115)" — stat between the
   name and the side, odds parenthesized or bare at the end.

   The player/stat boundary inside the head segment is ambiguous to a
   regex ("Pete Alonso Home Runs" — is "Home" a name token?), so we
   split on the LONGEST known stat alias the head ends with. No alias
   match → stat null + needs_review; the split NEVER invents a stat. */

// Alias keys, longest (most words, then chars) first, for suffix scans.
const ALIAS_KEYS_BY_LEN = Object.keys(STAT_ALIASES)
  .sort((a, b) => b.split(' ').length - a.split(' ').length || b.length - a.length);

function splitPlayerStat(head) {
  const low = String(head || '').toLowerCase().replace(/\s+/g, ' ').trim();
  for (const alias of ALIAS_KEYS_BY_LEN) {
    if (low.length > alias.length && low.endsWith(' ' + alias)) {
      const player = head.slice(0, head.length - alias.length)
        .trim()
        .replace(/\s+alt$/i, ''); // "Judge Alt Total Bases" → player "Judge"
      return { player, statLabel: head.slice(head.length - alias.length).trim() };
    }
  }
  // No known stat suffix — the player/stat boundary is unknown too, so
  // the name candidate is low-confidence (below threshold → nulled).
  const nameMatch = String(head || '').match(new RegExp(`^${NAME_RE.source}`));
  return { player: nameMatch ? nameMatch[1] : null, statLabel: null, uncertain: true };
}

function parseCaesars(text) {
  const lines = cleanLines(text);
  const legs = [];
  const re = /^(.+?)\s+(Over|Under)\s+(\d+(?:\.\d+)?)\s*(?:\(?([+\-−–]\s?\d{2,4})\)?)?$/i;

  for (let i = 0; i < lines.length && legs.length < MAX_LEGS; i += 1) {
    const line = lines[i];
    if (NOISE_RE.test(line)) continue;
    const m = line.match(re);
    if (!m) continue;
    const [, head, sideRaw, lineRaw, oddsRaw] = m;
    const { player, statLabel, uncertain } = splitPlayerStat(head);
    legs.push(buildLeg({
      player,
      statLabel,
      ...(uncertain ? { playerConfidence: 0.5 } : {}),
      line: parseLine(lineRaw),
      side: sideRaw.toLowerCase(),
      odds: parseOdds(oddsRaw),
    }));
  }
  return legs;
}

/* ── Book detection + dispatch ─────────────────────────────────── */

const BOOK_MARKERS = [
  ['draftkings', /draft\s?kings|dksb/i],
  ['fanduel', /fan\s?duel/i],
  ['betmgm', /bet\s?mgm/i],
  ['caesars', /caesars|czr/i],
];

const PARSERS = {
  draftkings: parseDraftKings,
  fanduel: parseFanDuel,
  betmgm: parseBetMGM,
  caesars: parseCaesars,
};

function detectBook(text) {
  const t = String(text || '');
  for (const [book, re] of BOOK_MARKERS) {
    if (re.test(t)) return book;
  }
  return null;
}

/**
 * Parse OCR slip text. Uses the hinted/detected book's layout parser;
 * unknown book → try every layout, keep the best result (most legs,
 * tie-broken by fewest needs_review). Layout SELECTION is heuristic;
 * field VALUES never are — unparseable fields stay null.
 */
function parseSlipText(text, bookHint) {
  const hinted = bookHint && PARSERS[String(bookHint).toLowerCase()]
    ? String(bookHint).toLowerCase() : null;
  const detected = hinted || detectBook(text);

  if (detected) {
    const legs = PARSERS[detected](text);
    return finalize(detected, legs);
  }

  let best = { book: null, legs: [] };
  for (const [book, parser] of Object.entries(PARSERS)) {
    const legs = parser(text);
    const better = legs.length > best.legs.length
      || (legs.length === best.legs.length && legs.length > 0
          && countReview(legs) < countReview(best.legs));
    if (better) best = { book, legs };
  }
  return finalize(best.legs.length ? best.book : null, best.legs);
}

function countReview(legs) { return legs.filter((l) => l.needs_review).length; }

function finalize(book, legs) {
  return {
    book,
    legs,
    needs_review: legs.length === 0 || legs.some((l) => l.needs_review),
    source: 'user_slip',
  };
}

/* ── OCR (tesseract.js — self-hosted WASM, zero API) ───────────── */

/**
 * Run OCR on an image buffer. Lazy-requires tesseract.js so the pure
 * parsers stay loadable without the WASM engine (tests, cold paths).
 * English traineddata (~11MB) downloads once and caches on disk.
 */
async function recognizeImage(buffer, opts = {}) {
  const { createWorker } = opts.tesseract || require('tesseract.js');
  const worker = await createWorker('eng', 1, {
    cachePath: opts.cachePath || process.env.TESSERACT_CACHE_PATH || '.tesseract-cache',
    // Silence per-job progress logging.
    logger: () => {},
  });
  try {
    const { data } = await worker.recognize(buffer);
    return data.text || '';
  } finally {
    await worker.terminate();
  }
}

module.exports = {
  parseSlipText,
  detectBook,
  recognizeImage,
  CONFIDENCE_THRESHOLD,
  __internals: {
    parseDraftKings,
    parseFanDuel,
    parseBetMGM,
    parseCaesars,
    normalizeStat,
    splitPlayerStat,
    statLabel,
    parseOdds,
    buildLeg,
    STAT_ALIASES,
    NOISE_RE,
  },
};
