/* ============================================================
   VYNDR 2.0 — grade adapter (§7).
   Maps our grading-engine output (the /api/scan ScanResponse shape +
   the GradeCard props) onto the GradeResultCard data contract from the
   design spec. Plain CommonJS so the .tsx card imports it (allowJs) AND
   Jest exercises the mapping logic directly (no TS/Babel transform).
   ============================================================ */

const STAT_LABELS = {
  points: 'Points', rebounds: 'Rebounds', assists: 'Assists', threes: '3-Pointers',
  steals: 'Steals', blocks: 'Blocks', pra: 'P+R+A', turnovers: 'Turnovers',
  strikeouts: 'Strikeouts', hits_allowed: 'Hits Allowed', earned_runs: 'Earned Runs',
  innings_pitched: 'Innings Pitched', hits: 'Hits', total_bases: 'Total Bases',
  rbi: 'RBI', runs: 'Runs', home_runs: 'Home Runs',
};

/** Humanize a stat_type id ("home_runs" → "Home Runs"). */
function statLabel(stat) {
  if (!stat) return '';
  if (STAT_LABELS[stat]) return STAT_LABELS[stat];
  return String(stat).replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
}

/** Signed edge as a percentage of the line, from projection vs line. */
function computeEdge(projection, line, direction) {
  if (projection == null || line == null) return 0;
  const raw = direction === 'under' ? line - projection : projection - line;
  const pct = line > 0 ? (raw / line) * 100 : raw;
  return Math.round(pct * 10) / 10;
}

/** A/A+ with strong support → "phosphor confirmed". */
function isPhosphorConfirmed(grade, confidence, sampleSize) {
  const g = String(grade || '').trim().toUpperCase();
  const strong = g === 'A+' || g === 'A';
  return strong && ((confidence != null && confidence >= 70) || (sampleSize != null && sampleSize >= 30));
}

/** factors object/array → plain-English signal bullets (4–6). */
function toSignals(factors) {
  if (!factors) return [];
  if (Array.isArray(factors)) return factors.filter(Boolean).slice(0, 6).map(String);
  return Object.entries(factors)
    .filter(([, v]) => Boolean(v))
    .slice(0, 6)
    .map(([k, v]) => `${k.replace(/_/g, ' ').replace(/\b\w/, (c) => c.toUpperCase())}: ${v}`);
}

/**
 * Map an engine result to the GradeResultCard contract.
 * input: { player, team, sport, stat, line, direction|side, grade, projection,
 *          confidence, sample_size, factors, alt_lines, kill_conditions, books, tier }
 */
function mapScanToGradeResult(input = {}) {
  const direction = (input.side || input.direction || 'over').toString().toLowerCase();
  const side = direction === 'under' ? 'Under' : 'Over';
  const line = input.line != null ? Number(input.line) : 0;
  const projection = input.projection != null ? Number(input.projection) : undefined;
  const confidence = input.confidence != null ? Math.round(Number(input.confidence)) : 0;
  const tier = input.tier || 'free';
  const includeAlt = tier === 'desk';
  // Tier gating so the new card doesn't give paid content away (free users get
  // a 3-signal teaser; kill conditions are Analyst+; alt ladder is Desk). The
  // richer blurred-paywall treatment returns in Phase G (Session 38).
  const allSignals = toSignals(input.factors);
  const signals = tier === 'free' ? allSignals.slice(0, 3) : allSignals;
  // Session 62 (A1-S1) — the pricing page promises free users a LOCKED
  // PREVIEW of kill conditions (the count + codes exist server-side via
  // tierGating), not their absence. Free sees "⚠ TRAP — upgrade to see
  // details"; paid sees the reasons.
  const killConditions = tier === 'free'
    ? (input.kill_conditions || [])
        .map((k) => (k && k.code ? `${k.code} — upgrade to see details` : ''))
        .filter(Boolean)
    : (input.kill_conditions || []).map((k) => (typeof k === 'string' ? k : (k && k.reason) || '')).filter(Boolean);

  return {
    player: input.player || '',
    team: input.team || '',
    sport: (input.sport || 'nba').toString().toLowerCase(),
    // Wave 2A — real headshot ids (optional; card self-hides to a monogram).
    playerId: input.playerId != null ? input.playerId : null,
    espnId: input.espnId != null ? input.espnId : null,
    stat: statLabel(input.stat),
    line,
    side,
    grade: input.grade || '—',
    confidence,
    // DATA SEMANTICS (Session 58): projection is MODEL output and must never
    // be fabricated. The old fallback displayed the LINE as the projection —
    // the audit's model==line / +0% edge degenerate. No projection → the
    // MODEL row is absent and edge is null (the card renders an absent state,
    // not a fake zero-edge read).
    edge: projection != null ? computeEdge(projection, line, direction) : null,
    projection: projection != null ? Math.round(projection * 10) / 10 : null,
    phosphorConfirmed: isPhosphorConfirmed(input.grade, input.confidence, input.sample_size),
    signals,
    killConditions,
    books: Array.isArray(input.books) ? input.books : [],
    altLadder: includeAlt && Array.isArray(input.alt_lines)
      ? input.alt_lines.map((a) => ({ line: a.line, grade: a.grade, edge: a.edge_pct, base: a.base }))
      : [],
    // Session 62 (A1-S1) — quarter-Kelly (Desk; API already strips below).
    kelly: includeAlt && input.kelly && input.kelly.pct != null ? input.kelly : undefined,
    // Session 42 — Player Intelligence additions. All OPTIONAL + self-hiding:
    // they only render once the engine supplies them (Session 43 data pipeline).
    ...buildIntelFields(input),
    // Session 66 — the PRICE LAYER (book · fair · model). Self-hiding: absent
    // unless the engine supplied real prices, so a read with no market prices
    // renders the projection alone rather than an empty gauge. NEVER
    // fabricated — the design file's numbers are a spec, not a fallback.
    ...buildPriceTriplet(input),
  };
}

/**
 * buildPriceTriplet(input) — the optional price-layer block for the card.
 *
 * Returns `{}` (section stays hidden) unless the engine supplied at least a
 * book price AND a fair price: with no honest de-vigged number there is no
 * price story to tell, and a lone book price is just the market repeated back.
 *
 * `model_price_locked` is set by the SERVER (utils/tierGating strips
 * `model_odds` for unentitled tiers and flags it) — the free tier never
 * receives the model price, so the lock is real, not a client-side blur over
 * data that was already sent. Book and fair always pass through: the fair leg
 * is never the paywall.
 */
function buildPriceTriplet(input) {
  const n = (v) => {
    if (v == null || v === '') return null;
    const x = typeof v === 'number' ? v : Number(v);
    return Number.isFinite(x) ? x : null;
  };
  const book = n(input.book_odds);
  const fair = n(input.fair_odds);
  if (book == null || fair == null) return {};
  return {
    priceTriplet: {
      book_odds: book,
      fair_odds: fair,
      model_odds: n(input.model_odds),
      ev_pct: n(input.ev_pct),
      quarantine_reason: input.quarantine_reason || null,
      model_price_locked: input.model_price_locked === true,
    },
  };
}

/**
 * Build the optional archetype / stat-context / vyndr-intelligence fields for
 * the grade card from whatever the engine provided. Returns {} when nothing is
 * present so the card sections stay hidden (no empty boxes).
 */
function buildIntelFields(input) {
  const out = {};
  if (Array.isArray(input.archetype_blend) && input.archetype_blend.length) {
    out.archetypeBlend = input.archetype_blend;
  } else if (input.archetype) {
    out.archetypeBlend = [{ archetype: String(input.archetype), weight: 1 }];
  }
  if (input.prop_dna && (input.prop_dna.reliable || input.prop_dna.volatile)) {
    out.propDNA = {
      reliable: input.prop_dna.reliable || [],
      volatile: input.prop_dna.volatile || [],
    };
  }
  const sc = {};
  if (input.season_avg != null) sc.season = String(input.season_avg);
  if (input.last10_avg != null) sc.last10 = String(input.last10_avg);
  if (input.vs_opp_avg != null) sc.vsOpp = String(input.vs_opp_avg);
  if (Object.keys(sc).length) out.statContext = sc;

  const vi = {};
  if (input.form != null) vi.form = input.form;
  if (input.usage != null) vi.usage = String(input.usage);
  if (input.matchup_grade != null) vi.matchup = String(input.matchup_grade);
  if (input.rest != null) vi.rest = String(input.rest);
  if (Object.keys(vi).length) out.vyndrIntel = vi;

  return out;
}

module.exports = { mapScanToGradeResult, statLabel, computeEdge, isPhosphorConfirmed, toSignals, buildIntelFields, buildPriceTriplet };
