/**
 * Secret admin panel.
 *
 * Two independent gates:
 *   1. The URL slug (ADMIN_SLUG) — the panel is mounted at /<slug>/ and is
 *      un-discoverable without it. Unknown paths 404 like any other.
 *   2. A password, checked either against ADMIN_PASSWORD (timing-safe
 *      comparison) or against a scrypt hash persisted at
 *      data/admin-auth.json, exchanged for a short-lived in-memory bearer
 *      token.
 *
 * There is NO hardcoded or auto-generated password: this repo is public and
 * the panel can push patches fleet-wide, so a leaked default would be a
 * supply-chain risk. On first launch (no ADMIN_PASSWORD, no admin-auth.json)
 * the panel makes the owner create one, which is hashed and saved to disk.
 * If ADMIN_SLUG is unset, one is generated at boot and printed to the server
 * console so the self-hoster (and only they) learns it.
 *
 * The panel shows telemetry counters, lets the owner tune runtime config
 * knobs, and lets the owner replace the bundled Chrome extension with a new
 * .zip. It exposes nothing else.
 */
import { randomBytes, scryptSync, timingSafeEqual } from 'node:crypto';
import express, { type Request, type Response, type Router } from 'express';
import fs from 'node:fs';
import path from 'node:path';
import type { Telemetry } from './telemetry';
import {
  getConfigStatus, resetConfigOverrides, writeConfigOverrides, type RuntimeConfig,
} from '../core/config';
import { listSev1, sev1Count } from '../core/alerts';
import {
  applyUpdateFromUrl,
  compareVersions,
  isLegacyInstall,
  latestRelease,
  listPatchVersions,
  readApproval,
  restorePatch,
  scheduleRestart,
  snapshotCurrent,
  versionPayload,
  writeApproval,
} from './update';
import { VERSION } from '../version';

export type AdminPasswordState = 'env' | 'saved' | 'unset';

export interface AdminConfig {
  slug: string;
  /** The env password when passwordState === 'env'; otherwise null (never the plaintext of a saved hash). */
  password: string | null;
  passwordState: AdminPasswordState;
  /** Path to the persisted { algo, salt, hash, createdAt } credential file. */
  authPath: string;
  /** Path where an uploaded override zip is stored (takes precedence over the folder). */
  extensionOverridePath: string;
}

export interface AdminPaths {
  root: string;
  dataDir: string;
}

interface AdminAuthFile {
  algo: 'scrypt';
  salt: string;
  hash: string;
  createdAt: string;
}

function readAdminAuthFile(authPath: string): AdminAuthFile | null {
  try {
    if (!fs.existsSync(authPath)) return null;
    return JSON.parse(fs.readFileSync(authPath, 'utf8')) as AdminAuthFile;
  } catch {
    return null;
  }
}

function writeAdminAuthFile(authPath: string, password: string): void {
  const salt = randomBytes(16);
  const hash = scryptSync(password, salt, 64);
  const data: AdminAuthFile = {
    algo: 'scrypt',
    salt: salt.toString('hex'),
    hash: hash.toString('hex'),
    createdAt: new Date().toISOString(),
  };
  fs.mkdirSync(path.dirname(authPath), { recursive: true });
  fs.writeFileSync(authPath, JSON.stringify(data, null, 2) + '\n');
}

function verifyAdminAuthFile(authPath: string, password: string): boolean {
  const data = readAdminAuthFile(authPath);
  if (!data) return false;
  try {
    const salt = Buffer.from(data.salt, 'hex');
    const expected = Buffer.from(data.hash, 'hex');
    const actual = scryptSync(password, salt, expected.length);
    if (actual.length !== expected.length) return false;
    return timingSafeEqual(actual, expected);
  } catch {
    return false;
  }
}

/** Resolve slug + password state from env/disk, generating + logging the slug if missing. */
export function resolveAdminConfig(dataDir: string): AdminConfig {
  let slug = process.env.ADMIN_SLUG?.trim();
  const generated: string[] = [];
  if (!slug) { slug = randomBytes(6).toString('hex'); generated.push(`ADMIN_SLUG=${slug}`); }
  if (generated.length) {
    // eslint-disable-next-line no-console
    console.log(`\n  ⚑ admin panel: /${slug}   (set ADMIN_SLUG in .env to persist)\n     ${generated.join('\n     ')}\n`);
  } else {
    // eslint-disable-next-line no-console
    console.log(`  ⚑ admin panel mounted at /${slug}`);
  }

  const authPath = path.join(dataDir, 'admin-auth.json');
  const envPassword = process.env.ADMIN_PASSWORD?.trim();
  let passwordState: AdminPasswordState;
  let password: string | null;
  if (envPassword) {
    passwordState = 'env';
    password = envPassword;
  } else if (fs.existsSync(authPath)) {
    passwordState = 'saved';
    password = null;
  } else {
    passwordState = 'unset';
    password = null;
    // eslint-disable-next-line no-console
    console.log(`  ⚑ no admin password set yet — visit /${slug} to create one (first launch only)`);
  }

  return { slug, password, passwordState, authPath, extensionOverridePath: path.join(dataDir, 'extension-override.zip') };
}

function timingSafeEqualStr(a: string, b: string): boolean {
  const ab = Buffer.from(a);
  const bb = Buffer.from(b);
  if (ab.length !== bb.length) return false;
  return timingSafeEqual(ab, bb);
}

export function createAdminRouter(cfg: AdminConfig, telemetry: Telemetry, paths: AdminPaths): Router {
  const router = express.Router();
  const originDefault = (process.env.ORIGIN_URL || 'https://rha.software').replace(/\/$/, '');

  // token -> expiry(ms). In-memory only: tokens die with the process.
  const tokens = new Map<string, number>();
  const TTL = 2 * 60 * 60 * 1000;
  const now = () => Date.now();
  const issue = (): string => {
    const t = randomBytes(24).toString('hex');
    tokens.set(t, now() + TTL);
    return t;
  };
  const valid = (t: string | undefined): boolean => {
    if (!t) return false;
    const exp = tokens.get(t);
    if (!exp) return false;
    if (exp < now()) { tokens.delete(t); return false; }
    return true;
  };
  const requireAuth = (req: Request, res: Response, next: () => void) => {
    const h = req.headers.authorization || '';
    const t = h.startsWith('Bearer ') ? h.slice(7) : '';
    if (!valid(t)) { res.status(401).json({ error: 'unauthorized' }); return; }
    next();
  };

  router.get('/', (_req, res) => {
    const legacy = isLegacyInstall(paths.root);
    const brand = legacy
      ? 'RHA<span>Wrapper</span> <em class="legacy">Legacy</em> · admin'
      : 'RHA<span>Wrapper</span> · admin';
    res.type('html').send(
      ADMIN_HTML
        .replaceAll('__SLUG__', cfg.slug)
        .replaceAll('__VERSION__', VERSION)
        .replaceAll('__ORIGIN__', originDefault)
        .replaceAll('__BRAND__', brand),
    );
  });

  // Unauth (the slug already gates discovery): lets the UI decide whether to
  // show first-run setup or the normal unlock form.
  router.get('/state', (_req, res) => {
    res.json({ configured: cfg.passwordState !== 'unset' });
  });

  // First-run only: create the persisted password. 409s once configured.
  router.post('/setup', express.json(), (req, res) => {
    if (cfg.passwordState !== 'unset') {
      res.status(409).json({ error: 'already configured' });
      return;
    }
    const password = String(req.body?.password ?? '');
    if (password.length < 8) {
      res.status(400).json({ error: 'password must be at least 8 characters' });
      return;
    }
    try {
      writeAdminAuthFile(cfg.authPath, password);
    } catch (e) {
      res.status(500).json({ error: `write failed: ${e instanceof Error ? e.message : String(e)}` });
      return;
    }
    cfg.passwordState = 'saved';
    res.json({ token: issue() });
  });

  router.post('/auth', express.json(), (req, res) => {
    const password = String(req.body?.password ?? '');
    if (cfg.passwordState === 'env') {
      if (!cfg.password || !timingSafeEqualStr(password, cfg.password)) {
        res.status(401).json({ error: 'incorrect password' });
        return;
      }
    } else if (cfg.passwordState === 'saved') {
      if (!verifyAdminAuthFile(cfg.authPath, password)) {
        res.status(401).json({ error: 'incorrect password' });
        return;
      }
    } else {
      res.status(401).json({ error: 'no admin password configured yet — set one up first' });
      return;
    }
    res.json({ token: issue() });
  });

  router.get('/telemetry', requireAuth, (_req, res) => {
    res.json(telemetry.snapshot());
  });

  router.get('/sev1', requireAuth, (_req, res) => {
    res.json({ count: sev1Count(), recent: listSev1(30) });
  });

  // Owner token for fetching consented bug reports from rha.software (KV-backed).
  const bugCfgPath = () => path.join(paths.dataDir, 'bug-report-config.json');
  router.get('/bug-report-config', requireAuth, (_req, res) => {
    try {
      if (!fs.existsSync(bugCfgPath())) {
        res.json({ token: '', host: 'https://rha.software' });
        return;
      }
      const j = JSON.parse(fs.readFileSync(bugCfgPath(), 'utf8')) as { token?: string; host?: string };
      res.json({ token: typeof j.token === 'string' ? j.token : '', host: j.host || 'https://rha.software' });
    } catch (e) {
      res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
    }
  });
  router.post('/bug-report-config', requireAuth, express.json(), (req, res) => {
    try {
      const token = typeof req.body?.token === 'string' ? req.body.token.trim() : '';
      const host = typeof req.body?.host === 'string' && req.body.host.trim()
        ? req.body.host.trim().replace(/\/$/, '')
        : 'https://rha.software';
      fs.mkdirSync(paths.dataDir, { recursive: true });
      fs.writeFileSync(bugCfgPath(), JSON.stringify({ token, host }, null, 2) + '\n');
      res.json({ ok: true, host, hasToken: !!token });
    } catch (e) {
      res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
    }
  });

  router.get('/config', requireAuth, (_req, res) => {
    res.json(getConfigStatus());
  });

  router.post('/config', requireAuth, express.json(), (req, res) => {
    try {
      const effective = writeConfigOverrides((req.body ?? {}) as Partial<RuntimeConfig>);
      res.json(effective);
    } catch (e) {
      res.status(400).json({ error: e instanceof Error ? e.message : String(e) });
    }
  });

  router.post('/config/reset', requireAuth, express.json(), (_req, res) => {
    res.json(resetConfigOverrides());
  });

  router.get('/release', requireAuth, (_req, res) => {
    const local = versionPayload(paths.root, paths.dataDir);
    const approval = readApproval(paths.dataDir);
    res.json({
      localVersion: VERSION,
      legacy: isLegacyInstall(paths.root),
      published: local,
      approval,
      patches: listPatchVersions(paths.root).slice().reverse(),
      originUrl: originDefault,
    });
  });

  router.post('/release/approve', requireAuth, express.json(), (_req, res) => {
    const latest = latestRelease(paths.root);
    if (!latest) {
      res.status(400).json({ error: 'no release.json history — run npm run release first' });
      return;
    }
    writeApproval(paths.dataDir, latest.version);
    res.json({ ok: true, approvedVersion: latest.version });
  });

  router.post('/release/check', requireAuth, express.json(), async (req, res) => {
    const origin = String(req.body?.originUrl || originDefault).replace(/\/$/, '');
    try {
      const u = new URL(`${origin}/api/version`);
      const localHost = u.hostname === 'localhost' || u.hostname === '127.0.0.1';
      if (u.protocol !== 'https:' && !(u.protocol === 'http:' && localHost)) {
        res.status(400).json({ error: 'ORIGIN_URL must be https (or http://localhost for testing)' });
        return;
      }
      const r = await fetch(u.toString());
      if (!r.ok) {
        res.status(502).json({ error: `origin /api/version HTTP ${r.status}` });
        return;
      }
      const remote = await r.json() as {
        version: string; hash: string; sizeKB: number; publishedAt: string; approved: boolean;
      };
      const newer = compareVersions(remote.version, VERSION) > 0;
      res.json({
        origin,
        remote,
        localVersion: VERSION,
        updateAvailable: !!(remote.approved && newer),
        upToDate: !remote.approved || !newer,
      });
    } catch (e) {
      res.status(502).json({ error: e instanceof Error ? e.message : String(e) });
    }
  });

  router.post('/release/apply', requireAuth, express.json(), async (req, res) => {
    const origin = String(req.body?.originUrl || originDefault).replace(/\/$/, '');
    const confirmHash = String(req.body?.hash || '');
    const confirmVersion = String(req.body?.version || '');
    try {
      const verRes = await fetch(`${origin}/api/version`);
      if (!verRes.ok) throw new Error(`origin /api/version HTTP ${verRes.status}`);
      const remote = await verRes.json() as {
        version: string; hash: string; approved: boolean;
      };
      if (!remote.approved) {
        res.status(409).json({ error: 'origin has not approved a fleet upgrade' });
        return;
      }
      if (compareVersions(remote.version, VERSION) <= 0) {
        res.status(409).json({ error: 'already up to date' });
        return;
      }
      if (confirmVersion && confirmVersion !== remote.version) {
        res.status(400).json({ error: 'version confirmation mismatch' });
        return;
      }
      if (confirmHash && confirmHash !== remote.hash) {
        res.status(400).json({ error: 'hash confirmation mismatch' });
        return;
      }
      const latestRes = await fetch(`${origin}/api/update/latest`);
      if (latestRes.status === 204) {
        res.status(409).json({ error: 'origin reports no approved update' });
        return;
      }
      if (!latestRes.ok) throw new Error(`origin /api/update/latest HTTP ${latestRes.status}`);
      const latest = await latestRes.json() as { version: string; url: string; hash: string };
      if (latest.hash !== remote.hash) {
        res.status(502).json({ error: 'origin version.hash ≠ update.hash — refusing' });
        return;
      }
      await applyUpdateFromUrl({
        root: paths.root,
        downloadUrl: latest.url,
        expectedHash: latest.hash,
        newVersion: latest.version,
      });
      res.json({
        ok: true,
        from: VERSION,
        to: latest.version,
        hash: latest.hash,
        restarting: true,
      });
      scheduleRestart();
    } catch (e) {
      res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
    }
  });

  router.post('/release/revert', requireAuth, express.json(), (req, res) => {
    const version = String(req.body?.version || '');
    if (!listPatchVersions(paths.root).includes(version)) {
      res.status(404).json({ error: `patch ${version} not found` });
      return;
    }
    try {
      // Keep the current tip so Legacy detection works after reverting.
      snapshotCurrent(paths.root, VERSION);
      restorePatch(paths.root, version);
      res.json({ ok: true, restored: version, restarting: true });
      scheduleRestart();
    } catch (e) {
      res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
    }
  });

  // Replace the served extension with an uploaded zip. Raw body, zip only.
  router.post('/extension', requireAuth, express.raw({ type: 'application/zip', limit: '25mb' }), (req, res) => {
    const buf = req.body as Buffer;
    if (!Buffer.isBuffer(buf) || buf.length < 4 || buf[0] !== 0x50 || buf[1] !== 0x4b) {
      res.status(400).json({ error: 'not a .zip file (bad magic bytes)' });
      return;
    }
    try {
      fs.mkdirSync(path.dirname(cfg.extensionOverridePath), { recursive: true });
      fs.writeFileSync(cfg.extensionOverridePath, buf);
    } catch (e) {
      res.status(500).json({ error: `write failed: ${e instanceof Error ? e.message : String(e)}` });
      return;
    }
    res.json({ ok: true, bytes: buf.length });
  });

  router.delete('/extension', requireAuth, (_req, res) => {
    try { fs.rmSync(cfg.extensionOverridePath, { force: true }); } catch { /* ignore */ }
    res.json({ ok: true });
  });

  return router;
}

/**
 * User-facing telemetry page at a stable path (/admin). Shows the same six
 * counters + daily trend as the owner panel, with no auth and no distribution
 * controls. Per-instance local numbers only — the instance owner's own data.
 */
export function createUserAdminRouter(telemetry: Telemetry): Router {
  const router = express.Router();
  router.get('/', (_req, res) => {
    res.type('html').send(USER_ADMIN_HTML.replaceAll('__VERSION__', VERSION));
  });
  router.get('/telemetry', (_req, res) => {
    res.json(telemetry.snapshot());
  });
  return router;
}

const USER_ADMIN_HTML = `<!doctype html>
<html lang="en"><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/svg+xml" href="/logo.svg">
<title>RHA Wrapper · admin</title>
<style>
  @font-face { font-family:"Trajan Pro"; font-weight:400 700; font-style:normal; font-display:swap; src:url("/fonts/TrajanPro3-Semibold.otf") format("opentype"); }
  * { box-sizing: border-box; margin: 0; padding: 0; }
  :root { --bg:#08060f; --panel:#130f1e; --line:#2b2440; --ink:#e8e2f7; --dim:#9990b3; --accent:#b14dff; --frost:#5fd7ee; --ok:#3fb96f; --bad:#e5604c; --warn:#d9a441;
    --font-body:"Trajan Pro","Trajan","Copperplate",serif; --font-display:var(--font-body); }
  body { background:var(--bg); color:var(--ink); font:14px/1.6 var(--font-body); }
  b, strong, h1 { font-weight:700; }
  .wrap { max-width:760px; margin:0 auto; padding:40px 24px; }
  .brand { display:flex; align-items:center; gap:10px; text-decoration:none; margin-bottom:2px; }
  .brand img { filter: drop-shadow(0 0 6px rgba(177,77,255,0.5)); }
  h1 { font-size:18px; letter-spacing:1px; font-family:var(--font-display); color:var(--ink); } h1 span { color:var(--frost); }
  .sub { color:var(--dim); font-size:12px; margin:4px 0 28px; }
  .grid { display:grid; grid-template-columns:repeat(3,1fr); gap:12px; margin:20px 0; }
  .metric { background:var(--panel); border:1px solid var(--line); border-radius:8px; padding:16px; text-align:center; }
  .metric b { display:block; font-size:26px; font-variant-numeric:tabular-nums; }
  .metric span { color:var(--dim); font-size:11px; }
  .metric.ratio b { color:var(--ok); }
  .sec { font-size:13px; letter-spacing:.4px; margin:28px 0 10px; color:var(--dim); }
  table { width:100%; border-collapse:collapse; font-size:12px; }
  th,td { text-align:right; padding:5px 8px; border-bottom:1px solid var(--line); font-variant-numeric:tabular-nums; }
  th:first-child,td:first-child { text-align:left; }
  th { color:var(--dim); font-weight:500; }
  a { color:var(--accent); }
</style></head><body>
<div class="wrap">
  <a class="brand" href="/" title="RHA Wrapper — home"><img src="/logo.svg" alt="RHA Wrapper" width="34" height="34"><h1>RHA<span>Wrapper</span> · admin</h1></a>
  <div class="sub">this instance's telemetry — <code>__VERSION__</code> — <a href="/privacy.html">privacy</a> · <a href="/">back to app</a></div>
  <div class="grid" id="metrics"></div>
  <div class="sec">success / failure</div>
  <div class="grid" style="grid-template-columns:repeat(3,1fr)" id="ratiorow"></div>
  <div class="sec">daily (last 14 days)</div>
  <div style="overflow-x:auto"><table id="daily"></table></div>
</div>
<script>
const LABEL = { hits:'human hits', botHits:'bot hits', pluginDownloads:'plugin downloads', runsExecuted:'runs executed', runsSucceeded:'runs succeeded', runsFailed:'runs failed' };
async function load() {
  const t = await fetch('/admin/telemetry').then(r => r.json());
  const top = ['hits','botHits','pluginDownloads','runsExecuted'];
  document.getElementById('metrics').innerHTML = top.map(k =>
    '<div class="metric"><b>'+(t.totals[k]||0).toLocaleString()+'</b><span>'+LABEL[k]+'</span></div>').join('');
  const s = t.totals.runsSucceeded||0, f = t.totals.runsFailed||0, tot = s+f;
  const pct = tot ? Math.round(s/tot*100) : 0;
  document.getElementById('ratiorow').innerHTML =
    '<div class="metric"><b>'+s+'</b><span>succeeded</span></div>' +
    '<div class="metric"><b>'+f+'</b><span>failed</span></div>' +
    '<div class="metric ratio"><b>'+pct+'%</b><span>success rate ('+tot+' finished)</span></div>';
  const days = Object.keys(t.daily).sort().slice(-14).reverse();
  const cols = t.keys;
  document.getElementById('daily').innerHTML =
    '<thead><tr><th>day</th>'+cols.map(c=>'<th>'+c.replace('Hits','').replace('runs','').replace('plugin','plug.')+'</th>').join('')+'</tr></thead>' +
    '<tbody>'+days.map(d=>'<tr><td>'+d+'</td>'+cols.map(c=>'<td>'+(t.daily[d][c]||0)+'</td>').join('')+'</tr>').join('')+'</tbody>';
}
load().catch(e => { document.getElementById('metrics').innerHTML = '<p style="color:var(--bad)">'+e.message+'</p>'; });
</script>
</body></html>`;

// Self-contained admin page — same design tokens as the app.
const ADMIN_HTML = `<!doctype html>
<html lang="en"><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/svg+xml" href="/logo.svg">
<title>RHA · admin</title>
<style>
  @font-face { font-family:"Trajan Pro"; font-weight:400 700; font-style:normal; font-display:swap; src:url("/fonts/TrajanPro3-Semibold.otf") format("opentype"); }
  * { box-sizing: border-box; margin: 0; padding: 0; }
  :root { --bg:#08060f; --panel:#130f1e; --line:#2b2440; --ink:#e8e2f7; --dim:#9990b3; --accent:#b14dff; --violet:#b14dff; --frost:#5fd7ee; --ok:#3fb96f; --bad:#e5604c; --warn:#d9a441;
    /* Archaic Latin, one font. REVERT: --font-body to "SF Mono",ui-monospace,Menlo,monospace */
    --font-body:"Trajan Pro","Trajan","Copperplate",serif; --font-display:var(--font-body); }
  body { background:var(--bg); color:var(--ink); font:14px/1.6 var(--font-body); }
  b, strong, h1 { font-weight:700; } em, i { font-style:italic; }
  .wrap { max-width:760px; margin:0 auto; padding:40px 24px; }
  .brand { display:flex; align-items:center; gap:10px; text-decoration:none; margin-bottom:2px; }
  .brand img { filter: drop-shadow(0 0 6px rgba(177,77,255,0.5)); }
  h1 { font-size:18px; letter-spacing:1px; font-family:var(--font-display); color:var(--ink); } h1 span { color:var(--frost); }
  .sub { color:var(--dim); font-size:12px; margin:4px 0 28px; }
  .card { background:var(--panel); border:1px solid var(--line); border-radius:8px; padding:18px; }
  input { background:var(--bg); color:var(--ink); border:1px solid var(--line); border-radius:6px; padding:9px 11px; font:inherit; width:100%; }
  input:focus { outline:none; border-color:var(--accent); }
  button { font:inherit; cursor:pointer; background:var(--accent); color:#fff; border:0; border-radius:6px; padding:9px 16px; font-weight:600; }
  button.ghost { background:none; border:1px solid var(--line); color:var(--ink); font-weight:400; }
  button.danger { background:none; border:1px solid var(--bad); color:var(--bad); font-weight:400; }
  .err { color:var(--bad); font-size:12px; margin-top:10px; min-height:16px; }
  .grid { display:grid; grid-template-columns:repeat(3,1fr); gap:12px; margin:20px 0; }
  .metric { background:var(--panel); border:1px solid var(--line); border-radius:8px; padding:16px; text-align:center; }
  .metric b { display:block; font-size:26px; font-variant-numeric:tabular-nums; }
  .metric span { color:var(--dim); font-size:11px; }
  .metric.ratio b { color:var(--ok); }
  .sec { font-size:13px; letter-spacing:.4px; margin:28px 0 10px; color:var(--dim); }
  table { width:100%; border-collapse:collapse; font-size:12px; }
  th,td { text-align:right; padding:5px 8px; border-bottom:1px solid var(--line); font-variant-numeric:tabular-nums; }
  th:first-child,td:first-child { text-align:left; }
  th { color:var(--dim); font-weight:500; }
  .row { display:flex; gap:10px; align-items:center; flex-wrap:wrap; }
  .hidden { display:none; }
  .note { color:var(--dim); font-size:11.5px; margin-top:8px; }
  a { color:var(--accent); }
  em.legacy { color:var(--warn); font-style:normal; font-weight:700; }
  code.hash { font-size:11px; word-break:break-all; color:var(--frost); }
  .field { display:flex; flex-direction:column; gap:5px; }
  .field label { font-size:11.5px; color:var(--dim); }
  .field .fieldrow { display:flex; align-items:center; gap:8px; }
  .field .fieldrow input[type=checkbox] { width:auto; }
  .cfggrid { display:grid; grid-template-columns:repeat(2,1fr); gap:14px; }
  @media (max-width:520px) { .cfggrid { grid-template-columns:1fr; } }
  details.advanced { margin-top:14px; }
  details.advanced summary { cursor:pointer; color:var(--dim); font-size:12px; }
</style></head><body>
<div class="wrap">
  <a class="brand" href="/landing.html" title="RHA Wrapper — home"><img src="/logo.svg" alt="RHA Wrapper" width="34" height="34"><h1>__BRAND__</h1></a>
  <div class="sub">telemetry &amp; distribution — <code>__VERSION__</code> — <a href="/privacy.html" target="_blank">privacy policy</a></div>

  <div id="setup" class="card hidden">
    <div class="note" style="margin-top:0">First launch — there is no default password. Create one now; it's hashed with scrypt and saved to <code>data/admin-auth.json</code> (never the plaintext).</div>
    <div class="row" style="margin-top:12px"><input id="newpw" type="password" placeholder="new password (min 8 chars)" autofocus></div>
    <div class="row" style="margin-top:10px"><input id="newpw2" type="password" placeholder="confirm password">
    <button id="setupgo">Create password</button></div>
    <div id="setuperr" class="err"></div>
  </div>

  <div id="login" class="card hidden">
    <div class="row"><input id="pw" type="password" placeholder="admin password" autofocus>
    <button id="go">Unlock</button></div>
    <div id="loginerr" class="err"></div>
  </div>

  <div id="panel" class="hidden">
    <div class="grid" id="metrics"></div>
    <div class="sec">success / failure</div>
    <div class="grid" style="grid-template-columns:repeat(3,1fr)" id="ratiorow"></div>

    <div class="sec">reset global stats</div>
    <div class="card">
      <div class="note" style="margin-top:0">
        Zeroes the six site-wide counters above and the daily trend (<code>data/telemetry.json</code>) —
        the site-stats feed reads zero on its next build. Does not touch job history. This is a
        SEPARATE, hardcoded password from the panel login above — never shipped to the web or
        extension bundle.
      </div>
      <div class="row" style="margin-top:10px">
        <input id="resetpw" type="password" placeholder="reset password" style="flex:1">
        <button id="resetgo" class="danger">Reset global stats</button>
      </div>
      <div id="resetmsg" class="err"></div>
    </div>

    <div class="sec">SEV1 — algorithm breakdown</div>
    <div class="card" id="sev1card">
      <div class="row" style="justify-content:space-between">
        <div><b id="sev1count" style="color:var(--bad);font-size:22px">0</b> <span class="note">recorded</span></div>
      </div>
      <div id="sev1list" class="note" style="margin-top:10px">none yet</div>
    </div>

    <div class="sec">daily (last 14 days)</div>
    <div style="overflow-x:auto"><table id="daily"></table></div>

    <div class="sec">runtime configuration</div>
    <div class="card">
      <div class="cfggrid">
        <div class="field"><label for="cfg_model">model</label><input id="cfg_model" type="text" placeholder="claude-opus-4-8"></div>
        <div class="field"><label for="cfg_effort">effort</label>
          <select id="cfg_effort">
            <option value="low">low</option>
            <option value="medium">medium</option>
            <option value="high">high</option>
            <option value="xhigh">xhigh</option>
            <option value="max">max</option>
          </select>
        </div>
        <div class="field"><label for="cfg_concurrency">concurrency (1–16) — max parallel LLM calls</label><input id="cfg_concurrency" type="number" min="1" max="16" step="1"></div>
        <div class="field"><label for="cfg_swarmWidth">swarm width (1–8) — fresh-take candidates on stall</label><input id="cfg_swarmWidth" type="number" min="1" max="8" step="1"></div>
        <div class="field"><label for="cfg_maxPasses">max passes (1–100) — bounded-loop ceiling</label><input id="cfg_maxPasses" type="number" min="1" max="100" step="1"></div>
        <div class="field"><label for="cfg_maxEpisodes">max episodes (1–200) — RHA episode ceiling</label><input id="cfg_maxEpisodes" type="number" min="1" max="200" step="1"></div>
        <div class="field"><label for="cfg_maxAttempts">max attempts (1–100) — attempts per node</label><input id="cfg_maxAttempts" type="number" min="1" max="100" step="1"></div>
        <div class="field"><label for="cfg_stallLimit">stall limit (1–10) — stall → escalate</label><input id="cfg_stallLimit" type="number" min="1" max="10" step="1"></div>
        <div class="field"><label for="cfg_progressStall">progress stall (1–20) — plateau → escalate</label><input id="cfg_progressStall" type="number" min="1" max="20" step="1"></div>
        <div class="field"><label for="cfg_gateRounds">gate rounds (1–10) — input-gate reassess cap</label><input id="cfg_gateRounds" type="number" min="1" max="10" step="1"></div>
        <div class="field" style="justify-content:flex-end">
          <div class="fieldrow"><input id="cfg_autopilotDefault" type="checkbox"><label for="cfg_autopilotDefault" style="color:var(--ink)">autopilot pre-checked by default</label></div>
        </div>
        <div class="field"><label for="cfg_sev1IrFraction">SEV1 IR escalation fraction (0.1–1.0) — halt when escalations ≥ this share of features</label><input id="cfg_sev1IrFraction" type="number" min="0.1" max="1.0" step="0.05"></div>
      </div>
      <details class="advanced">
        <summary>advanced</summary>
        <div class="field" style="margin-top:10px; max-width:320px">
          <label for="cfg_swarmTempCeiling">swarm temp ceiling (0.2–1.0) — scales the repair-retry diversity ladder only, not normal generation</label>
          <input id="cfg_swarmTempCeiling" type="number" min="0.2" max="1.0" step="0.05">
        </div>
      </details>
      <div class="row" style="margin-top:14px">
        <button id="cfgsave">Save configuration</button>
        <button id="cfgreset" class="ghost">Reset to defaults</button>
      </div>
      <div id="cfgmsg" class="err"></div>
    </div>

    <div class="sec">fleet upgrade (this instance as origin)</div>
    <div class="card">
      <div id="relinfo" class="note">loading…</div>
      <div class="row" style="margin-top:12px">
        <button id="approve" class="ghost">Approve fleet upgrade</button>
      </div>
      <div class="note">Until approved, clones checking this origin see “up to date” even if a newer build exists.</div>
      <div id="relmsg" class="err"></div>
    </div>

    <div class="sec">self-update (pull from origin)</div>
    <div class="card">
      <div class="row">
        <input id="origin" type="text" placeholder="ORIGIN_URL" value="__ORIGIN__" style="flex:1">
        <button id="check" class="ghost">Check for updates</button>
      </div>
      <div id="checkinfo" class="note" style="margin-top:10px"></div>
      <div class="row" style="margin-top:12px">
        <button id="apply" class="ghost" disabled>Apply update</button>
      </div>
      <div class="note">Downloads only over HTTPS (localhost HTTP allowed). Verifies sha256 against origin <code>/api/version.hash</code> before unpacking. Restarts after apply.</div>
      <div id="updmsg" class="err"></div>
    </div>

    <div class="sec">revert / legacy</div>
    <div class="card">
      <div id="patches" class="note">no local patches yet</div>
      <div class="note" style="margin-top:8px">Restoring an older snapshot marks the brand <em class="legacy">Legacy</em> until you update forward again.</div>
      <div id="revmsg" class="err"></div>
    </div>

    <div class="sec">chrome extension distribution</div>
    <div class="card">
      <div class="row">
        <input id="zip" type="file" accept=".zip,application/zip">
        <button id="upload" class="ghost">Replace plugin</button>
        <button id="revert" class="danger">Revert to bundled</button>
      </div>
      <div class="note">Replaces the .zip served at <code>/api/extension.zip</code>. Must be a valid Chrome extension folder zipped at its root (manifest.json at top level).</div>
      <div id="extmsg" class="err"></div>
    </div>

    <div class="sec">Bug reports</div>
    <div class="card">
      <div class="note" style="margin-top:0">
        Consented reports land in KV on <code>rha.software</code>. Enter the Pages secret
        (<code>BUG_REPORT_TOKEN</code>) once — stored in <code>data/bug-report-config.json</code>.
      </div>
      <div class="row" style="margin-top:10px">
        <input id="brtoken" type="password" placeholder="BUG_REPORT_TOKEN" style="flex:1">
        <button id="brsave" class="ghost">Save token</button>
        <button id="brlist" class="ghost">Refresh list</button>
      </div>
      <div id="brmsg" class="err"></div>
      <div id="brkeys" class="note" style="margin-top:10px">no list loaded</div>
      <pre id="brdetail" class="note" style="margin-top:10px;white-space:pre-wrap;max-height:280px;overflow:auto"></pre>
    </div>
  </div>
</div>
<script>
const BASE = '/__SLUG__';
let token = '';
let pending = null; // { version, hash, origin }
const $ = id => document.getElementById(id);
async function api(pathname, opts={}) {
  const r = await fetch(BASE + pathname, opts);
  if (!r.ok) throw new Error((await r.json().catch(()=>({}))).error || ('HTTP '+r.status));
  return r.headers.get('content-type')?.includes('json') ? r.json() : r;
}
const LABEL = { hits:'human hits', botHits:'bot hits', pluginDownloads:'plugin downloads', runsExecuted:'runs executed', runsSucceeded:'runs succeeded', runsFailed:'runs failed' };

function enterPanel(t) {
  token = t;
  $('setup').classList.add('hidden');
  $('login').classList.add('hidden');
  $('panel').classList.remove('hidden');
  load();
}

$('go').onclick = async () => {
  $('loginerr').textContent = '';
  try {
    const r = await api('/auth', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ password: $('pw').value }) });
    enterPanel(r.token);
  } catch (e) { $('loginerr').textContent = e.message; }
};
$('pw').addEventListener('keydown', e => { if (e.key==='Enter') $('go').click(); });

$('setupgo').onclick = async () => {
  $('setuperr').textContent = '';
  const p1 = $('newpw').value, p2 = $('newpw2').value;
  if (p1.length < 8) { $('setuperr').textContent = 'password must be at least 8 characters'; return; }
  if (p1 !== p2) { $('setuperr').textContent = 'passwords do not match'; return; }
  try {
    const r = await api('/setup', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ password: p1 }) });
    enterPanel(r.token);
  } catch (e) { $('setuperr').textContent = e.message; }
};
$('newpw2').addEventListener('keydown', e => { if (e.key==='Enter') $('setupgo').click(); });

(async function boot() {
  try {
    const s = await api('/state');
    $(s.configured ? 'login' : 'setup').classList.remove('hidden');
  } catch {
    $('login').classList.remove('hidden');
  }
})();

async function load() {
  const t = await api('/telemetry', { headers:{ Authorization:'Bearer '+token } });
  const top = ['hits','botHits','pluginDownloads','runsExecuted'];
  $('metrics').innerHTML = top.map(k =>
    '<div class="metric"><b>'+(t.totals[k]||0).toLocaleString()+'</b><span>'+LABEL[k]+'</span></div>').join('');
  const s = t.totals.runsSucceeded||0, f = t.totals.runsFailed||0, tot = s+f;
  const pct = tot ? Math.round(s/tot*100) : 0;
  $('ratiorow').innerHTML =
    '<div class="metric"><b>'+s+'</b><span>succeeded</span></div>' +
    '<div class="metric"><b>'+f+'</b><span>failed</span></div>' +
    '<div class="metric ratio"><b>'+pct+'%</b><span>success rate ('+tot+' finished)</span></div>';
  const days = Object.keys(t.daily).sort().slice(-14).reverse();
  const cols = t.keys;
  $('daily').innerHTML =
    '<thead><tr><th>day</th>'+cols.map(c=>'<th>'+c.replace('Hits','').replace('runs','').replace('plugin','plug.')+'</th>').join('')+'</tr></thead>' +
    '<tbody>'+days.map(d=>'<tr><td>'+d+'</td>'+cols.map(c=>'<td>'+(t.daily[d][c]||0)+'</td>').join('')+'</tr>').join('')+'</tbody>';
  await loadRelease();
  await loadConfig();
  await loadSev1();
  await loadBugReports();
}

let brHost = 'https://rha.software';
let brToken = '';

async function loadBugReports() {
  try {
    const c = await api('/bug-report-config', { headers:{ Authorization:'Bearer '+token } });
    brHost = c.host || 'https://rha.software';
    brToken = c.token || '';
    $('brtoken').value = brToken;
    if (brToken) await refreshBugList();
    else $('brkeys').textContent = 'save a token to load reports from '+brHost;
  } catch (e) {
    $('brmsg').textContent = 'bug-report config: '+(e.message||e);
  }
}

$('brsave').onclick = async () => {
  $('brmsg').textContent = '';
  try {
    const r = await api('/bug-report-config', {
      method:'POST',
      headers:{ Authorization:'Bearer '+token, 'Content-Type':'application/json' },
      body: JSON.stringify({ token: $('brtoken').value.trim() }),
    });
    brToken = $('brtoken').value.trim();
    $('brmsg').style.color = 'var(--ok)';
    $('brmsg').textContent = r.hasToken ? 'token saved' : 'token cleared';
    if (brToken) await refreshBugList();
  } catch (e) { $('brmsg').style.color = 'var(--bad)'; $('brmsg').textContent = e.message; }
};

$('brlist').onclick = () => void refreshBugList();

async function refreshBugList() {
  $('brmsg').textContent = '';
  $('brdetail').textContent = '';
  if (!brToken) { $('brkeys').textContent = 'no token saved'; return; }
  try {
    const r = await fetch(brHost + '/api/bug-report?list=1', {
      headers: { Authorization: 'Bearer ' + brToken },
    });
    const j = await r.json().catch(()=>({}));
    if (!r.ok) throw new Error(j.error || ('HTTP '+r.status));
    const keys = j.keys || [];
    if (!keys.length) { $('brkeys').textContent = 'no reports yet'; return; }
    $('brkeys').innerHTML = keys.map(k =>
      '<div class="row" style="margin:4px 0"><code>'+k+'</code> '
      + '<button class="ghost" data-brid="'+k+'">Open</button></div>'
    ).join('');
    $('brkeys').querySelectorAll('[data-brid]').forEach(btn => {
      btn.onclick = () => void openBugReport(btn.getAttribute('data-brid'));
    });
  } catch (e) {
    $('brmsg').style.color = 'var(--bad)';
    $('brmsg').textContent = e.message||e;
    $('brkeys').textContent = 'failed to list';
  }
}

async function openBugReport(id) {
  $('brdetail').textContent = 'loading…';
  try {
    const r = await fetch(brHost + '/api/bug-report?id=' + encodeURIComponent(id), {
      headers: { Authorization: 'Bearer ' + brToken },
    });
    const j = await r.json().catch(()=>({}));
    if (!r.ok) throw new Error(j.error || ('HTTP '+r.status));
    $('brdetail').textContent = JSON.stringify(j, null, 2);
  } catch (e) {
    $('brdetail').textContent = e.message||e;
  }
}

$('resetgo').onclick = async () => {
  $('resetmsg').textContent = '';
  const pw = $('resetpw').value;
  if (!pw) { $('resetmsg').textContent = 'password required'; return; }
  if (!confirm('Reset ALL global site-wide stats to zero? This cannot be undone.')) return;
  try {
    const r = await fetch('/api/admin/reset-global-stats', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ password: pw }),
    });
    const j = await r.json().catch(() => ({}));
    if (!r.ok) throw new Error(j.error || ('HTTP ' + r.status));
    $('resetmsg').style.color = 'var(--ok)';
    $('resetmsg').textContent = 'global stats reset at ' + j.ts;
    $('resetpw').value = '';
    await load();
  } catch (e) { $('resetmsg').style.color = 'var(--bad)'; $('resetmsg').textContent = e.message; }
};

async function loadSev1() {
  try {
    const s = await api('/sev1', { headers:{ Authorization:'Bearer '+token } });
    $('sev1count').textContent = String(s.count||0);
    const rows = s.recent||[];
    if (!rows.length) { $('sev1list').textContent = 'none yet'; return; }
    $('sev1list').innerHTML = rows.slice(0,12).map(r =>
      '<div style="margin:6px 0;border-bottom:1px solid var(--line);padding-bottom:6px">'
      + '<b style="color:var(--bad)">'+r.code+'</b> · job <code>'+r.jobId+'</code>'
      + (r.feature ? ' · '+r.feature : '')
      + '<br><span style="color:var(--dim)">'+r.ts+' — '+(r.message||'').slice(0,120)+'</span></div>'
    ).join('');
  } catch (e) {
    $('sev1list').textContent = 'failed to load: '+(e.message||e);
  }
}

const CFG_NUM_KEYS = ['concurrency','swarmWidth','maxPasses','maxEpisodes','maxAttempts','stallLimit','progressStall','gateRounds','swarmTempCeiling','sev1IrFraction'];

async function loadConfig() {
  const c = await api('/config', { headers:{ Authorization:'Bearer '+token } });
  const eff = c.effective;
  $('cfg_model').value = eff.model;
  $('cfg_effort').value = eff.effort;
  for (const k of CFG_NUM_KEYS) $('cfg_'+k).value = eff[k];
  $('cfg_autopilotDefault').checked = !!eff.autopilotDefault;
}

$('cfgsave').onclick = async () => {
  $('cfgmsg').textContent = '';
  const body = {
    model: $('cfg_model').value.trim(),
    effort: $('cfg_effort').value,
    autopilotDefault: $('cfg_autopilotDefault').checked,
  };
  for (const k of CFG_NUM_KEYS) body[k] = Number($('cfg_'+k).value);
  try {
    await api('/config', { method:'POST', headers:{ Authorization:'Bearer '+token, 'Content-Type':'application/json' }, body: JSON.stringify(body) });
    $('cfgmsg').style.color = 'var(--ok)';
    $('cfgmsg').textContent = 'saved — takes effect on the next job, no restart needed';
    await loadConfig();
  } catch (e) { $('cfgmsg').style.color = 'var(--bad)'; $('cfgmsg').textContent = e.message; }
};

$('cfgreset').onclick = async () => {
  $('cfgmsg').textContent = '';
  try {
    await api('/config/reset', { method:'POST', headers:{ Authorization:'Bearer '+token, 'Content-Type':'application/json' }, body:'{}' });
    $('cfgmsg').style.color = 'var(--warn)';
    $('cfgmsg').textContent = 'reset to env/defaults';
    await loadConfig();
  } catch (e) { $('cfgmsg').style.color = 'var(--bad)'; $('cfgmsg').textContent = e.message; }
};

async function loadRelease() {
  const r = await api('/release', { headers:{ Authorization:'Bearer '+token } });
  $('origin').value = r.originUrl || $('origin').value;
  const p = r.published || {};
  $('relinfo').innerHTML =
    'running <b>'+r.localVersion+'</b>'+(r.legacy?' <em class="legacy">Legacy</em>':'')+
    ' · published <b>'+(p.version||'—')+'</b>'+
    ' · approved '+(p.approved ? '<b style="color:var(--ok)">yes</b> ('+(r.approval.approvedVersion||'')+')' : '<b style="color:var(--warn)">no</b>')+
    (p.hash ? '<br>hash <code class="hash">'+p.hash+'</code>' : '');
  if (r.patches && r.patches.length) {
    $('patches').innerHTML = r.patches.map(v =>
      '<div class="row" style="margin:6px 0"><code>'+v+'</code> <button class="ghost" data-revert="'+v+'">Revert</button></div>'
    ).join('');
    $('patches').querySelectorAll('[data-revert]').forEach(btn => {
      btn.onclick = () => void doRevert(btn.getAttribute('data-revert'));
    });
  } else {
    $('patches').textContent = 'no local patches yet';
  }
}

$('approve').onclick = async () => {
  $('relmsg').textContent = '';
  try {
    const r = await api('/release/approve', { method:'POST', headers:{ Authorization:'Bearer '+token, 'Content-Type':'application/json' }, body:'{}' });
    $('relmsg').style.color = 'var(--ok)';
    $('relmsg').textContent = 'fleet upgrade approved for '+r.approvedVersion;
    await loadRelease();
  } catch (e) { $('relmsg').style.color = 'var(--bad)'; $('relmsg').textContent = e.message; }
};

$('check').onclick = async () => {
  $('updmsg').textContent = '';
  $('checkinfo').textContent = 'checking…';
  $('apply').disabled = true;
  pending = null;
  try {
    const r = await api('/release/check', {
      method:'POST',
      headers:{ Authorization:'Bearer '+token, 'Content-Type':'application/json' },
      body: JSON.stringify({ originUrl: $('origin').value.trim() }),
    });
    const rem = r.remote || {};
    if (r.updateAvailable) {
      $('checkinfo').innerHTML =
        'Update to <b>'+rem.version+'</b> available.<br>hash <code class="hash">'+rem.hash+'</code>';
      pending = { version: rem.version, hash: rem.hash, origin: r.origin };
      $('apply').disabled = false;
    } else {
      $('checkinfo').textContent = rem.approved
        ? ('up to date with origin '+rem.version+' (local '+r.localVersion+')')
        : ('origin '+rem.version+' exists but fleet upgrade is not approved — treating as up to date');
    }
  } catch (e) {
    $('checkinfo').textContent = '';
    $('updmsg').style.color = 'var(--bad)';
    $('updmsg').textContent = e.message;
  }
};

$('apply').onclick = async () => {
  if (!pending) return;
  if (!confirm('Apply update to '+pending.version+'?\\n\\nhash '+pending.hash+'\\n\\nCurrent install will be snapshotted under patches/, then the server restarts.')) return;
  $('updmsg').textContent = 'applying…';
  try {
    const r = await api('/release/apply', {
      method:'POST',
      headers:{ Authorization:'Bearer '+token, 'Content-Type':'application/json' },
      body: JSON.stringify({ originUrl: pending.origin, version: pending.version, hash: pending.hash }),
    });
    $('updmsg').style.color = 'var(--ok)';
    $('updmsg').textContent = 'applied '+r.from+' → '+r.to+' — restarting…';
  } catch (e) { $('updmsg').style.color = 'var(--bad)'; $('updmsg').textContent = e.message; }
};

async function doRevert(version) {
  if (!confirm('Revert to patch '+version+'? Server will restart. Brand will show Legacy if this is behind the newest snapshot.')) return;
  $('revmsg').textContent = '';
  try {
    await api('/release/revert', {
      method:'POST',
      headers:{ Authorization:'Bearer '+token, 'Content-Type':'application/json' },
      body: JSON.stringify({ version }),
    });
    $('revmsg').style.color = 'var(--warn)';
    $('revmsg').textContent = 'restored '+version+' — restarting…';
  } catch (e) { $('revmsg').style.color = 'var(--bad)'; $('revmsg').textContent = e.message; }
}

$('upload').onclick = async () => {
  $('extmsg').textContent = '';
  const file = $('zip').files[0];
  if (!file) { $('extmsg').textContent = 'choose a .zip first'; return; }
  try {
    const buf = await file.arrayBuffer();
    const r = await api('/extension', { method:'POST', headers:{ Authorization:'Bearer '+token, 'Content-Type':'application/zip' }, body: buf });
    $('extmsg').style.color = 'var(--ok)';
    $('extmsg').textContent = 'replaced — '+r.bytes.toLocaleString()+' bytes now served';
  } catch (e) { $('extmsg').style.color = 'var(--bad)'; $('extmsg').textContent = e.message; }
};
$('revert').onclick = async () => {
  $('extmsg').textContent = '';
  try {
    await api('/extension', { method:'DELETE', headers:{ Authorization:'Bearer '+token } });
    $('extmsg').style.color = 'var(--warn)';
    $('extmsg').textContent = 'reverted to the bundled extension';
  } catch (e) { $('extmsg').style.color = 'var(--bad)'; $('extmsg').textContent = e.message; }
};
</script>
</body></html>`;
