/**
 * HTTP surface: REST + SSE. Serves the built web UI from web/dist.
 */
import express from 'express';
import { createHash } from 'node:crypto';
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { JobManager } from './jobs';
import { Telemetry } from './telemetry';
import { createAdminRouter, createUserAdminRouter, resolveAdminConfig } from './admin';
import {
  isLegacyInstall,
  latestRelease,
  readReleaseFile,
  versionPayload,
} from './update';
import { VERSION } from '../version';
import { resolveJobAutopilot } from './jobAutopilot';
import { getConfig, getConfigStatus, writeConfigOverrides, resetConfigOverrides, validateConfigPatch, writeRoleKnobs, CONFIG_ROLES, type ConfigRole } from '../core/config';
import { listDeployableFiles } from '../core/deploySync';
import { assertSafeRelPath } from '../core/deployPath';
import {
  clearConnection,
  getConnection,
  setConnection,
  verifyClaude,
  verifyOpenAiCompat,
} from './connection';
import {
  applyProviderEnv,
  classifyTopology,
  getProviders,
  getProvidersPublic,
  primaryOperator,
  resetToSolo,
  setAdvancedMode,
  setRoleProvider,
  setSupervisorEnabled,
  setSupervisorModes,
  type ProviderRole,
  type RoleProvider,
} from '../core/providers';
import {
  injectStaleBanner,
  STALE_BUNDLE_MSG,
  webBundleFreshness,
} from './webBundle';
import { isPrivateHost } from '../core/netHost';
import { resolveActiveModel } from '../core/lmsModels';
import { loadLessons } from '../core/lessons';
import { resolveBootstrapChoice } from './bootstrapPrecedence';
import { createGlobalStatsAdminRouter } from './globalStatsAdmin';

const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
const DATA_DIR = path.join(ROOT, 'data');

// Load .env if present (ANTHROPIC_API_KEY, RHA_MODEL, ...). Values already in
// the environment win.
try {
  process.loadEnvFile(path.join(ROOT, '.env'));
} catch {
  /* no .env — fine; the SDK also resolves `ant auth login` profiles */
}

const PORT = Number(process.env.PORT || 4100);

const app = express();
app.use(express.json({ limit: '1mb' }));

// Allow Chrome extensions, localhost origins, and the HOSTED static UI
// (rha.software / *.pages.dev) to reach this LOCAL engine. The hosted site is
// the same bundle pointed through the localhost tunnel — the
// Allow-Private-Network header answers Chrome's public→local preflight.
app.use((req, res, next) => {
  const origin = req.headers.origin || '';
  const allowed =
    origin.startsWith('chrome-extension://') ||
    origin.startsWith('http://localhost') ||
    origin.startsWith('http://127.0.0.1') ||
    /^https:\/\/([a-z0-9-]+\.)?rha\.software$/.test(origin) ||
    /^https:\/\/[a-z0-9-]+\.pages\.dev$/.test(origin);
  if (allowed) {
    res.setHeader('Access-Control-Allow-Origin', origin);
    res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PATCH,PUT,DELETE,OPTIONS');
    res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
    res.setHeader('Access-Control-Allow-Private-Network', 'true');
  }
  if (req.method === 'OPTIONS') { res.sendStatus(204); return; }
  next();
});

const telemetry = new Telemetry(DATA_DIR);
const manager = new JobManager(telemetry);

// Count page views only — never assets, API calls, or the admin panel. The
// User-Agent is read here, used to classify human vs bot, and discarded; only
// the resulting +1 is kept. No IP, path, or identifier is recorded.
const adminCfg = resolveAdminConfig(DATA_DIR);
app.use((req, res, next) => {
  if (
    req.method === 'GET' &&
    !req.path.startsWith(`/${adminCfg.slug}`) &&
    !req.path.startsWith('/admin') &&
    (req.path === '/' || req.path.endsWith('.html'))
  ) {
    telemetry.recordHit(req.headers['user-agent']);
  }
  next();
});

// User-facing telemetry (stable path) + owner admin (secret slug).
app.use('/admin', createUserAdminRouter(telemetry));
// The owner admin panel: mounted at its secret slug, before the SPA catch-all.
app.use(`/${adminCfg.slug}`, createAdminRouter(adminCfg, telemetry, { root: ROOT, dataDir: DATA_DIR }));
// SET 30a — global stats reset, gated by its OWN hardcoded server-side
// password (never the admin-panel password, never shipped to a client
// bundle). Deliberately NOT under the secret slug: the slug is discovery
// obscurity, not the auth boundary for this destructive action.
app.use('/api/admin', createGlobalStatsAdminRouter(telemetry));

app.get('/api/meta', async (_req, res) => {
  const conn = getConnection();
  const providers = getProvidersPublic();
  const mock = conn.connected && conn.type === 'mock';
  const local = conn.connected && conn.kind === 'local';
  // Follow-the-loaded-model: the badge shows what a job would ACTUALLY run
  // right now. Explicit override wins; otherwise query the endpoint's load
  // state; nothing loaded reads honestly instead of guessing.
  let model: string | null = conn.connected ? conn.model : null;
  const op0 = providers.operators?.[0];
  if (conn.connected && conn.type === 'openai-compat' && op0?.baseURL && !op0?.model) {
    const active = await resolveActiveModel(op0.baseURL, undefined).catch(() => null);
    const base = op0.baseURL.replace(/\/$/, '');
    model = active ? `${active.id} @ ${base}` : `no model loaded — load one in LM Studio @ ${base}`;
  }
  res.json({
    mock,
    local,
    model,
    connected: conn.connected,
    kind: conn.kind,
    version: VERSION,
    legacy: isLegacyInstall(ROOT),
    canonical: process.env.RHA_CANONICAL === '1',
    autopilotDefault: getConfig().autopilotDefault,
    providers,
  });
});

app.get('/api/version', (_req, res) => {
  res.json(versionPayload(ROOT, DATA_DIR));
});

app.get('/api/update/latest', (req, res) => {
  const payload = versionPayload(ROOT, DATA_DIR);
  if (!payload.approved) {
    res.status(204).end();
    return;
  }
  const latest = latestRelease(ROOT);
  if (!latest) {
    res.status(204).end();
    return;
  }
  const base = `${req.protocol}://${req.get('host')}`;
  res.json({
    version: latest.version,
    url: `${base}/api/update/artifact/${encodeURIComponent(latest.version)}`,
    hash: latest.hash,
  });
});

app.get('/api/update/artifact/:version', (req, res) => {
  const version = String(req.params.version || '');
  const entry = readReleaseFile(ROOT).history.find(h => h.version === version);
  if (!entry) {
    res.status(404).json({ error: 'unknown release' });
    return;
  }
  const file = path.join(ROOT, entry.artifact);
  if (!fs.existsSync(file)) {
    res.status(404).json({ error: 'artifact missing on this instance' });
    return;
  }
  res.download(file, `rha-wrapper-${entry.version}.zip`);
});

/**
 * Hot-reload provider config pushed from the Chrome extension (or Control Panel).
 * Body: { type, baseURL?, modelOverride?, apiKey?, role?, index? }
 * role defaults to "operator" (back-compat). Verifies before marking connected.
 */
app.post('/api/provider', async (req, res) => {
  const { type, baseURL, modelOverride, apiKey, role: roleRaw, index, poolIndex } = req.body ?? {};
  if (roleRaw === 'orchestrator') {
    // eslint-disable-next-line no-console
    console.warn('[api/provider] orchestrator role ignored — assign Operator 2 instead');
  }
  const role: ProviderRole =
    roleRaw === 'supervisor' || roleRaw === 'pool'
      ? roleRaw
      : 'operator';
  const idx = typeof index === 'number' ? index
    : typeof poolIndex === 'number' ? poolIndex
    : 0;

  // Clear connection first so a failed switch never leaves a stale "connected".
  if (role === 'operator' && idx === 0) {
    clearConnection();
    applyProviderEnv(null);
  }

  if (!type || !['claude', 'openai-compat', 'mock'].includes(type)) {
    res.status(400).json({ error: `Unknown type: ${type}` });
    return;
  }

  const now = new Date().toISOString();
  let provider: RoleProvider;

  try {
    if (type === 'mock') {
      provider = {
        type: 'mock',
        connected: true,
        kind: 'mock',
        label: 'mock fixture (URL Shortener Library — prompt is ignored)',
        verifiedAt: now,
      };
    } else if (type === 'openai-compat') {
      const url = String(baseURL || process.env.LOCAL_LLM_URL || 'http://localhost:1234/v1');
      const v = await verifyOpenAiCompat(url, apiKey);
      if (!v.ok) {
        res.status(400).json({ error: v.error });
        return;
      }
      const label = modelOverride ? `${modelOverride} @ ${url.replace(/\/$/, '')}` : v.model;
      // FOLLOW-THE-LOADED-MODEL: no auto-pin. The user's load button in LM
      // Studio decides the model, resolved fresh per job and recorded per
      // call — loud, never silent. An explicit modelOverride remains the
      // deliberate hard-pin path.
      provider = {
        type: 'openai-compat',
        baseURL: url,
        model: modelOverride || undefined,
        apiKey: apiKey || undefined,
        connected: true,
        kind: isPrivateHost(url) ? 'local' : 'cloud',
        label,
        verifiedAt: now,
      };
    } else {
      const key = String(apiKey || process.env.ANTHROPIC_API_KEY || '');
      const v = await verifyClaude(key);
      if (!v.ok) {
        res.status(400).json({ error: v.error });
        return;
      }
      const model = String(modelOverride || process.env.RHA_MODEL || getConfig().model || '').trim();
      if (!model) {
        res.status(400).json({ error: 'modelOverride required for Claude (no phantom default)' });
        return;
      }
      provider = {
        type: 'claude',
        model,
        apiKey: key,
        connected: true,
        kind: 'cloud',
        label: model,
        verifiedAt: now,
      };
    }
  } catch (e) {
    res.status(400).json({ error: e instanceof Error ? e.message : String(e) });
    return;
  }

  setRoleProvider(role, provider, idx);

  const topo = classifyTopology();
  if (topo.kind === 'invalid') {
    // Keep the assignment but surface the topology error so UI can block Save.
    res.json({
      ok: true, type, role, index: idx,
      model: provider.label ?? provider.model,
      topology: topo,
    });
  } else if (role === 'operator' && idx === 0) {
    applyProviderEnv(provider);
    setConnection({
      type: provider.type,
      kind: provider.kind,
      model: provider.label ?? provider.model ?? null,
      verifiedAt: now,
      role: 'operator',
    });
    res.json({ ok: true, type, role, index: idx, model: provider.label ?? provider.model, topology: topo });
  } else {
    res.json({ ok: true, type, role, index: idx, model: provider.label ?? provider.model, topology: topo });
  }
});

app.get('/api/providers', (_req, res) => {
  res.json({ ...getProvidersPublic(), topology: classifyTopology() });
});

app.post('/api/providers/supervisor', (req, res) => {
  setSupervisorEnabled(Boolean(req.body?.enabled));
  res.json({ ...getProvidersPublic(), topology: classifyTopology() });
});

app.post('/api/providers/advanced', (req, res) => {
  setAdvancedMode(Boolean(req.body?.enabled));
  res.json({ ...getProvidersPublic(), topology: classifyTopology() });
});

app.post('/api/providers/modes', (req, res) => {
  setSupervisorModes(req.body ?? {});
  res.json({ ...getProvidersPublic(), topology: classifyTopology() });
});

app.post('/api/providers/reset', (_req, res) => {
  resetToSolo();
  const op = primaryOperator();
  if (op?.connected) {
    applyProviderEnv(op);
    setConnection({
      type: op.type, kind: op.kind,
      model: op.label ?? op.model ?? null,
      verifiedAt: op.verifiedAt ?? new Date().toISOString(),
      role: 'operator',
    });
  } else {
    clearConnection();
    applyProviderEnv(null);
  }
  res.json({ ...getProvidersPublic(), topology: classifyTopology() });
});

/** User-facing runtime config (same store as admin Part F). */
app.get('/api/config', (_req, res) => {
  res.json(getConfigStatus());
});

app.post('/api/config', (req, res) => {
  try {
    const patch = validateConfigPatch(req.body ?? {});
    const effective = writeConfigOverrides(patch);
    res.json({ ok: true, effective });
  } catch (e) {
    res.status(400).json({ error: e instanceof Error ? e.message : String(e) });
  }
});

app.post('/api/config/roles/:role', (req, res) => {
  const role = req.params.role as ConfigRole;
  if (!CONFIG_ROLES.includes(role)) {
    res.status(400).json({ error: `role must be one of ${CONFIG_ROLES.join('|')}` });
    return;
  }
  try {
    writeRoleKnobs(role, req.body ?? {});
    res.json({ ok: true, ...getConfigStatus() });
  } catch (e) {
    res.status(400).json({ error: e instanceof Error ? e.message : String(e) });
  }
});

app.post('/api/config/reset', (_req, res) => {
  res.json({ ok: true, effective: resetConfigOverrides() });
});

/** Deterministic fingerprint of the bundled (or override) extension for mismatch UI. */
function hashExtensionTree(dir: string): string {
  const files: string[] = [];
  const walk = (d: string) => {
    for (const name of fs.readdirSync(d).sort()) {
      if (name === '.DS_Store' || name === 'node_modules') continue;
      const p = path.join(d, name);
      const st = fs.statSync(p);
      if (st.isDirectory()) walk(p);
      else files.push(p);
    }
  };
  walk(dir);
  const h = createHash('sha256');
  for (const f of files) {
    const rel = path.relative(dir, f).split(path.sep).join('/');
    h.update(rel);
    h.update('\0');
    h.update(fs.readFileSync(f));
    h.update('\0');
  }
  return h.digest('hex');
}

function resolveExtensionMeta(): { version: string; hash: string } | null {
  const override = path.join(DATA_DIR, 'extension-override.zip');
  if (fs.existsSync(override)) {
    try {
      const raw = execFileSync('unzip', ['-p', override, 'manifest.json'], { timeout: 10_000 });
      const version = String((JSON.parse(raw.toString('utf8')) as { version?: string }).version ?? '');
      const hash = createHash('sha256').update(fs.readFileSync(override)).digest('hex');
      return { version, hash };
    } catch {
      /* fall through to bundled */
    }
  }
  const src = path.join(ROOT, 'extension');
  const manifestPath = path.join(src, 'manifest.json');
  if (!fs.existsSync(manifestPath)) return null;
  const version = String((JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as { version?: string }).version ?? '');
  return { version, hash: hashExtensionTree(src) };
}

const extensionMeta = resolveExtensionMeta();

app.get('/api/extension/meta', (_req, res) => {
  if (!extensionMeta) {
    res.status(404).json({ error: 'extension not bundled with this build' });
    return;
  }
  res.json(extensionMeta);
});

// Ship the Provider Vault Chrome extension with the app: the user downloads
// the zip here, extracts it, and loads it unpacked at chrome://extensions.
// An admin-uploaded override (data/extension-override.zip) takes precedence
// over the bundled folder, so the plugin can be updated without a redeploy.
app.get('/api/extension.zip', (_req, res) => {
  telemetry.recordDownload();
  const override = path.join(DATA_DIR, 'extension-override.zip');
  if (fs.existsSync(override)) {
    res.download(override, 'rha-provider-vault.zip');
    return;
  }
  const src = path.join(ROOT, 'extension');
  if (!fs.existsSync(src)) {
    res.status(404).json({ error: 'extension not bundled with this build' });
    return;
  }
  const out = path.join(os.tmpdir(), `rha-extension-${Date.now()}.zip`);
  try {
    execFileSync('zip', ['-qr', out, '.', '-x', 'node_modules/*', '-x', '.DS_Store'], {
      cwd: src,
      timeout: 30_000,
    });
  } catch (e) {
    res.status(500).json({ error: `zip failed: ${e instanceof Error ? e.message : String(e)}` });
    return;
  }
  res.download(out, 'rha-provider-vault.zip', () => {
    try { fs.unlinkSync(out); } catch { /* ignore */ }
  });
});

// The app serves ITSELF: a self-hosting bundle of this instance's own source
// plus one-click launchers (run.command / run.sh / run.bat) and the prebuilt
// web UI. Download → extract → double-click → your own instance, with its own
// copy of this endpoint. Node 20+ is the single prerequisite.
app.get('/api/app.zip', (_req, res) => {
  telemetry.recordDownload();
  const out = path.join(os.tmpdir(), `rha-app-${Date.now()}.zip`);
  try {
    execFileSync('zip', [
      '-qr', out, '.',
      '-x', 'node_modules/*', '-x', '*/node_modules/*',
      '-x', '.git/*', '-x', 'workspaces/*', '-x', 'data/*',
      '-x', '.env', '-x', '*.log', '-x', '.DS_Store', '-x', '*/.DS_Store',
      '-x', 'web/public/landing.html', '-x', 'web/public/privacy.html',
    ], { cwd: ROOT, timeout: 120_000 });
  } catch (e) {
    res.status(500).json({ error: `zip failed: ${e instanceof Error ? e.message : String(e)}` });
    return;
  }
  res.download(out, 'rha-wrapper-selfhost.zip', () => {
    try { fs.unlinkSync(out); } catch { /* ignore */ }
  });
});

app.post('/api/jobs', (req, res) => {
  const prompt = String(req.body?.prompt ?? '').trim();
  if (!prompt) {
    res.status(400).json({ error: 'prompt is required' });
    return;
  }
  try {
    // An omitted autopilot flag falls back to config `autopilotDefault` — the
    // config is the policy; clients that predate the flag must not silently
    // downgrade a run to supervised (it parks at awaiting-ir-approval, which
    // reads as "stopped at Compile"). Explicit false still means supervised.
    const autopilot = resolveJobAutopilot(req.body?.autopilot, getConfig().autopilotDefault);
    const state = manager.create(prompt, autopilot);
    res.status(201).json(publicState(state));
  } catch (e) {
    res.status(400).json({ error: e instanceof Error ? e.message : String(e) });
  }
});

app.get('/api/jobs', (_req, res) => {
  res.json(manager.list().map(publicState));
});

// History management: clear all (non-active) jobs.
app.delete('/api/jobs', (_req, res) => {
  res.json({ ok: true, deleted: manager.clearAll() });
});

app.delete('/api/jobs/:id', (req, res) => {
  const out = manager.delete(req.params.id);
  if (!out.ok) {
    res.status(out.error === 'job not found' ? 404 : 409).json({ error: out.error });
    return;
  }
  res.json({ ok: true });
});

app.patch('/api/jobs/:id', (req, res) => {
  const body = req.body ?? {};
  const hasFlags = typeof body.starred === 'boolean' || typeof body.pinned === 'boolean';
  const title = typeof body.title === 'string' ? body.title.trim() : '';
  if (!hasFlags && !title) {
    res.status(400).json({ error: 'title, starred, and/or pinned required' });
    return;
  }
  const out = hasFlags || title
    ? manager.setFlags(req.params.id, {
        starred: typeof body.starred === 'boolean' ? body.starred : undefined,
        pinned: typeof body.pinned === 'boolean' ? body.pinned : undefined,
        title: title || undefined,
      })
    : { ok: false as const, error: 'nothing to update' };
  if (!out.ok) {
    res.status(404).json({ error: out.error });
    return;
  }
  res.json({ ok: true, job: manager.get(req.params.id) });
});

app.put('/api/jobs/order', (req, res) => {
  const ids = req.body?.ids;
  if (!Array.isArray(ids) || !ids.every(x => typeof x === 'string')) {
    res.status(400).json({ error: 'ids must be an array of job ids' });
    return;
  }
  manager.reorder(ids);
  res.json({ ok: true });
});

app.get('/api/jobs/:id', (req, res) => {
  const state = manager.get(req.params.id);
  if (!state) {
    res.status(404).json({ error: 'job not found' });
    return;
  }
  res.json(publicState(state));
});

// Input-gate operator levers: re-evaluate (resample the judge) or override
// (proceed on manual authentication). Both act only on a blocked gate.
app.post('/api/jobs/:id/reassess', (req, res) => {
  const out = manager.reassess(req.params.id);
  if (!out.ok) {
    res.status(out.error === 'job not found' ? 404 : 409).json({ error: out.error });
    return;
  }
  res.json({ ok: true });
});

app.post('/api/jobs/:id/override', (req, res) => {
  const out = manager.overrideGate(req.params.id);
  if (!out.ok) {
    res.status(out.error === 'job not found' ? 404 : 409).json({ error: out.error });
    return;
  }
  res.json({ ok: true });
});

app.post('/api/jobs/:id/gate/approve', (req, res) => {
  const body = req.body ?? {};
  const out = manager.approveGateFeatures(req.params.id, {
    all: Boolean(body.all),
    features: Array.isArray(body.features) ? body.features.map(String) : undefined,
  });
  if (!out.ok) {
    res.status(out.error === 'job not found' ? 404 : 409).json({ error: out.error });
    return;
  }
  res.json({ ok: true });
});

app.post('/api/jobs/:id/approve', (req, res) => {
  const gate = req.body?.gate;
  if (gate !== 'ir' && gate !== 'build') {
    res.status(400).json({ error: 'gate must be "ir" or "build"' });
    return;
  }
  const out = manager.approve(req.params.id, gate);
  if (!out.ok) {
    res.status(409).json({ error: out.error });
    return;
  }
  res.json({ ok: true });
});

/**
 * Attach a synced project folder (full-mode sync: substrate + git) as a live
 * workspace. Self-host path — the folder must be readable by this server.
 */
app.post('/api/projects/attach', (req, res) => {
  const p = String((req.body as { path?: string } | undefined)?.path ?? '').trim();
  if (!p) {
    res.status(400).json({ error: 'path required' });
    return;
  }
  const out = manager.attach(p);
  if (!out.ok) {
    res.status(400).json({ error: out.error });
    return;
  }
  res.json(out);
});

app.post('/api/jobs/:id/resume', (req, res) => {
  const out = manager.resume(req.params.id);
  if (!out.ok) {
    res.status(out.error === 'job not found' ? 404 : 409).json({ error: out.error });
    return;
  }
  res.json({ ok: true });
});

app.post('/api/jobs/:id/recompute', (req, res) => {
  const out = manager.recompute(req.params.id, {
    featureId: req.body?.featureId,
    spec: req.body?.spec,
  });
  if (!out.ok) {
    res.status(out.error === 'job not found' ? 404 : 409).json({ error: out.error });
    return;
  }
  res.json({ ok: true });
});

app.post('/api/jobs/:id/add-features', (req, res) => {
  const out = manager.addFeatures(req.params.id, String(req.body?.prompt ?? ''));
  if (!out.ok) {
    res.status(out.error === 'job not found' ? 404 : 409).json({ error: out.error });
    return;
  }
  res.json({ ok: true });
});

app.get('/api/jobs/:id/ledger', (req, res) => {
  const csv = manager.ledgerCsv(req.params.id);
  if (csv === null) {
    res.status(404).json({ error: 'no ledger for this job yet' });
    return;
  }
  res.type('text/csv').send(csv);
});

// Dense machine-readable LLM I/O trace (JSONL): exact prompts + raw responses
// for every call in the run. Separate from the human audit log.
app.get('/api/jobs/:id/debug.jsonl', (req, res) => {
  const state = manager.get(req.params.id);
  const file = state && path.join(state.workspaceDir, '.rha-debug.jsonl');
  if (!file || !fs.existsSync(file)) {
    res.status(404).json({ error: 'no debug trace for this job yet' });
    return;
  }
  res.type('application/x-ndjson').download(file, `rha-debug-${state!.id}.jsonl`);
});

// Deployable file manifest + raw bytes for extension folder-sync (no zip).
app.get('/api/jobs/:id/deploy-manifest', (req, res) => {
  const state = manager.get(req.params.id);
  if (!state || !fs.existsSync(state.workspaceDir)) {
    res.status(404).json({ error: 'no workspace for this job' });
    return;
  }
  const q = String(req.query.mode ?? '');
  const mode = q === 'deployable' || q === 'full' ? q
    : (req.query.ledger === '1' || req.query.ledger === 'true') ? 'full'
    : getConfig().syncMode;
  const files = listDeployableFiles(state.workspaceDir, { mode });
  res.json({
    jobId: state.id,
    mode,
    files,
    totalBytes: files.reduce((n, f) => n + f.size, 0),
  });
});

app.get('/api/jobs/:id/deploy-file', (req, res) => {
  const state = manager.get(req.params.id);
  if (!state || !fs.existsSync(state.workspaceDir)) {
    res.status(404).json({ error: 'no workspace for this job' });
    return;
  }
  const rel = String(req.query.path ?? '');
  try {
    assertSafeRelPath(rel);
  } catch (e) {
    res.status(400).json({ error: e instanceof Error ? e.message : String(e) });
    return;
  }
  const allowed = new Set(
    listDeployableFiles(state.workspaceDir, { mode: getConfig().syncMode }).map(f => f.path),
  );
  if (!allowed.has(rel)) {
    res.status(404).json({ error: 'file not in deployable set' });
    return;
  }
  const abs = path.join(state.workspaceDir, rel);
  if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) {
    res.status(404).json({ error: 'file missing' });
    return;
  }
  const buf = fs.readFileSync(abs);
  const hash = createHash('sha256').update(buf).digest('hex');
  res.setHeader('Content-Type', 'application/octet-stream');
  res.setHeader('X-Content-SHA256', hash);
  res.setHeader('X-Rel-Path', rel);
  res.send(buf);
});

// Export the artifact: the full workspace including tests, .supergit and the
// git history (the supergit substrate travels with the code). node_modules
// (vitest cache) is excluded.
app.get('/api/jobs/:id/artifact.zip', (req, res) => {
  const state = manager.get(req.params.id);
  if (!state || !fs.existsSync(state.workspaceDir)) {
    res.status(404).json({ error: 'no workspace for this job' });
    return;
  }
  const out = path.join(os.tmpdir(), `rha-artifact-${state.id}-${Date.now()}.zip`);
  try {
    execFileSync('zip', ['-qr', out, '.', '-x', 'node_modules/*'], {
      cwd: state.workspaceDir,
      timeout: 60_000,
    });
  } catch (e) {
    res.status(500).json({ error: `zip failed: ${e instanceof Error ? e.message : String(e)}` });
    return;
  }
  const nice = (state.title || 'artifact').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 40);
  res.download(out, `${nice || 'artifact'}-${state.id}.zip`, () => {
    try { fs.unlinkSync(out); } catch { /* ignore */ }
  });
});

app.get('/api/jobs/:id/events', (req, res) => {
  const state = manager.get(req.params.id);
  if (!state) {
    res.status(404).json({ error: 'job not found' });
    return;
  }
  res.setHeader('Content-Type', 'text/event-stream');
  res.setHeader('Cache-Control', 'no-cache');
  res.setHeader('Connection', 'keep-alive');
  res.flushHeaders();

  const send = (ev: unknown) => res.write(`data: ${JSON.stringify(ev)}\n\n`);
  for (const ev of state.events) send(ev); // replay history
  const unsubscribe = manager.subscribe(req.params.id, send);
  const heartbeat = setInterval(() => res.write(': hb\n\n'), 15_000);
  req.on('close', () => {
    clearInterval(heartbeat);
    unsubscribe();
  });
});

// Strip server-internal fields and cap event replay in list payloads.
function publicState(state: ReturnType<JobManager['get']> & object) {
  const { events, ...rest } = state as any;
  return { ...rest, eventCount: events.length };
}

// --- static web UI ---------------------------------------------------------
// License docs linked from landing (not part of the built SPA bundle).
app.get('/COMMERCIAL-LICENSE.md', (_req, res) => {
  res.type('text/markdown').sendFile(path.join(ROOT, 'COMMERCIAL-LICENSE.md'));
});
app.get('/LICENSE', (_req, res) => {
  res.type('text/plain').sendFile(path.join(ROOT, 'LICENSE'));
});

const webDist = path.join(ROOT, 'web', 'dist');
const bundleFreshness = webBundleFreshness(ROOT);
if (bundleFreshness.stale) {
  // eslint-disable-next-line no-console
  console.warn(`\n⚠  ${STALE_BUNDLE_MSG}\n`);
}

function sendSpaIndex(res: express.Response): void {
  const indexPath = path.join(webDist, 'index.html');
  if (!fs.existsSync(indexPath)) {
    res.status(503).type('text').send(STALE_BUNDLE_MSG);
    return;
  }
  if (bundleFreshness.stale) res.setHeader('X-RHA-Stale-Bundle', '1');
  let html = fs.readFileSync(indexPath, 'utf8');
  if (bundleFreshness.stale) html = injectStaleBanner(html);
  res.type('html').send(html);
}

if (fs.existsSync(webDist)) {
  // index: false — we serve index.html ourselves so a stale banner / header
  // can be injected when web/src is newer than web/dist.
  app.get(['/', '/index.html'], (_req, res) => sendSpaIndex(res));
  app.use(express.static(webDist, { index: false }));
  // Also serve public HTML that vite copies / that lives beside dist.
  app.use(express.static(path.join(ROOT, 'web', 'public')));
  // SET 42d — SPA fallback must NEVER answer under /assets/* (poisoned
  // browser caches of text/html at hashed .js URLs). Missing assets 404.
  app.use((req, res, next) => {
    if (req.method !== 'GET' || req.path.startsWith('/api/')) {
      next();
      return;
    }
    if (req.path === '/assets' || req.path.startsWith('/assets/')) {
      res.status(404).setHeader('Cache-Control', 'no-store').type('text').send('Not Found');
      return;
    }
    sendSpaIndex(res);
  });
} else if (bundleFreshness.stale) {
  app.get(['/', '/index.html'], (_req, res) => {
    res.setHeader('X-RHA-Stale-Bundle', '1');
    res.status(503).type('text').send(STALE_BUNDLE_MSG);
  });
}

async function bootstrapConnectionFromEnv(): Promise<void> {
  // SET 23e / R1 — MOCK_LLM=1 takes absolute precedence over persisted
  // providers.json. Mock mode exists to keep tests/dev off real compute; a
  // connected vault must not silently win over an explicit mock env flag.
  // The precedence order itself lives in resolveBootstrapChoice (pure,
  // unit-tested) so this is an actual-behavior guarantee, not just a
  // source-position convention.
  const existing = primaryOperator();
  const choice = resolveBootstrapChoice({
    mockLlm: process.env.MOCK_LLM === '1',
    hasPersistedOperator: !!(existing?.connected && existing.label),
    localLlmEnv: process.env.LOCAL_LLM === '1',
    claudeKeyPresent: !!process.env.ANTHROPIC_API_KEY,
    claudeModelPresent: !!process.env.RHA_MODEL?.trim(),
  });

  if (choice === 'mock') {
    const now = new Date().toISOString();
    const p: RoleProvider = {
      type: 'mock', connected: true, kind: 'mock',
      label: 'mock fixture (URL Shortener Library — prompt is ignored)', verifiedAt: now,
    };
    setRoleProvider('operator', p);
    applyProviderEnv(p);
    setConnection({ type: 'mock', kind: 'mock', model: p.label!, verifiedAt: now, role: 'operator' });
    return;
  }

  if (choice === 'persisted') {
    // resolveBootstrapChoice only returns 'persisted' when hasPersistedOperator
    // was true, so `existing` is guaranteed non-null here.
    applyProviderEnv(existing!);
    setConnection({
      type: existing!.type,
      kind: existing!.kind,
      model: existing!.label!,
      verifiedAt: existing!.verifiedAt ?? new Date().toISOString(),
      role: 'operator',
    });
    return;
  }

  if (choice === 'local-env') {
    const url = process.env.LOCAL_LLM_URL || 'http://localhost:1234/v1';
    const v = await verifyOpenAiCompat(url, process.env.LLM_API_KEY);
    if (v.ok) {
      const now = new Date().toISOString();
      const label = process.env.LOCAL_LLM_MODEL
        ? `${process.env.LOCAL_LLM_MODEL} @ ${url.replace(/\/$/, '')}`
        : v.model;
      const p: RoleProvider = {
        type: 'openai-compat',
        baseURL: url,
        model: process.env.LOCAL_LLM_MODEL,
        apiKey: process.env.LLM_API_KEY,
        connected: true,
        kind: 'local',
        label,
        verifiedAt: now,
      };
      setRoleProvider('operator', p);
      applyProviderEnv(p);
      setConnection({ type: 'openai-compat', kind: 'local', model: label, verifiedAt: now, role: 'operator' });
    }
    return;
  }

  if (choice === 'claude-env') {
    // Claude: only connect when both key AND an explicit model are present — never invent a default.
    const key = process.env.ANTHROPIC_API_KEY!;
    const model = process.env.RHA_MODEL!.trim();
    const v = await verifyClaude(key);
    if (v.ok) {
      const now = new Date().toISOString();
      const p: RoleProvider = {
        type: 'claude', model, apiKey: key, connected: true, kind: 'cloud', label: model, verifiedAt: now,
      };
      setRoleProvider('operator', p);
      applyProviderEnv(p);
      setConnection({ type: 'claude', kind: 'cloud', model, verifiedAt: now, role: 'operator' });
    }
  }
}

void bootstrapConnectionFromEnv().then(() => {
  loadLessons();
  app.listen(PORT, () => {
    const conn = getConnection();
    const mode = conn.type === 'mock' ? 'MOCK' : conn.connected ? 'LIVE' : 'IDLE';
    const modelLabel = conn.model || '(none configured)';
    // eslint-disable-next-line no-console
    console.log(`rha-wrapper listening on http://localhost:${PORT} [${mode} llm, model ${modelLabel}]`);
  });
});
