'use strict';

/**
 * Internal ops endpoints (Session 18).
 *
 * Reachable only with the shared `VYNDR_INTERNAL_KEY` — never
 * exposed to end users. The admin dashboard wires the Tank01
 * prefetch button to POST here through the Next.js server (the
 * key never touches a browser).
 *
 * Deviation from spec: the spec suggested `execSync('node scripts/tank01-prefetch.js')`.
 * We import the module instead — same behavior, but in-process and
 * testable. The module already exposes `main(argv)` which returns
 * the same summary object the spec expected to parse out of stdout.
 */

const express = require('express');
const { requireInternalAuth } = require('../middleware/internalAuth');
const tank01Prefetch = require('../../scripts/tank01-prefetch');
const quotaTracker = require('../services/quotaTracker');

const router = express.Router();

router.use(requireInternalAuth({ loopbackOnly: false }));

/**
 * GET /api/internal/quota (Session 20)
 *
 * Snapshot of every configured provider's current quota counter.
 * Consumed by the admin dashboard's "Provider Quotas" tile. Cached
 * for 5s so a refresh-button mash doesn't flood Redis.
 */
router.get('/quota', async (req, res) => {
  try {
    const providers = await quotaTracker.getAllQuotaStatuses();
    res.set('Cache-Control', 'private, max-age=5');
    return res.json({ ok: true, providers });
  } catch (err) {
    const message = err && err.message ? err.message : String(err);
    console.error('[internal/quota] failed:', message);
    return res.status(500).json({ ok: false, error: message });
  }
});

/**
 * POST /api/internal/quota/test-alert (quota guard)
 *
 * Test-fires the quota pager end-to-end through the REAL opsNotify path so
 * "does the 80% alert actually deliver?" is verifiable on demand (the reason
 * the 500/500 drain went unnoticed was a counting blind spot, not delivery —
 * this proves the delivery leg). Sends one ntfy to vyndr-pipeline-kev2026.
 * Does NOT touch the real counter. Body: { pct? } (default 0.85).
 */
router.post('/quota/test-alert', async (req, res) => {
  try {
    const notify = require('../utils/opsNotify').notify;
    const pct = Number.isFinite(Number(req.body && req.body.pct)) ? Number(req.body.pct) : 0.85;
    const out = await notify(
      `TEST — odds-api quota alert delivery check (${Math.round(pct * 100)}%). If you can read this, the quota pager path works.`,
      { title: 'VYNDR quota (test)', priority: 'high', tags: ['warning', 'chart_decreasing'] },
    );
    return res.json({ ok: true, delivery: out });
  } catch (err) {
    const message = err && err.message ? err.message : String(err);
    return res.status(500).json({ ok: false, error: message });
  }
});

/**
 * POST /api/internal/prefetch/tank01
 *
 * Body (all optional):
 *   { max?: number, sports?: string[]|string, dryRun?: boolean }
 *
 * Builds an argv array equivalent to the CLI form and hands it to
 * the prefetch module. Returns the module's summary on success.
 */
router.post('/prefetch/tank01', async (req, res) => {
  const body = (req.body && typeof req.body === 'object') ? req.body : {};

  // Build argv. `main()` parses its own args, so all the validation
  // (numeric bounds, allowed sports) stays in one place — we just
  // translate JSON shapes into CLI flags.
  const argv = ['node', 'scripts/tank01-prefetch.js'];

  if (Number.isFinite(body.max) && body.max > 0) {
    argv.push(`--max=${Math.floor(body.max)}`);
  }
  if (body.dryRun === true) {
    argv.push('--dry-run');
  }
  if (body.sports) {
    const sportsList = Array.isArray(body.sports)
      ? body.sports.join(',')
      : String(body.sports);
    argv.push(`--sports=${sportsList}`);
  }

  try {
    const summary = await tank01Prefetch.main(argv);
    return res.json({ ok: true, summary });
  } catch (err) {
    const message = err && err.message ? err.message : String(err);
    console.error('[internal/prefetch/tank01] failed:', message);
    return res.status(500).json({ ok: false, error: message });
  }
});

/**
 * POST /api/internal/snapshot/:sport (Session 45)
 *
 * Trigger one snapshot cycle for a sport (pre-grade the slate, lock grades,
 * compute deltas, emit ticker events). Internal-only (requireInternalAuth at the
 * router root). This is what the cron / n8n schedule calls — never public, so a
 * bad actor can't drain the PropLine quota by spamming it.
 */
/** POST /api/internal/snapshot/all — every active sport, sequentially.
 *  Registered BEFORE /snapshot/:sport so "all" isn't captured as a sport. */
router.post('/snapshot/all', async (req, res) => {
  const snapshot = require('../services/snapshotService');
  try {
    const results = await snapshot.runAllSnapshots();
    return res.json({ ok: true, results });
  } catch (err) {
    const message = err && err.message ? err.message : String(err);
    console.error('[internal/snapshot/all] failed:', message);
    return res.status(500).json({ ok: false, error: message });
  }
});

/**
 * GET /api/internal/snapshot/status (Session 52) — verification probe. Reports
 * whether the in-process cron is armed, the freshest snapshot per sport, which
 * pipeline Redis keys exist, and the ticker item count. Read-only; safe to poll.
 * GET vs the POST /snapshot/:sport below — no route collision.
 */
router.get('/snapshot/status', async (req, res) => {
  const { cacheGet } = require('../utils/redis');
  const { HOURS_UTC, isSnapshotOverdue } = require('../snapshotScheduler');
  const SPORTS = ['mlb', 'nba', 'wnba'];
  try {
    const redis_keys = {};
    const last_snapshot = {};
    for (const sp of SPORTS) {
      const latestKey = `snapshot:${sp}:latest`;
      const prevKey = `snapshot:${sp}:previous`;
      const gradesKey = `grades:${sp}`;
      const [latest, prev, grades] = await Promise.all([cacheGet(latestKey), cacheGet(prevKey), cacheGet(gradesKey)]);
      redis_keys[latestKey] = !!latest;
      redis_keys[prevKey] = !!prev;
      redis_keys[gradesKey] = !!grades;
      if (latest) {
        last_snapshot[sp] = {
          updated_at: latest.updated_at || null,
          gradeCount: Array.isArray(latest.grades) ? latest.grades.length : 0,
          deltaCount: Array.isArray(latest.deltas) ? latest.deltas.length : 0,
        };
      }
    }
    const ticker = await cacheGet('ticker:items');
    redis_keys['ticker:items'] = !!ticker;
    // Session 56 — surface the missed-cron signal in the health probe.
    const mlbTs = last_snapshot.mlb && last_snapshot.mlb.updated_at;
    return res.json({
      cron_armed: process.env.SNAPSHOT_CRON === '1',
      cron_hours_utc: HOURS_UTC,
      last_snapshot,
      overdue: isSnapshotOverdue(mlbTs),
      redis_keys,
      ticker_count: Array.isArray(ticker) ? ticker.length : 0,
    });
  } catch (err) {
    return res.status(500).json({ ok: false, error: err.message });
  }
});

router.post('/snapshot/:sport', async (req, res) => {
  const snapshot = require('../services/snapshotService');
  try {
    const summary = await snapshot.runSnapshot(req.params.sport);
    return res.json({ ok: true, summary });
  } catch (err) {
    const message = err && err.message ? err.message : String(err);
    console.error('[internal/snapshot] failed:', message);
    return res.status(500).json({ ok: false, error: message });
  }
});

/**
 * POST /api/internal/outcomes/all (Session 55) — settle every sport's latest
 * snapshot against real results + recompute the overall accuracy record. This
 * is the self-learning loop's write path (the public /api/accuracy is read-only).
 * Registered BEFORE /outcomes/:sport so "all" isn't captured as a sport.
 */
router.post('/outcomes/all', async (req, res) => {
  const outcomes = require('../services/outcomeService');
  try {
    const results = await outcomes.settleAllOutcomes();
    return res.json({ ok: true, results });
  } catch (err) {
    const message = err && err.message ? err.message : String(err);
    console.error('[internal/outcomes/all] failed:', message);
    return res.status(500).json({ ok: false, error: message });
  }
});

/**
 * POST /api/internal/ghost/drafts (Session 63 / A1-S4d) — assemble today's
 * desk pack and save the slate-preview + settle drafts to Ghost. DRAFTS
 * ONLY; Kev publishes from the Ghost admin. Env-gated no-op without Ghost.
 */
router.post('/ghost/drafts', async (req, res) => {
  try {
    const { assembleDeskPack } = require('../services/deskService');
    const ghost = require('../services/ghostPublisher');
    const pack = await assembleDeskPack();
    const result = await ghost.saveDailyDrafts(pack);
    return res.json({ ok: result.ok, result });
  } catch (err) {
    const message = err && err.message ? err.message : String(err);
    console.error('[internal/ghost/drafts] failed:', message);
    return res.status(500).json({ ok: false, error: message });
  }
});

/**
 * POST /api/internal/refresh/all (Session 60, Phase 2.5) — manual intraday
 * odds-only refresh: STEAM/VALUE movement, public revisions, closing
 * capture. Same behavior as the in-process 20-min cadence.
 */
router.post('/refresh/all', async (req, res) => {
  const refresh = require('../services/intradayRefreshService');
  try {
    const results = await refresh.runAllIntradayRefreshes();
    return res.json({ ok: true, results });
  } catch (err) {
    const message = err && err.message ? err.message : String(err);
    console.error('[internal/refresh/all] failed:', message);
    return res.status(500).json({ ok: false, error: message });
  }
});

/**
 * POST /api/internal/backup/run (Session 64) — fire ONE real database backup
 * now: pg_dump → validate (`pg_restore --list` must contain ledger_entries) →
 * off-box rsync to the Storage Box.
 *
 * Exists because the backup can only run where SUPABASE_DB_URL and the Supabase
 * network route live — inside this container — and there was previously no way
 * to trigger or observe it without a shell on the box. Returns the script's
 * exit code and output tail so a real run can be VERIFIED, not assumed.
 */
router.post('/backup/run', async (req, res) => {
  const { runBackup, durabilityWarning } = require('../backupScheduler');
  try {
    const started = Date.now();
    const result = await runBackup();
    // Session 64 Phase 2b — off-box is REQUIRED now, so its outcome is reported
    // SEPARATELY from the exit code. The script deliberately still exits 0 on a
    // failed push (a durable on-box dump must not raise a false total-failure
    // alarm) — so without this field a failed off-box push reads as success.
    const tail = result.tail || '';
    const offboxOk = /OFFBOX_OK=1/.test(tail) ? true
      : (/OFFBOX_OK=0/.test(tail) ? false : null);   // null = deferred/not attempted
    return res.json({
      ok: result.ok,
      offbox_ok: offboxOk,
      exit_code: result.code,
      duration_ms: Date.now() - started,
      durability_warning: durabilityWarning() || null,
      remote_configured: !!process.env.BACKUP_REMOTE,
      ssh_key_configured: !!process.env.BACKUP_SSH_KEY,
      output_tail: result.tail || result.error || null,
    });
  } catch (err) {
    const message = err && err.message ? err.message : String(err);
    console.error('[internal/backup/run] failed:', message);
    return res.status(500).json({ ok: false, error: message });
  }
});

/**
 * GET /api/internal/backup/verify (Session 64) — prove the newest dump on the
 * persistent volume actually CONTAINS the data, by counting `ledger_entries`
 * rows out of the archive with pg_restore. A backup nobody has read back is a
 * hope, not a backup.
 */
router.get('/backup/verify', async (req, res) => {
  const { latestDump, countRowsInDump } = require('../backupScheduler');
  try {
    const dir = process.env.BACKUP_DIR || '/var/backups/vyndr';
    // Report identity + writability: a mounted-but-unwritable volume is the
    // exact failure we hit (Coolify mounts root-owned; the container runs as
    // the non-root `vyndr` user), and the fix needs the real uid/gid.
    const fs = require('fs');
    let writable = false;
    let dirErr = null;
    try {
      fs.accessSync(dir, fs.constants.W_OK);
      writable = true;
    } catch (e) { dirErr = e.code || e.message; }
    const identity = {
      uid: typeof process.getuid === 'function' ? process.getuid() : null,
      gid: typeof process.getgid === 'function' ? process.getgid() : null,
      backup_dir_writable: writable,
      backup_dir_error: dirErr,
    };

    const dump = latestDump(dir);
    if (!dump) {
      return res.json({ ok: false, backup_dir: dir, ...identity, error: 'no dump found in BACKUP_DIR' });
    }
    const table = String(req.query.table || 'ledger_entries');
    const counted = await countRowsInDump(dump.path, table);
    return res.json({
      ok: counted.ok,
      backup_dir: dir,
      ...identity,
      dump: dump.file,
      dump_bytes: dump.size,
      table,
      rows_in_dump: counted.rows,
      error: counted.error || null,
    });
  } catch (err) {
    const message = err && err.message ? err.message : String(err);
    console.error('[internal/backup/verify] failed:', message);
    return res.status(500).json({ ok: false, error: message });
  }
});

/**
 * GET /api/internal/backup/offbox — list the dumps actually present ON the
 * Storage Box. Exit 0 from the backup is not proof the file landed; this reads
 * the remote directory back so off-box presence is verified, not inferred.
 */
router.get('/backup/offbox', async (req, res) => {
  const { listOffbox } = require('../backupScheduler');
  try {
    const result = await listOffbox();
    return res.json(result);
  } catch (err) {
    const message = err && err.message ? err.message : String(err);
    return res.status(500).json({ ok: false, error: message });
  }
});

/**
 * POST /api/internal/harness/run (Session 64) — induce the nightly harness run
 * on demand. Scheduled mechanisms are verified by INDUCING their real code
 * path, never by waiting for a slot to discover whether they work.
 */
router.post('/harness/run', async (req, res) => {
  try {
    const runner = require('../services/harnessRunner');
    const out = await runner.runAndRecord();
    return res.json({ ok: out.ok !== false, ...out, last_run_at: await runner.lastRunAt() });
  } catch (err) {
    return res.status(500).json({ ok: false, error: err.message });
  }
});

/**
 * POST /api/internal/closing/capture/:sport (Session 64) — induce one closing
 * capture pass against the CURRENT live odds, so the mechanism is proven now
 * rather than discovered tomorrow.
 */
router.post('/closing/capture/:sport', async (req, res) => {
  try {
    const sp = String(req.params.sport || '').toLowerCase();
    const closing = require('../services/closingCapture');
    const odds = await require('../services/oddsService').getOdds(sp);
    const props = (odds && Array.isArray(odds.props)) ? odds.props : [];
    await require('../services/gameBinder').attachGameTimes(sp, props, {});
    const windowMinutes = Number(req.query.window || 0) || undefined;
    const rows = closing.buildCaptureRows(sp, props, { windowMinutes });
    const priced = rows.filter((r) => !r.missed_reason).length;
    const persisted = req.query.dry === '1' ? { skipped: true } : await closing.persist(rows);
    const reasons = {};
    for (const r of rows) if (r.missed_reason) reasons[r.missed_reason] = (reasons[r.missed_reason] || 0) + 1;
    return res.json({
      ok: true, sport: sp, props: props.length,
      rows: rows.length, priced, missed: rows.length - priced,
      missed_reasons: reasons, persisted,
    });
  } catch (err) {
    return res.status(500).json({ ok: false, error: err.message });
  }
});

/**
 * POST /api/internal/ledger/settle (Session 58, Phase 1) — settle the
 * persistent ledger (outcome + actual_value + CLV) across every sport.
 * Idempotent — safe to re-run; already-settled rows are never touched.
 */
router.post('/ledger/settle', async (req, res) => {
  const ledger = require('../services/ledgerService');
  try {
    const results = await ledger.settleAllLedgers();
    return res.json({ ok: true, results });
  } catch (err) {
    const message = err && err.message ? err.message : String(err);
    console.error('[internal/ledger/settle] failed:', message);
    return res.status(500).json({ ok: false, error: message });
  }
});

/**
 * POST /api/internal/newsletter/send (Session S7, a1) — assemble today's
 * VYNDR REPORT from the pipeline (snapshot signals + streak lens + the
 * ledger record) and send it as a Listmonk campaign to the opted-in list.
 *
 * DELIBERATELY UNSCHEDULED: nothing calls this on a timer. The operator
 * (or a future n8n cron, once Kev arms it) triggers the send. Env-gated —
 * without LISTMONK_* config it's a calm no-op; an empty report (zero
 * signals AND zero streaks) refuses to send.
 *
 * Body (optional): { sports?: string[] }  — defaults to ['mlb', 'wnba'].
 */
router.post('/newsletter/send', async (req, res) => {
  const newsletter = require('../services/newsletterService');
  const body = (req.body && typeof req.body === 'object') ? req.body : {};
  const sports = Array.isArray(body.sports) && body.sports.length > 0 ? body.sports : undefined;
  try {
    const result = await newsletter.sendDailyReport({ sports });
    return res.json(result);
  } catch (err) {
    const message = err && err.message ? err.message : String(err);
    console.error('[internal/newsletter/send] failed:', message);
    return res.status(500).json({ ok: false, error: message });
  }
});

/** POST /api/internal/gamectx/:date — induce a game-context capture on demand. */
router.post('/gamectx/:date', async (req, res) => {
  try {
    const gc = require('../services/gameContext');
    const out = await gc.captureDate(req.params.date, { sport: req.query.sport || 'mlb' });
    return res.status(out.ok ? 200 : 500).json(out);
  } catch (err) { return res.status(500).json({ ok: false, error: err.message }); }
});

/** GET /api/internal/gamectx/accrual — how far from a self-derived park factor. */
router.get('/gamectx/accrual', async (req, res) => {
  try {
    const gc = require('../services/gameContext');
    return res.json(await gc.accrualStatus({ sport: req.query.sport || 'mlb' }));
  } catch (err) { return res.status(500).json({ error: err.message }); }
});

/**
 * POST /api/internal/statcast/refresh — INDUCE the Layer-1 mechanism refresh.
 *
 * Same call the nightly scheduler makes. Exists so the job is verifiable ON
 * DEMAND: we prove a refresh works by running it and reading the result, never
 * by waiting for the cron slot. Idempotent — running it twice is a no-op beyond
 * refreshing values and updated_at.
 */
router.post('/statcast/refresh', async (req, res) => {
  try {
    const agg = require('../services/statcastAggregateService');
    const season = req.query.season ? Number(req.query.season) : undefined;
    const out = await agg.refreshSeason({ season });
    const fresh = await agg.getFreshness({});
    return res.status(out.ok ? 200 : 500).json({ ...out, freshness: fresh, stale: agg.isStale(fresh) });
  } catch (err) {
    console.error('[internal/statcast]', err.message);
    return res.status(500).json({ ok: false, error: err.message });
  }
});

/** GET /api/internal/statcast/status — freshness probe for the mechanism tier. */
router.get('/statcast/status', async (req, res) => {
  try {
    const agg = require('../services/statcastAggregateService');
    const fresh = await agg.getFreshness({});
    return res.json({
      ...fresh,
      stale: agg.isStale(fresh),
      max_age_hours: agg.MAX_AGE_HOURS,
      min_pa: agg.MIN_PA,
      min_ip: agg.MIN_IP,
      cron_hour_utc: Number(process.env.STATCAST_HOUR_UTC || 11),
      enabled: process.env.STATCAST !== '0',
    });
  } catch (err) {
    return res.status(500).json({ error: err.message });
  }
});

router.post('/outcomes/:sport', async (req, res) => {
  const outcomes = require('../services/outcomeService');
  try {
    const summary = await outcomes.settleSnapshot(req.params.sport);
    await outcomes.recomputeOverall();
    return res.json({ ok: true, summary: { sport: summary.sport, settled: summary.settled, pending: summary.pending, accuracy: summary.accuracy } });
  } catch (err) {
    const message = err && err.message ? err.message : String(err);
    console.error('[internal/outcomes] failed:', message);
    return res.status(500).json({ ok: false, error: message });
  }
});

module.exports = router;
