/**
 * analyzeViaEngine1 — the canonical single-prop analysis function.
 *
 * Composes the three pieces sessions 6c, 7e, and 7f built:
 *   computeFeaturesForProp → engine1.gradeProp → toLegacyShape
 *
 * Output matches the legacy `analyzeProp()` shape byte-for-byte (DemoScan
 * + GradeCard read the same fields). The `reasoning.summary` here is built
 * from real feature values (l5_avg, opp_rank_stat, etc.) so users still
 * see concrete sentences, not abstract factor labels.
 *
 * Never throws. Every upstream failure mode is reflected as low-confidence
 * grade + an explanatory reasoning summary.
 */

const { computeFeaturesForProp } = require('./computeFeatures');
const engine1 = require('./engine1');
const { toLegacyShape } = require('../../utils/gradeAdapter');
const { isSuppressedRareUnder, isSuppressedRareOver, isTooJuiced, gradedSideOdds } = require('../../config/rareEventMarkets');

// Map an error code from computeFeaturesForProp.meta.errors into a human
// sentence the user will see in reasoning.summary.
const ERROR_EXPLANATIONS = Object.freeze({
  player_not_found_in_id_map: "We couldn't find this player in our roster index.",
  no_game_scheduled_today: "No game scheduled for this player tonight.",
  no_features_computed: "Statistical features unavailable for this player tonight.",
});

function explainErrors(errors) {
  if (!Array.isArray(errors) || errors.length === 0) return '';
  return errors.map((e) => ERROR_EXPLANATIONS[e] || `Data gap: ${e}.`).join(' ');
}

// Soccer reasoning — different signals than NBA (xG, penalty role,
// altitude, referee, minutes). Concrete sentences from real values;
// nothing fires unless the underlying feature is non-null.
function buildSoccerReasoningLines(features = {}, meta = {}, prop = {}) {
  const lines = [];
  const statType = prop.stat_type || '';

  if (Number.isFinite(features.goals_per_90)) {
    lines.push(`${prop.player || 'Player'} scores ${features.goals_per_90.toFixed(2)} goals per 90 minutes.`);
  } else if (Number.isFinite(features.l5_avg)) {
    lines.push(`${prop.player || 'Player'} is averaging ${features.l5_avg.toFixed(2)} ${statType} over his last 5 matches.`);
  }

  if (Number.isFinite(features.xg_per_90)) {
    const delta = features.xg_delta;
    let trend = 'tracking expectations';
    if (Number.isFinite(delta)) {
      if (delta > 0.2) trend = 'overperforming — regression risk';
      else if (delta < -0.2) trend = 'underperforming — breakout candidate';
    }
    lines.push(`Expected goals (xG): ${features.xg_per_90.toFixed(2)} per 90 — ${trend}.`);
  }

  if (features.is_penalty_taker) {
    lines.push('Designated penalty taker — adds ~0.15 goals per 90 to base rate.');
  }
  if (features.takes_free_kicks && (statType === 'goals' || statType === 'shots' || statType === 'shots_on_target')) {
    lines.push('Direct free-kick specialist — boosts shot/goal probability on fouls drawn.');
  }
  if (features.takes_corners && statType === 'assists') {
    lines.push('Designated corner taker — meaningfully lifts assist probability.');
  }

  if (features.altitude_impact === 'high') {
    lines.push(`Match at ${features.venue_altitude_ft || 'high'}ft altitude. ${features.home_continent ? 'Acclimated host team.' : 'Non-acclimatized side — historical goal reduction.'}`);
  } else if (features.altitude_impact === 'moderate' && !features.home_continent) {
    lines.push(`Moderate altitude at ${features.venue_altitude_ft || 'venue'}ft — minor stamina impact.`);
  }

  if (Number.isFinite(features.referee_cards_per_game)) {
    const refName = features.referee_name || 'Referee';
    lines.push(`${refName} averages ${features.referee_cards_per_game.toFixed(1)} cards per match.`);
  }

  if (Number.isFinite(features.minutes_per_game) && features.minutes_per_game < 75) {
    lines.push(`Averaging only ${features.minutes_per_game.toFixed(0)} minutes per match — line may assume full 90.`);
  }

  if (Number.isFinite(features.opp_goals_conceded_per_game)) {
    lines.push(`${meta.opponentAbbr || 'Opponent'} concedes ${features.opp_goals_conceded_per_game.toFixed(2)} goals per game.`);
  }

  if (features.tournament_player && Number.isFinite(features.wc_goals_career)) {
    lines.push(`Tournament pedigree: ${features.wc_goals_career} career World Cup goals.`);
  }

  if (features.home_away === 1.0) lines.push('Playing at home.');
  else if (features.home_away === 0.0) lines.push('Playing on the road.');

  return lines;
}

// Build a human-readable reasoning summary + steps from the actual
// features (which carry real numbers) and engine1's grade.
function buildConcreteReasoning(features = {}, engine1Result = {}, meta = {}, prop = {}) {
  // Soccer (Session 7j) routes to a sport-specific line builder and
  // returns before the NBA-flavored sentences would fire. The closer
  // logic (trap, engine1 verdict, error gaps, steps shape) is shared
  // between sports and lives below this branch.
  const sportLc = String(meta.sport || '').toLowerCase();
  const isSoccer = sportLc === 'soccer' || sportLc === 'football';

  const lines = isSoccer
    ? buildSoccerReasoningLines(features, meta, prop)
    : [];

  if (!isSoccer) {
    // Recent form vs the line — L5 and L20 are the orchestrator's
    // canonical season-trend signals.
    if (Number.isFinite(features.l5_avg)) {
      lines.push(`${prop.player || 'Player'} is averaging ${features.l5_avg.toFixed(1)} ${prop.stat_type || ''} over his last 5 games.`);
    }
    if (Number.isFinite(features.l20_avg)) {
      lines.push(`Last 20 games average: ${features.l20_avg.toFixed(1)}.`);
    }

    // Trend direction relative to the line.
    if (Number.isFinite(features.l5_avg) && Number.isFinite(prop.line)) {
      const diff = features.l5_avg - prop.line;
      if (Math.abs(diff) >= 0.5) {
        const dir = diff > 0 ? 'above' : 'below';
        lines.push(`That's ${Math.abs(diff).toFixed(1)} ${dir} the line of ${prop.line}.`);
      }
    }

    // Home / away.
    if (features.home_away === 1.0) lines.push('Playing at home tonight.');
    else if (features.home_away === 0.0) lines.push('Playing on the road tonight.');
  }

  if (!isSoccer) {
    // Opponent matchup. opp_rank_stat is 0..1 normalized
    // (0 = best D, 1 = worst D) — translate to friendlier language.
    if (Number.isFinite(features.opp_rank_stat) && meta.opponentAbbr) {
      if (features.opp_rank_stat >= 0.7) {
        lines.push(`${meta.opponentAbbr} is a bottom-tier defense vs this stat.`);
      } else if (features.opp_rank_stat <= 0.3) {
        lines.push(`${meta.opponentAbbr} is a top-tier defense vs this stat.`);
      } else {
        lines.push(`${meta.opponentAbbr} is a middling defense vs this stat.`);
      }
    }

    // Rest / fatigue context.
    if (features.rest_days === 0) lines.push('Back-to-back — fatigue concern.');
    else if (Number.isFinite(features.rest_days) && features.rest_days >= 2) {
      lines.push(`${features.rest_days} days of rest.`);
    }
    if (Number.isFinite(features.game_count_in_7d) && features.game_count_in_7d >= 4) {
      lines.push(`Heavy workload — ${features.game_count_in_7d} games in the last week.`);
    }

    // Injury context.
    if (Number.isFinite(features.injury_severity_score) && features.injury_severity_score > 0) {
      lines.push(`${features.injury_severity_score} opponent starter(s) on the injury report.`);
    }
  }

  // Trap composite — surfaced when meaningful.
  // (Adapter handles the per-factor kill_conditions chips; this line
  // gives the user the overall warning.)
  if (engine1Result?.grade && engine1Result.grade.endsWith('-') === false
      && Array.isArray(engine1Result.all_factors)
      && engine1Result.all_factors.includes('trap_composite_high')) {
    lines.push('Multiple trap signals firing — proceed with caution.');
  }

  // Engine-1 verdict capper.
  const grade = engine1Result?.grade;
  if (grade) {
    const verb = grade.startsWith('A') ? 'favors the play'
      : grade.startsWith('B') ? 'leans toward the play'
      : grade.startsWith('C') ? 'is split'
      : 'leans against the play';
    lines.push(`Engine 1 graded ${grade} — ${verb}.`);
  }

  // Tack on any data-gap explanations.
  const gapNote = explainErrors(meta.errors);
  if (gapNote) lines.push(gapNote);

  const summary = lines.join(' ').trim()
    || `Analysis complete. Grade: ${grade || 'C'}.`;

  // Legacy-shaped steps so backward-compat callers (integration tests,
  // anything pre-dating the engine swap) keep seeing the named
  // sub-blocks. Each is populated from engine1 features where the data
  // exists; missing sub-blocks contain null fields instead of being
  // absent so callers can dot-access without optional chaining.
  const seasonAvg = Number.isFinite(features.l20_avg) ? features.l20_avg : null;
  const recentAvg = Number.isFinite(features.l5_avg) ? features.l5_avg : null;
  const haContext = features.home_away === 1.0 ? 'home'
    : features.home_away === 0.0 ? 'away' : null;
  const restContext = features.rest_days === 0 ? 'b2b'
    : Number.isFinite(features.rest_days) && features.rest_days >= 2 ? 'rested' : null;

  return {
    summary,
    steps: {
      season_avg: {
        value: seasonAvg,
        vs_line: seasonAvg != null && Number.isFinite(prop.line)
          ? Math.round((seasonAvg - prop.line) * 10) / 10 : null,
        signal: null,
      },
      recent_form: {
        value: recentAvg,
        vs_line: recentAvg != null && Number.isFinite(prop.line)
          ? Math.round((recentAvg - prop.line) * 10) / 10 : null,
        signal: null,
      },
      situational: {
        home_away: { value: null, context: haContext, signal: null },
        rest_days: { value: features.rest_days ?? null, context: restContext, signal: null },
        vs_opponent: { value: null, games: null, signal: null },
      },
      line_comparison: {
        best_line: null,
        worst_line: null,
        edge_from_best: 0,
        signal: null,
      },
      kill_conditions: [],
      final_grade: grade || null,
      // Flat narrative bullets — kept under `steps` so legacy clients
      // can still find them but they don't collide with the named
      // sub-blocks above.
      narrative: lines.map((line, i) => ({ step: i + 1, detail: line })),
    },
  };
}

// The model's projection: the same reference the edge is computed from.
// Recent-form averages first (l5, else l20); soccer props (which carry
// per-90 rates instead of game averages) fall back to `{stat}_per_90`,
// then xG for goals. This is a REAL model number — it is never the line.
// When it's null the model has no projection and the read must refuse
// (insufficient_data), not ship a hollow grade.
function projectionFor(features, prop) {
  const f = features || {};
  const round2 = (n) => Math.round(n * 100) / 100;
  // A projection must be a POSITIVE model reference. A non-positive value (0
  // or negative) is not a real projection — it yields a degenerate edge
  // ((line - 0) / line = 100%) and a hollow grade (the audit's projection=0
  // nine). Skip non-positive candidates and fall through to the next real
  // reference; when NONE is positive, return null so the read REFUSES
  // (insufficient_data) instead of grading on zero.
  const pos = (n) => (Number.isFinite(n) && n > 0 ? round2(n) : null);
  const stat = String(prop?.stat_type || '').toLowerCase();
  return pos(f.l5_avg)
    ?? pos(f.l20_avg)
    ?? pos(f[`${stat}_per_90`])
    ?? (stat === 'goals' ? pos(f.xg_per_90) : null);
}

// edge_pct in the legacy shape compares the model projection to the line:
// (model - line) / line, signed by direction. It is a projection-vs-line gap,
// always LABELLED MODEL in the UI. `ref` may be passed to reuse the exact
// projection the read was validated on (so edge and the persisted projection
// can never diverge); omitted, it recomputes (used by the alt-line ladder,
// where the projection is line-independent so recompute is equivalent).
function edgePctFor(features, prop, ref) {
  const r = ref === undefined ? projectionFor(features, prop) : ref;
  if (r == null || !Number.isFinite(prop?.line) || prop.line === 0) return 0;
  const signed = prop.direction === 'over' ? (r - prop.line) : (prop.line - r);
  return Math.round((signed / prop.line) * 1000) / 10;
}

// Session 58 (work-order 1.5) — when the model has NO projection there is no
// read. This used to return grade 'C' / confidence 10 / edge 0 — the exact
// "hollow C" the live audit caught (model==line, +0% edge). A refused read
// builds more trust than a fake one: grade is null, insufficient_data is
// true, and NOTHING downstream (grades cache, snapshot, ledger) persists it.
function insufficientDataResult(rawProp, errors) {
  return {
    player: rawProp.player ?? null,
    stat_type: rawProp.stat_type ?? null,
    line: rawProp.line ?? null,
    direction: rawProp.direction ?? null,
    book: rawProp.book || 'unknown',
    grade: null,
    insufficient_data: true,
    confidence: 0,
    edge_pct: 0,
    projection: null,
    kill_conditions_triggered: [],
    reasoning: {
      summary: `INSUFFICIENT DATA — no read. ${explainErrors(errors) || 'The model has no projection for this prop.'}`.trim(),
      steps: [],
    },
  };
}

// Betting-logic audit (2026-07-19) — rare-event 0.5 markets (doubles/triples/
// HR/SB) have no takeable edge on the UNDER (juiced) and no edge on the OVER
// unless the model genuinely projects the event above the line. We REFUSE those
// (grade null + insufficient_data so every consumer's no-read handling applies)
// with a distinct `suppressed` flag/reason.
function suppressedRareResult(rawProp, reason, summary) {
  return {
    player: rawProp.player ?? null,
    stat_type: rawProp.stat_type ?? null,
    line: rawProp.line ?? null,
    direction: rawProp.direction ?? null,
    book: rawProp.book || 'unknown',
    grade: null,
    insufficient_data: true,
    suppressed: true,
    suppressed_reason: reason,
    confidence: 0,
    edge_pct: 0,
    projection: null,
    kill_conditions_triggered: [],
    reasoning: { summary, steps: [] },
  };
}

/**
 * Form score (0..100) from recent-vs-baseline averages (Session 43). Hot
 * (l5 > l20) trends above 70; cold below. undefined when there's no recent avg.
 */
function computeFormScore(features = {}) {
  const l5 = features.l5_avg;
  if (!Number.isFinite(l5)) return undefined;
  const base = Number.isFinite(features.l20_avg) ? features.l20_avg
    : Number.isFinite(features.l10_avg) ? features.l10_avg : null;
  // Session 65 — was `return 75`: a hardcoded constant rendered under the FORM
  // label whenever the baseline was missing or zero. With no baseline there is
  // no recent-vs-baseline ratio, so the field is absent and the card's
  // self-hiding intel section drops the row. Absent renders absent.
  if (base == null || base === 0) return undefined;
  const ratio = l5 / base;
  return Math.round(Math.max(40, Math.min(99, 70 + (ratio - 1) * 60)));
}

function matchupGradeFromRank(rank) {
  if (!Number.isFinite(rank)) return undefined;
  if (rank >= 0.66) return 'A';
  if (rank >= 0.5) return 'B+';
  if (rank >= 0.33) return 'B';
  return 'C';
}

/**
 * Intelligence fields for the grade card (Session 43) — STAT CONTEXT + VYNDR
 * INTELLIGENCE. Computed ONLY from the already-built feature vector (no extra
 * I/O), so every field is optional and self-hides on the card when absent.
 * NOTE: archetype is intentionally NOT set here — the per-prop feature vector
 * doesn't carry a full multi-stat season line, so classifying it would just
 * yield the fallback. The archetype strip lights up once the snapshot pipeline
 * (Session 44) feeds per-player season lines into the grade response.
 */
const firstFinite = (...vals) => vals.find((v) => Number.isFinite(v));

function buildIntelFields(features = {}, opts = {}) {
  const out = {};
  const round1 = (n) => Math.round(n * 10) / 10;
  // Resilience (Session 46): fall back to a caller-supplied playerStats bundle
  // and the model projection when the feature vector is sparse. Partial intel
  // beats none — we add only the fields we can actually back with a number.
  const ps = opts.playerStats || {};
  const proj = Number.isFinite(opts.projection) ? opts.projection : undefined;

  const seasonAvg = firstFinite(features.l20_avg, features.season_avg, ps.season_avg, proj);
  if (seasonAvg != null) out.season_avg = round1(seasonAvg);

  const last10 = firstFinite(features.l10_avg, features.l5_avg, ps.last10_avg);
  if (last10 != null) out.last10_avg = round1(last10);

  let form = computeFormScore(features);
  if (form == null && Number.isFinite(ps.form)) form = Math.round(ps.form);
  if (form != null) out.form = form;

  if (Number.isFinite(features.usage_rate)) out.usage = `${round1(features.usage_rate)}%`;
  else if (Number.isFinite(features.minutes_per_game)) out.usage = `${Math.round(features.minutes_per_game)} min`;
  else if (Number.isFinite(features.ab_per_game)) out.usage = `${round1(features.ab_per_game)} AB/G`; // MLB usage equivalent
  else if (ps.usage) out.usage = String(ps.usage);

  const matchup = matchupGradeFromRank(features.opp_rank_stat)
    || (Number.isFinite(features.bvp_advantage) ? (features.bvp_advantage > 0.05 ? 'A' : features.bvp_advantage > 0 ? 'B+' : 'C') : null);
  if (matchup) out.matchup_grade = matchup;

  if (Number.isFinite(features.rest_days)) out.rest = features.rest_days === 0 ? 'B2B' : `${features.rest_days}d rest`;
  return out;
}

async function analyzeViaEngine1(rawProp = {}) {
  // Betting-logic audit — the GENERAL no-edge guard (stat/sport-agnostic): the
  // book's own price is the truth. A side priced past the juice floor (e.g.
  // those doubles unders at -625 to -1100) has no takeable edge, so refuse it up
  // front. This is what makes the fix robust instead of a fragile whitelist.
  if (isTooJuiced(rawProp)) {
    return suppressedRareResult(rawProp, 'juiced_no_edge',
      `No read — the book prices this side at ${gradedSideOdds(rawProp)}; the vig has eaten any edge.`);
  }

  // Backup for props with NO odds — the structural rare-event rule. Suppress the
  // juiced UNDER on a 0.5-line rare counting stat (doubles/triples/HR/SB/steals/
  // blocks). (The OVER is gated on the projection below.)
  if (isSuppressedRareUnder(rawProp.stat_type, rawProp.line, rawProp.direction)) {
    return suppressedRareResult(rawProp, 'rare_event_under',
      `No read — a ${rawProp.line} under on ${rawProp.stat_type} is a juiced rare-event market, not a takeable edge.`);
  }

  const featureResult = await computeFeaturesForProp(rawProp);
  const { features, trap, consistency, prop, meta } = featureResult;

  // Hard refusal when computeFeatures couldn't produce anything useful at
  // all (no features AND no consistency input) — there is nothing to grade.
  if ((!features || Object.keys(features).length === 0)
      && (!consistency || consistency.consistency === 'unknown')
      && (!Array.isArray(meta?.gameLogs) || meta.gameLogs.length === 0)) {
    return insufficientDataResult(rawProp, meta?.errors);
  }

  // Session 58 (work-order 1.5) — no projection ⇒ no read. Without a model
  // reference the edge is fictional and the grade would be hollow.
  // 2026-07 hardening: the invariant is STRUCTURAL — a grade can NEVER be
  // emitted with a non-positive projection. projectionFor already nulls
  // non-positive references; the explicit `> 0` guard defends the law even if
  // that ever changes. Refusal is the correct output (fewer graded props, honest).
  const projection = projectionFor(features, { ...rawProp, line: prop.line });
  if (projection == null || !(projection > 0)) {
    return insufficientDataResult(rawProp, meta?.errors);
  }

  // Betting-logic audit — a rare-event 0.5 OVER is a read ONLY when the model
  // genuinely projects the event ABOVE the line. Below that, the over carries
  // the same |edge| as the (already-suppressed) under and would just take its
  // place on the board — so refuse it. This is what actually clears the market.
  if (isSuppressedRareOver(rawProp.stat_type, prop.line, prop.direction, projection)) {
    return suppressedRareResult(rawProp, 'rare_event_over_below_line',
      `No read — the model projects ${projection} ${rawProp.stat_type}, at or below the ${prop.line} line; the over is not a genuine event projection.`);
  }

  // Engine 1: deterministic rule-based grade on the feature vector.
  const engine1Result = engine1.gradeProp({ features, trap, consistency, prop });

  // Translate engine1 output → legacy shape via the adapter from 7e.
  // The adapter handles kill_conditions_triggered + the 4-letter grade
  // collapse + the 0-100 confidence scale.
  const summaryOverride = buildConcreteReasoning(features, engine1Result, meta, {
    ...rawProp,
    line: prop.line,
  }).summary;

  const legacy = toLegacyShape(engine1Result, {
    player: rawProp.player,
    stat_type: rawProp.stat_type,
    line: prop.line,
    direction: prop.direction,
    book: rawProp.book || 'unknown',
    sport: meta.sport,
  }, {
    summaryOverride,
    edgePct: edgePctFor(features, prop, projection), // reuse the validated projection
  });

  // The adapter's reasoning.steps was a single-element debug bag;
  // replace it with the line-by-line breakdown we built above so the
  // legacy UI's step list looks identical to before.
  legacy.reasoning = buildConcreteReasoning(features, engine1Result, meta, {
    ...rawProp,
    line: prop.line,
  });

  // Session 43 — attach grade-card intelligence fields (stat context + VYNDR
  // intelligence). Optional + self-hiding on the card; zero extra I/O.
  Object.assign(legacy, buildIntelFields(features));

  // Session 58 — the model's REAL projection (never the line). Persisted as
  // ledger model_value and rendered under the MODEL label on the card.
  legacy.projection = projection;

  // Session 64 — RETENTION: expose the feature vector + the pre-collapse
  // 11-step grade so `model_snapshots` can store the model's INPUTS, not just
  // its output. Without inputs a backtest can only grade our own homework.
  // Underscore-prefixed = internal: gradeSlateService strips these before the
  // grade reaches any cache or API payload.
  legacy._features = features;
  legacy._grade_11 = engine1Result && engine1Result.grade ? engine1Result.grade : null;

  // Session 62 (A1-S1) — ALT LINE LADDER + EDGE RANKING. The pricing page
  // sold it; the adapter/card were already built for it; nothing produced
  // it. Re-grade the SAME feature vector at shifted lines (zero extra I/O)
  // and rank by edge. Attached for every graded result; the API tier gate
  // strips it below Desk.
  try {
    const baseLine = Number(prop.line);
    if (Number.isFinite(baseLine)) {
      const ladder = [-1, -0.5, 0, 0.5, 1]
        .map((shift) => Math.round((baseLine + shift) * 100) / 100)
        .filter((ln) => ln > 0)
        .map((ln) => {
          const shiftedProp = { ...prop, line: ln };
          const g = engine1.gradeProp({ features, trap, consistency, prop: shiftedProp });
          const adapted = toLegacyShape(g, {
            player: rawProp.player, stat_type: rawProp.stat_type,
            line: ln, direction: prop.direction, book: rawProp.book || 'unknown', sport: meta.sport,
          }, { edgePct: edgePctFor(features, { ...shiftedProp, stat_type: rawProp.stat_type, direction: prop.direction }) });
          return { line: ln, grade: adapted.grade, edge_pct: adapted.edge_pct, base: ln === baseLine };
        })
        .sort((a, b) => (Number(b.edge_pct) || 0) - (Number(a.edge_pct) || 0));
      if (ladder.length > 1) legacy.alt_lines = ladder;
    }
  } catch { /* the ladder is additive — never breaks the read */ }

  // Session 62 (A1-S1) — QUARTER-KELLY + Model Train (steps 1-6): de-vig, EV,
  // the value triplet, and the takeable/value flags. Real probability (quantile
  // estimator over the actual game logs) × real book odds, or nothing — never
  // derived from confidence, never a default vig.
  try {
    const { estimateProbability } = require('./probabilityEstimator');
    const { devigTwoWay, evPct, impliedProbToAmerican } = require('../../utils/devig');
    const { isTakeable, isValue } = require('../../config/valueEngine');

    const dir = String(prop.direction || 'over').toLowerCase();
    const est = estimateProbability({ gameLogs: meta.gameLogs, line: prop.line, statType: rawProp.stat_type, features });
    const pWin = dir === 'under'
      ? (Number.isFinite(est.p_over) ? 1 - est.p_over : null)
      : (Number.isFinite(est.p_over) ? est.p_over : null);
    if (pWin != null) legacy.p_win = Math.round(pWin * 1000) / 1000;

    const sideOdds = dir === 'under' ? rawProp.under_odds : rawProp.over_odds;

    // Quarter-Kelly sizing (unchanged).
    if (pWin != null && sideOdds != null) {
      const { quarterKelly } = require('../../utils/kelly');
      const k = quarterKelly(pWin, sideOdds);
      if (k) legacy.kelly = { ...k, odds: String(sideOdds) };
    }

    // The VALUE TRIPLET (step 6): book price · fair (de-vigged) price · model
    // price. Two-way de-vig needs BOTH sides; one side missing → fair absent.
    if (sideOdds != null) legacy.book_odds = Number(sideOdds);
    const dv = devigTwoWay(rawProp.over_odds, rawProp.under_odds);
    if (dv) {
      const fair = dir === 'under' ? dv.under : dv.over;
      legacy.fair_prob = fair.fair_prob;
      legacy.fair_odds = fair.fair_odds;
      legacy.devig_method = dv.method;
      legacy.overround = dv.overround;
    }
    if (pWin != null) legacy.model_odds = impliedProbToAmerican(pWin);

    // EV at the ACTUAL price (step 2) + the takeable/value flags (steps 3-4).
    // `takeable` is a property of the price alone; `value` also needs the edge.
    if (sideOdds != null) legacy.takeable = isTakeable(sideOdds);
    if (pWin != null && sideOdds != null) {
      const ev = evPct(pWin, sideOdds);
      if (ev != null) {
        legacy.ev_pct = ev;
        legacy.value = isValue(sideOdds, ev);
      }
    }
  } catch { /* the value layer is additive — absent beats wrong */ }

  return legacy;
}

module.exports = {
  analyzeViaEngine1,
  __internals: {
    buildConcreteReasoning,
    edgePctFor,
    projectionFor,
    insufficientDataResult,
    explainErrors,
    ERROR_EXPLANATIONS,
    buildIntelFields,
    computeFormScore,
    matchupGradeFromRank,
  },
};
