'use strict';

/**
 * STATCAST AGGREGATE SERVICE (Layer 1) — derivation + join + store.
 *
 * The AGGREGATE DERIVATION and IDENTITY BRIDGE boxes of the mechanism-data
 * pattern (docs/MECHANISM-DATA.md). Pure where it can be, injectable
 * everywhere, so tests never touch the network or the database.
 *
 * BACKFILL and REFRESH are the SAME operation: a full re-pull of the ~1,300-row
 * aggregate set, upserted on (sport, season, source_id). That makes the nightly
 * job idempotent and self-healing — a missed night self-corrects on the next
 * run, with no incremental "who played today" bookkeeping to drift out of sync.
 * At this volume the simple thing is also the robust thing.
 *
 * HONESTY RULES:
 *   - A metric the feed did not carry is ABSENT (null), never 0.
 *   - A player below the minimum sample keeps his row but is marked
 *     `sufficient: false` — thin is not the same as missing, and Layer 2 must
 *     be able to tell them apart.
 *   - A player we cannot join to our own roster still stores (player_key null)
 *     and joins later. Storing him is not a claim about him.
 *   - `updated_at` is written every run. Freshness is a truth property; the
 *     staleness alarm reads it.
 */

const { nameKey } = require('../utils/playerName');
const statcast = require('./adapters/statcastAdapter');

/** Minimum samples for a mechanism claim. Below these the row is stored but
 *  flagged insufficient — the explicit gate that prevents a 12-PA sample from
 *  being classified as confidently as a 400-PA one (the FLEX-at-weight-1.0
 *  failure, structurally prevented). */
const MIN_PA = Number(process.env.STATCAST_MIN_PA) || 50;
const MIN_IP = Number(process.env.STATCAST_MIN_IP) || 10;

const num = (v) => {
  if (v == null || v === '') return null;
  const n = typeof v === 'number' ? v : Number(v);
  return Number.isFinite(n) ? n : null;
};

/**
 * roleDetail(usage) — TRUE role from real usage, not the IP proxy (which drifts
 * all season as innings accumulate). Order matters: a closer who also holds is
 * a closer. Absent when statsapi carried no usage for him.
 */
function roleDetail(u) {
  if (!u) return null;
  const gs = u.gs ?? 0, gp = u.gp ?? 0, sv = u.saves ?? 0, hld = u.holds ?? 0;
  if (gp <= 0) return null;
  if (gs / gp >= 0.5) return 'starter';
  if (sv >= 5) return 'closer';
  if (hld >= 5) return 'setup';
  return 'reliever';
}

/**
 * buildRows(season, feeds, opts) — PURE. Merges the five feed indexes into one
 * row per player per role. No I/O.
 */
function buildRows(season, feeds, opts = {}) {
  const sport = opts.sport || 'mlb';
  const now = opts.now || new Date().toISOString();
  const rows = [];

  // ---- BATTERS -----------------------------------------------------------
  const batterIds = new Set([...feeds.batterDiscipline.keys(), ...feeds.batterBattedBall.keys()]);
  for (const id of batterIds) {
    const d = feeds.batterDiscipline.get(id);
    const b = feeds.batterBattedBall.get(id);
    const m = { ...(b ? b.metrics : {}), ...(d ? d.metrics : {}) };
    const name = (d && d.name) || (b && b.name) || null;
    const hand = feeds.handedness && feeds.handedness.get(id);
    const pa = num(m.pa);
    rows.push({
      sport,
      season,
      source_id: id,
      player_key: name ? nameKey(name) : null,
      player_name: name,
      role: 'batter',
      // Tier-A join (Session 69): statsapi carries batSide/pitchHand for every
      // player in ONE call. Absent only if the id is unknown to statsapi.
      throws: (hand && hand.throws) || null,
      bats: (hand && hand.bats) || null,
      position: (hand && hand.position) || null,
      role_detail: null,
      games_started: null, games_pitched: null, saves: null, holds: null,
      sample_pa: pa,
      sample_ip: null,
      sample_bip: num(m.bip),
      k_pct: num(m.k_pct),
      bb_pct: num(m.bb_pct),
      whiff_pct: num(m.whiff_pct),
      swing_pct: num(m.swing_pct),
      chase_pct: num(m.chase_pct),
      barrel_pct: num(m.barrel_pct) ?? num(m.barrel_pct_bb),
      hard_hit_pct: num(m.hard_hit_pct),
      avg_exit_velo: num(m.avg_exit_velo),
      max_exit_velo: num(m.max_exit_velo),
      avg_launch_angle: num(m.avg_launch_angle),
      sweet_spot_pct: num(m.sweet_spot_pct) ?? num(m.sweet_spot_pct_bb),
      ev95_pct: num(m.ev95_pct),
      arm_angle: null,
      gb_pct: null,
      fb_pct: null,
      ld_pct: null,
      pitch_mix: null,
      metrics: m,
      updated_at: now,
      _sufficient: pa != null && pa >= MIN_PA,
    });
  }

  // ---- PITCHERS ----------------------------------------------------------
  const pitcherIds = new Set([
    ...feeds.pitcherDiscipline.keys(),
    ...feeds.pitcherBattedBall.keys(),
    ...feeds.pitchMix.keys(),
  ]);
  for (const id of pitcherIds) {
    const d = feeds.pitcherDiscipline.get(id);
    const b = feeds.pitcherBattedBall.get(id);
    const mix = feeds.pitchMix.get(id);
    const m = { ...(b ? b.metrics : {}), ...(d ? d.metrics : {}) };
    const name = (d && d.name) || (mix && mix.name) || (b && b.name) || null;
    const hand = feeds.handedness && feeds.handedness.get(id);
    const usage = feeds.roles && feeds.roles.get(id);
    const ip = num(m.ip);
    rows.push({
      sport,
      season,
      source_id: id,
      player_key: name ? nameKey(name) : null,
      player_name: name,
      role: 'pitcher',
      // Handedness comes free on the movement feed — the ONLY feed that carries
      // it. Absent when a pitcher has thrown too few tracked pitches to appear.
      // statsapi is the authority for handedness; the movement feed is the
      // fallback for pitchers statsapi does not carry.
      throws: (hand && hand.throws) || (mix && mix.throws) || null,
      bats: (hand && hand.bats) || null,
      position: (hand && hand.position) || null,
      role_detail: roleDetail(usage),
      games_started: usage ? usage.gs : null,
      games_pitched: usage ? usage.gp : null,
      saves: usage ? usage.saves : null,
      holds: usage ? usage.holds : null,
      sample_pa: null,
      sample_ip: ip,
      sample_bip: num(m.bip),
      k_pct: num(m.k_pct),
      bb_pct: num(m.bb_pct),
      whiff_pct: num(m.whiff_pct),
      swing_pct: num(m.swing_pct),
      chase_pct: num(m.chase_pct),
      barrel_pct: num(m.barrel_pct_allowed),
      hard_hit_pct: num(m.hard_hit_pct_allowed),
      avg_exit_velo: num(m.avg_exit_velo),
      max_exit_velo: num(m.max_exit_velo),
      avg_launch_angle: num(m.avg_launch_angle),
      sweet_spot_pct: num(m.sweet_spot_pct_bb),
      ev95_pct: num(m.ev95_pct),
      arm_angle: num(m.arm_angle),
      gb_pct: num(m.gb_pct),
      fb_pct: num(m.fb_pct),
      ld_pct: num(m.ld_pct),
      pitch_mix: mix && mix.pitches && mix.pitches.length ? mix.pitches : null,
      metrics: m,
      updated_at: now,
      _sufficient: ip != null && ip >= MIN_IP,
    });
  }

  return rows;
}

/** Strip the internal flag before the row reaches Postgres. */
function toDbRow(r) {
  const { _sufficient, ...rest } = r;
  return rest;
}

/**
 * refreshSeason(opts) — THE JOB. Backfill and nightly refresh are the same call.
 *
 * opts: { season, sport, supabase, fetchSeason, now, chunkSize }
 * Returns a summary — never throws on a partial failure; a broken feed degrades
 * to fewer rows, which is honest, rather than to a wrong number.
 */
async function refreshSeason(opts = {}) {
  const season = opts.season || statcast.DEFAULT_SEASON;
  const sport = opts.sport || 'mlb';
  const started = opts.now || new Date().toISOString();

  const fetchSeason = opts.fetchSeason || statcast.fetchSeason;
  const feeds = await fetchSeason(season, opts);
  const rows = buildRows(season, feeds, { sport, now: started });

  const summary = {
    ok: true,
    sport,
    season,
    started_at: started,
    feed_counts: feeds.counts || null,
    rows: rows.length,
    batters: rows.filter((r) => r.role === 'batter').length,
    pitchers: rows.filter((r) => r.role === 'pitcher').length,
    sufficient: rows.filter((r) => r._sufficient).length,
    thin: rows.filter((r) => !r._sufficient).length,
    joined: rows.filter((r) => r.player_key).length,
    unjoined: rows.filter((r) => !r.player_key).length,
    with_handedness: rows.filter((r) => r.throws || r.bats).length,
    with_role: rows.filter((r) => r.role_detail).length,
    by_role: rows.reduce((a, r) => { if (r.role_detail) a[r.role_detail] = (a[r.role_detail] || 0) + 1; return a; }, {}),
    written: 0,
  };

  if (!rows.length) {
    // Every feed empty = the source failed, NOT "there is no mechanism data".
    // Refuse to write, so a bad night can never blank a good table.
    summary.ok = false;
    summary.reason = 'no rows from source — refusing to write';
    return summary;
  }

  const sb = opts.supabase || getServiceClient();
  if (!sb) {
    summary.ok = false;
    summary.reason = 'no supabase client (env not configured)';
    return summary;
  }

  // Chunked upsert — idempotent on the natural key, so re-running is a no-op
  // beyond refreshing values and `updated_at`.
  const chunk = opts.chunkSize || 500;
  for (let i = 0; i < rows.length; i += chunk) {
    const batch = rows.slice(i, i + chunk).map(toDbRow);
    const { error } = await sb
      .from('statcast_aggregates')
      // ROLE is part of the key: a two-way player has a real batter profile AND
      // a real pitcher profile, and merging them would invent one player out of
      // two genuinely different sets of measurements. (Found by inducing the
      // job: without role, one batch hit the same row twice and Postgres
      // refused the chunk.)
      .upsert(batch, { onConflict: 'sport,season,source_id,role' });
    if (error) {
      summary.ok = false;
      summary.reason = `upsert failed: ${error.message}`;
      return summary;
    }
    summary.written += batch.length;
  }

  return summary;
}

function getServiceClient() {
  try {
    return require('../utils/supabase').getSupabaseServiceClient();
  } catch {
    return null;
  }
}

/**
 * getFreshness(opts) — how current is the mechanism tier?
 * Reads the newest `updated_at`. Used by the staleness alarm and the status
 * probe. Returns { updated_at, age_hours, rows } or nulls — never a guess.
 */
async function getFreshness(opts = {}) {
  const sb = opts.supabase || getServiceClient();
  if (!sb) return { updated_at: null, age_hours: null, rows: null };
  const sport = opts.sport || 'mlb';
  const { data, error } = await sb
    .from('statcast_aggregates')
    .select('updated_at')
    .eq('sport', sport)
    .order('updated_at', { ascending: false })
    .limit(1);
  if (error || !data || !data.length) return { updated_at: null, age_hours: null, rows: null };
  const updated = data[0].updated_at;
  const nowMs = opts.nowMs || Date.now();
  const ageH = (nowMs - new Date(updated).getTime()) / 3_600_000;
  return { updated_at: updated, age_hours: Math.round(ageH * 10) / 10 };
}

/**
 * isStale(freshness, maxAgeHours) — PURE. The alarm predicate.
 *
 * NEVER-REFRESHED (updated_at null) is NOT stale — it is "not built yet", a
 * different condition with a different fix. Conflating them would page on a
 * fresh install and teach the operator to ignore the alarm.
 */
const MAX_AGE_HOURS = Number(process.env.STATCAST_MAX_AGE_HOURS) || 48;
function isStale(freshness, maxAgeHours = MAX_AGE_HOURS) {
  if (!freshness || freshness.updated_at == null) return false;
  return Number.isFinite(freshness.age_hours) && freshness.age_hours > maxAgeHours;
}

module.exports = {
  refreshSeason,
  buildRows,
  getFreshness,
  isStale,
  MIN_PA,
  MIN_IP,
  MAX_AGE_HOURS,
  __internals: { toDbRow, num, getServiceClient, roleDetail },
};
