import express from 'express';
import crypto from 'crypto';
import fs from 'fs';
import path from 'path';
import { loadConfig, saveConfig } from '../shared/config.js';
import { paths, WORKSPACE_DIR } from '../shared/paths.js';
import { log } from '../shared/logger.js';
import { initDb, closeDb, listConversations, createConversation, deleteConversation, getMessages, addMessage, getSetting, getAllSettings, setSetting, createSession, getSession, deleteExpiredSessions, getRecentMessages, getMessagesBefore, addPushSubscription, removePushSubscription, getAllPushSubscriptions, getPushSubscriptionByEndpoint, createTrustedDevice, getTrustedDevice, updateDeviceLastSeen, listTrustedDevices, deleteTrustedDevice, deleteExpiredDevices, deleteAllTrustedDevices } from './db.js';
import webpush from 'web-push';
import { TOTP } from 'otpauth';
import QRCode from 'qrcode';
import {
  startCodexOAuth, cancelCodexOAuth, getCodexAuthStatus, exchangeCodexCode,
  startDeviceCodeLogin, getDeviceCodeStatus, cancelDeviceCodeLogin,
} from './codex-auth.js';
import { startClaudeOAuth, exchangeClaudeCode, getClaudeAuthStatus, readClaudeAccessToken } from './claude-auth.js';
import { checkAvailability, registerHandle, claimReservedHandle, releaseHandle, updateTunnelUrl, startHeartbeat, stopHeartbeat } from '../shared/relay.js';
import { ensureFileDirs } from '../supervisor/file-saver.js';
import { readPiAuth, writePiAuth, clearPiAuth, getPiAuthStatus } from '../supervisor/harnesses/pi/auth-storage.js';
import { runPiTestCompletion } from '../supervisor/harnesses/pi/test-completion.js';
import { PI_SUB_PROVIDERS, getPiSubProvider } from '../supervisor/harnesses/pi/sub-providers.js';

// ── Password hashing (scrypt) ──

function hashPassword(password: string): string {
  const salt = crypto.randomBytes(16).toString('hex');
  const hash = crypto.scryptSync(password, salt, 64).toString('hex');
  return `${salt}:${hash}`;
}

function verifyPassword(password: string, stored: string): boolean {
  const [salt, hash] = stored.split(':');
  const test = crypto.scryptSync(password, salt, 64).toString('hex');
  return hash === test;
}

// ── TOTP helpers ──

function generateTOTPSecret(): string {
  return crypto.randomBytes(20).toString('base64url').replace(/[^A-Z2-7]/gi, '').slice(0, 32).toUpperCase();
}

function verifyTOTPCode(code: string, secret: string): boolean {
  const totp = new TOTP({ issuer: 'Bloby', algorithm: 'SHA1', digits: 6, period: 30, secret });
  const delta = totp.validate({ token: code, window: 1 });
  return delta !== null;
}

function generateRecoveryCodes(): string[] {
  const codes: string[] = [];
  for (let i = 0; i < 8; i++) {
    codes.push(crypto.randomBytes(4).toString('hex'));
  }
  return codes;
}

function hashRecoveryCode(code: string): string {
  return crypto.createHash('sha256').update(code.toLowerCase()).digest('hex');
}

function verifyRecoveryCode(code: string, hashes: string[]): { valid: boolean; remaining: string[] } {
  const h = hashRecoveryCode(code);
  const idx = hashes.indexOf(h);
  if (idx === -1) return { valid: false, remaining: hashes };
  const remaining = [...hashes];
  remaining.splice(idx, 1);
  return { valid: true, remaining };
}

function parseCookie(cookieHeader: string | undefined, name: string): string | undefined {
  if (!cookieHeader) return undefined;
  const match = cookieHeader.split(';').map(c => c.trim()).find(c => c.startsWith(`${name}=`));
  return match ? match.slice(name.length + 1) : undefined;
}

export function createWorkerApp() {
// Database
initDb();

// Ensure file storage directories exist
ensureFileDirs();

// ── VAPID key management (Web Push) ──

function getOrCreateVapidKeys() {
  let publicKey = getSetting('vapid_public_key');
  let privateKey = getSetting('vapid_private_key');
  if (!publicKey || !privateKey) {
    const keys = webpush.generateVAPIDKeys();
    publicKey = keys.publicKey;
    privateKey = keys.privateKey;
    setSetting('vapid_public_key', publicKey);
    setSetting('vapid_private_key', privateKey);
    log.ok('Generated new VAPID keys');
  }
  return { publicKey, privateKey };
}

function initWebPush() {
  const { publicKey, privateKey } = getOrCreateVapidKeys();
  webpush.setVapidDetails('mailto:push@bloby.bot', publicKey, privateKey);
  log.ok('Web Push initialized');
}

initWebPush();

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

// Prevent browsers/CDN/relay from caching API responses (avoids stale 502s)
app.use('/api', (_, res, next) => {
  res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
  res.set('Pragma', 'no-cache');
  res.set('Expires', '0');
  res.set('Surrogate-Control', 'no-store');
  next();
});

app.get('/api/health', (_, res) => res.json({ status: 'ok' }));
app.get('/api/conversations', (_, res) => res.json(listConversations()));
app.get('/api/conversations/:id', (req, res) => {
  const msgs = getMessages(req.params.id);
  res.json({ id: req.params.id, messages: msgs });
});
app.post('/api/conversations', (req, res) => {
  const { title, model } = req.body || {};
  const conv = createConversation(title, model);
  res.json(conv);
});
app.post('/api/conversations/:id/messages', (req, res) => {
  const { role, content, meta } = req.body || {};
  if (!role || !content) { res.status(400).json({ error: 'Missing role or content' }); return; }
  const msg = addMessage(req.params.id, role, content, meta);
  res.json(msg);
});
app.get('/api/conversations/:id/messages/recent', (req, res) => {
  const limit = parseInt(req.query.limit as string) || 20;
  const msgs = getRecentMessages(req.params.id, Math.min(limit, 1000));
  res.json(msgs);
});
app.get('/api/conversations/:id/messages', (req, res) => {
  const before = req.query.before as string;
  const limit = Math.min(parseInt(req.query.limit as string) || 20, 1000);
  if (before) {
    res.json(getMessagesBefore(req.params.id, before, limit));
  } else {
    res.json(getRecentMessages(req.params.id, limit));
  }
});
app.delete('/api/conversations/:id', (req, res) => { deleteConversation(req.params.id); res.json({ ok: true }); });
app.get('/api/settings', (_, res) => {
  // SECURITY: GET /api/settings is reachable UNAUTHENTICATED — the supervisor auth gate
  // skips GET/HEAD, and the public relay handle (bloby.bot/<handle>) proxies straight
  // through to here. So this response must never carry credentials/secrets. Strip them.
  // The known consumers (widget.js, bloby-main, workspace App.tsx) read only non-secret
  // keys (onboard flags, user_name, agent_name, whisper_enabled); "is a password set" is
  // exposed separately as portalConfigured on /api/onboard/status.
  const SECRET_KEYS = new Set([
    'portal_pass', 'totp_secret', 'totp_pending_secret', 'totp_recovery_codes',
    'whisper_key', 'vapid_private_key',
  ]);
  const safe: Record<string, string> = {};
  for (const [k, v] of Object.entries(getAllSettings())) {
    if (SECRET_KEYS.has(k) || k.startsWith('totp_pending_login')) continue;
    safe[k] = v as string;
  }
  res.json(safe);
});
app.put('/api/settings/:key', (req, res) => {
  setSetting(req.params.key, req.body.value);
  res.json({ ok: true });
});

// ── Current conversation (shared across devices) ──

app.get('/api/context/current', (_, res) => {
  const convId = getSetting('current_conversation');
  res.json({ conversationId: convId || null });
});

app.post('/api/context/set', (req, res) => {
  const { conversationId } = req.body;
  if (conversationId) {
    setSetting('current_conversation', conversationId);
  }
  res.json({ ok: true });
});

app.post('/api/context/clear', (_, res) => {
  setSetting('current_conversation', '');
  res.json({ ok: true });
});

// ── Codex OAuth routes ──

app.post('/api/auth/codex/start', (_req, res) => {
  res.json(startCodexOAuth());
});

app.post('/api/auth/codex/exchange', async (req, res) => {
  const { code } = req.body || {};
  if (!code || typeof code !== 'string') {
    res.json({ success: false, error: 'No code provided' });
    return;
  }
  res.json(await exchangeCodexCode(code));
});

app.post('/api/auth/codex/cancel', (_req, res) => {
  cancelCodexOAuth();
  res.json({ ok: true });
});

app.get('/api/auth/codex/status', async (_req, res) => {
  res.json(await getCodexAuthStatus());
});

// ── Codex device-code routes (preferred for headless dashboards) ──

app.post('/api/auth/codex/device/start', async (_req, res) => {
  res.json(await startDeviceCodeLogin());
});

app.get('/api/auth/codex/device/status', (_req, res) => {
  res.json(getDeviceCodeStatus());
});

app.post('/api/auth/codex/device/cancel', (_req, res) => {
  cancelDeviceCodeLogin();
  res.json({ ok: true });
});

// ── Claude OAuth routes ──

app.post('/api/auth/claude/start', (_req, res) => {
  res.json(startClaudeOAuth());
});

app.post('/api/auth/claude/exchange', async (req, res) => {
  const { code } = req.body;
  if (!code) { res.json({ success: false, error: 'No code provided' }); return; }
  const result = await exchangeClaudeCode(code);
  res.json(result);
});

app.get('/api/auth/claude/status', async (_req, res) => {
  res.json(await getClaudeAuthStatus());
});

// ── Pi (Bloby third harness) auth routes ──

app.get('/api/auth/pi/providers', (_req, res) => {
  res.json({
    providers: PI_SUB_PROVIDERS.map((p) => ({
      id: p.id,
      name: p.name,
      subtitle: p.subtitle,
      flavor: p.flavor,
      baseUrl: p.baseUrl,
      needsBaseUrl: !!p.needsBaseUrl,
      needsApiKey: p.needsApiKey !== false,
      apiKeyUrl: p.apiKeyUrl,
      models: p.models,
      defaultModel: p.defaultModel,
    })),
  });
});

app.get('/api/auth/pi/status', (_req, res) => {
  res.json(getPiAuthStatus());
});

app.post('/api/auth/pi/test', async (req, res) => {
  const { subProvider, apiKey, baseUrl, modelId } = req.body || {};
  if (!subProvider || typeof subProvider !== 'string') {
    res.json({ ok: false, error: 'Missing subProvider' });
    return;
  }
  const provider = getPiSubProvider(subProvider);
  if (!provider) {
    res.json({ ok: false, error: `Unknown sub-provider: ${subProvider}` });
    return;
  }
  const prompt = 'Reply with the single word OK so we can confirm this LLM endpoint is reachable.';
  const result = await runPiTestCompletion({ subProvider, apiKey, baseUrl, modelId, prompt });
  res.json(result);
});

app.post('/api/auth/pi/save', (req, res) => {
  const { subProvider, apiKey, baseUrl, modelId } = req.body || {};
  if (!subProvider || typeof subProvider !== 'string') {
    res.json({ ok: false, error: 'Missing subProvider' });
    return;
  }
  const provider = getPiSubProvider(subProvider);
  if (!provider) {
    res.json({ ok: false, error: `Unknown sub-provider: ${subProvider}` });
    return;
  }
  const saved = writePiAuth({
    subProvider,
    apiKey: typeof apiKey === 'string' ? apiKey : undefined,
    baseUrl: typeof baseUrl === 'string' && baseUrl.trim() ? baseUrl.trim() : provider.baseUrl,
    modelId: typeof modelId === 'string' && modelId.trim() ? modelId.trim() : provider.defaultModel,
  });
  res.json({ ok: true, status: { configured: true, subProvider: saved.subProvider, modelId: saved.modelId, baseUrl: saved.baseUrl } });
});

app.delete('/api/auth/pi', (_req, res) => {
  clearPiAuth();
  res.json({ ok: true });
});

app.post('/api/auth/pi/completion', async (req, res) => {
  const auth = readPiAuth();
  if (!auth) {
    res.json({ ok: false, error: 'Bloby provider is not configured yet' });
    return;
  }
  const prompt = (req.body?.prompt && typeof req.body.prompt === 'string')
    ? req.body.prompt
    : 'In one short sentence, introduce yourself as the model behind this endpoint and confirm the connection is working.';
  const result = await runPiTestCompletion({
    subProvider: auth.subProvider,
    apiKey: auth.apiKey,
    baseUrl: auth.baseUrl,
    modelId: auth.modelId,
    prompt,
  });
  res.json(result);
});

// ── Handle registration ──

app.get('/api/handle/check/:username', async (req, res) => {
  try {
    const result = await checkAvailability(req.params.username);
    res.json(result);
  } catch {
    res.json({ available: false, valid: false, error: 'Could not reach relay server' });
  }
});

app.get('/api/handle/status', (_req, res) => {
  const cfg = loadConfig();
  res.json({
    registered: !!cfg.relay?.token,
    username: cfg.username || '',
    tier: cfg.relay?.tier || '',
    url: cfg.relay?.url || '',
  });
});

app.post('/api/handle/register', async (req, res) => {
  const { username, tier } = req.body;
  log.ok(`Handle register request: username=${username}, tier=${tier}`);
  if (!username || !tier) {
    res.status(400).json({ error: 'Missing username or tier' });
    return;
  }

  try {
    const cfg = loadConfig();
    const result = await registerHandle(username, tier, cfg.wallet?.address);
    log.ok(`Handle registered with relay: url=${result.relayUrl}, token=${result.token ? 'received' : 'MISSING'}`);

    // Save to config
    cfg.username = username;
    cfg.relay = { token: result.token, tier, url: result.relayUrl };
    saveConfig(cfg);

    log.ok(`Handle registered: ${result.relayUrl}`);

    // If the tunnel is already running, push the URL to the relay immediately
    if (cfg.tunnelUrl) {
      try {
        await updateTunnelUrl(result.token, cfg.tunnelUrl);
        startHeartbeat(result.token, cfg.tunnelUrl);
      } catch (err: any) {
        log.warn(`Relay update: ${err.message}`);
      }
    }

    res.json({ ok: true, url: result.relayUrl });
  } catch (err: any) {
    res.status(400).json({ error: err.message || 'Registration failed' });
  }
});

app.post('/api/handle/change', async (req, res) => {
  const { username, tier } = req.body;
  log.ok(`Handle change request: new=${username}, tier=${tier}`);
  if (!username || !tier) {
    res.status(400).json({ error: 'Missing username or tier' });
    return;
  }

  try {
    const cfg = loadConfig();
    log.ok(`Handle change: old username=${cfg.username || '(none)'}, old token=${cfg.relay?.token ? 'yes' : 'NO — cannot release!'}`);

    // Release old handle if one exists
    if (cfg.relay?.token) {
      stopHeartbeat();
      try {
        await releaseHandle(cfg.relay.token);
        log.ok(`Released old handle: ${cfg.username}`);
      } catch (err: any) {
        log.warn(`Release old handle: ${err.message}`);
      }
    } else {
      log.warn('Handle change: no existing token found — old handle will NOT be released (orphaned in MongoDB)');
    }

    // Register new handle
    const result = await registerHandle(username, tier, cfg.wallet?.address);

    cfg.username = username;
    cfg.relay = { token: result.token, tier, url: result.relayUrl };
    saveConfig(cfg);

    log.ok(`Handle changed to: ${result.relayUrl}`);

    // Push tunnel URL to new handle
    if (cfg.tunnelUrl) {
      try {
        await updateTunnelUrl(result.token, cfg.tunnelUrl);
        startHeartbeat(result.token, cfg.tunnelUrl);
      } catch (err: any) {
        log.warn(`Relay update: ${err.message}`);
      }
    }

    res.json({ ok: true, url: result.relayUrl });
  } catch (err: any) {
    res.status(400).json({ error: err.message || 'Handle change failed' });
  }
});

app.post('/api/handle/claim-reserved', async (req, res) => {
  const { handle, hash } = req.body;
  if (!handle || !hash) {
    res.status(400).json({ error: 'Missing handle or activation code' });
    return;
  }

  try {
    const cfg = loadConfig();
    const result = await claimReservedHandle(handle, hash, cfg.wallet?.address);

    // Release old handle if one exists
    if (cfg.relay?.token) {
      stopHeartbeat();
      try {
        await releaseHandle(cfg.relay.token);
      } catch (err: any) {
        log.warn(`Release old handle: ${err.message}`);
      }
    }

    cfg.username = handle;
    cfg.relay = { token: result.token, tier: 'premium', url: result.relayUrl };
    saveConfig(cfg);

    log.ok(`Reserved handle claimed: ${result.relayUrl}`);

    // Push tunnel URL if running
    if (cfg.tunnelUrl) {
      try {
        await updateTunnelUrl(result.token, cfg.tunnelUrl);
        startHeartbeat(result.token, cfg.tunnelUrl);
      } catch (err: any) {
        log.warn(`Relay update: ${err.message}`);
      }
    }

    res.json({ ok: true, url: result.relayUrl });
  } catch (err: any) {
    res.status(400).json({ error: err.message || 'Claim failed' });
  }
});

// ── Wallet ──

// USDC contracts on each network. Same EOA can hold balances on both —
// wallet generation uses an Ethereum-compatible private key, so the address is
// identical across Tempo and Base.
const USDC_TEMPO = '0x20c000000000000000000000b9537d11c60e8b50';
const USDC_BASE = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913';
const ERC20_BALANCE_OF_ABI = [{
  name: 'balanceOf',
  type: 'function',
  stateMutability: 'view',
  inputs: [{ name: 'account', type: 'address' }],
  outputs: [{ name: '', type: 'uint256' }],
}] as const;

app.get('/api/wallet/balance', async (_req, res) => {
  const cfg = loadConfig();
  if (!cfg.wallet?.address) {
    res.json({ address: null, tempo: '0.00', base: '0.00' });
    return;
  }

  const address = cfg.wallet.address as `0x${string}`;

  try {
    const { createPublicClient, http, formatUnits } = await import('viem');
    const { tempo, base } = await import('viem/chains');

    const tempoClient = createPublicClient({ chain: tempo, transport: http('https://rpc.tempo.xyz') });
    const baseClient = createPublicClient({ chain: base, transport: http() });

    const [tempoBal, baseBal] = await Promise.all([
      tempoClient.readContract({
        address: USDC_TEMPO, abi: ERC20_BALANCE_OF_ABI, functionName: 'balanceOf', args: [address],
      }).catch((err) => { log.warn(`Tempo balance failed: ${err.message}`); return 0n; }),
      baseClient.readContract({
        address: USDC_BASE, abi: ERC20_BALANCE_OF_ABI, functionName: 'balanceOf', args: [address],
      }).catch((err) => { log.warn(`Base balance failed: ${err.message}`); return 0n; }),
    ]);

    res.json({
      address,
      tempo: formatUnits(tempoBal, 6),
      base: formatUnits(baseBal, 6),
    });
  } catch (err: any) {
    log.warn(`Wallet balance check failed: ${err.message}`);
    res.json({ address, tempo: '0.00', base: '0.00' });
  }
});

// ── Onboarding ──

app.get('/api/onboard/status', (_, res) => {
  const settings = getAllSettings();
  const cfg = loadConfig();
  const hasToken = !!cfg.relay?.token;
  res.json({
    userName: settings.user_name || '',
    agentName: settings.agent_name || '',
    portalUser: settings.portal_user || '',
    portalConfigured: !!settings.portal_pass,
    whisperEnabled: settings.whisper_enabled === 'true',
    whisperKey: settings.whisper_key || '',
    provider: cfg.ai?.provider || '',
    model: cfg.ai?.model || '',
    handle: hasToken ? {
      username: cfg.username,
      tier: cfg.relay.tier,
      url: cfg.relay.url,
    } : null,
    tunnelMode: cfg.tunnel?.mode || 'quick',
    tunnelDomain: cfg.tunnel?.domain || '',
    tunnelUrl: cfg.tunnelUrl || '',
    totpEnabled: settings.totp_enabled === 'true',
  });
});

app.post('/api/portal/verify-password', (req, res) => {
  const { password } = req.body;
  const stored = getSetting('portal_pass');
  if (!stored) { res.json({ valid: false, error: 'No password set' }); return; }
  res.json({ valid: verifyPassword(password, stored) });
});

// Shared login logic (used by both POST and GET handlers)
function handleLogin(username: string | undefined, password: string | undefined, req: any, res: any) {
  if (!password) { res.status(400).json({ error: 'Password required' }); return; }
  const storedPass = getSetting('portal_pass');
  if (!storedPass) { res.status(400).json({ error: 'No password set' }); return; }
  if (!verifyPassword(password, storedPass)) { res.status(401).json({ error: 'Invalid password' }); return; }

  // Check if TOTP is enabled
  const totpEnabled = getSetting('totp_enabled') === 'true';
  if (totpEnabled) {
    // Check for trusted device cookie
    const deviceToken = parseCookie(req.headers.cookie, 'bloby_device');
    if (deviceToken) {
      const device = getTrustedDevice(deviceToken);
      if (device) {
        updateDeviceLastSeen(deviceToken);
        // Trusted device — skip TOTP
        deleteExpiredSessions();
        const token = crypto.randomBytes(64).toString('hex');
        const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString();
        createSession(token, expiresAt);
        res.json({ token, expiresAt });
        return;
      }
    }
    // No valid trusted device — require TOTP
    const pendingToken = crypto.randomBytes(32).toString('hex');
    const pendingExpiry = new Date(Date.now() + 5 * 60 * 1000).toISOString();
    setSetting(`totp_pending_login:${pendingToken}`, pendingExpiry);
    res.json({ requiresTOTP: true, pendingToken });
    return;
  }

  deleteExpiredSessions();
  const token = crypto.randomBytes(64).toString('hex');
  const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString();
  createSession(token, expiresAt);
  res.json({ token, expiresAt });
}

// POST: credentials in JSON body
app.post('/api/portal/login', (req, res) => {
  const { username, password } = req.body;
  handleLogin(username, password, req, res);
});

// GET: credentials via Authorization Basic header (relay proxies don't forward POST bodies)
app.get('/api/portal/login', (req, res) => {
  const authHeader = req.headers['authorization'];
  if (!authHeader?.startsWith('Basic ')) { res.status(400).json({ error: 'Authorization header required' }); return; }
  const decoded = Buffer.from(authHeader.slice(6), 'base64').toString();
  const sep = decoded.indexOf(':');
  if (sep < 0) { res.status(400).json({ error: 'Invalid credentials format' }); return; }
  handleLogin(decoded.slice(0, sep), decoded.slice(sep + 1), req, res);
});

// POST + GET for validate-token (same relay issue)
function handleValidateToken(token: string | undefined, res: any) {
  if (!token) { res.json({ valid: false }); return; }
  const session = getSession(token);
  res.json({ valid: !!session });
}

app.post('/api/portal/validate-token', (req, res) => {
  handleValidateToken(req.body.token, res);
});

app.get('/api/portal/validate-token', (req, res) => {
  handleValidateToken(req.query.token as string, res);
});

// ── TOTP 2FA endpoints ──

app.get('/api/portal/totp/status', (_req, res) => {
  res.json({ enabled: getSetting('totp_enabled') === 'true' });
});

app.post('/api/portal/totp/setup', async (req, res) => {
  // Verify caller has auth: session token, correct password, or initial onboard (no password set yet)
  const authHeader = req.headers['authorization'];
  let authorized = false;
  if (authHeader?.startsWith('Bearer ')) {
    const session = getSession(authHeader.slice(7));
    if (session) authorized = true;
  }
  if (!authorized && req.body?.password) {
    const storedPass = getSetting('portal_pass');
    if (storedPass && verifyPassword(req.body.password, storedPass)) authorized = true;
  }
  // During initial onboard, no password is stored yet — allow setup
  if (!authorized && !getSetting('portal_pass')) authorized = true;
  if (!authorized) { res.status(401).json({ error: 'Unauthorized' }); return; }

  const secret = generateTOTPSecret();
  setSetting('totp_pending_secret', secret);

  const botName = getSetting('agent_name') || 'Bloby';
  const totp = new TOTP({ issuer: 'Bloby', label: botName, algorithm: 'SHA1', digits: 6, period: 30, secret });
  const otpauthUri = totp.toString();

  try {
    const qrDataUri = await QRCode.toDataURL(otpauthUri, { width: 256, margin: 2 });
    res.json({ secret, qrDataUri, otpauthUri });
  } catch (err: any) {
    res.status(500).json({ error: 'Failed to generate QR code' });
  }
});

app.post('/api/portal/totp/verify-setup', (req, res) => {
  const authHeader = req.headers['authorization'];
  let authorized = false;
  if (authHeader?.startsWith('Bearer ')) {
    const session = getSession(authHeader.slice(7));
    if (session) authorized = true;
  }
  if (!authorized && req.body?.password) {
    const storedPass = getSetting('portal_pass');
    if (storedPass && verifyPassword(req.body.password, storedPass)) authorized = true;
  }
  // During initial onboard, no password is stored yet — allow verify
  if (!authorized && !getSetting('portal_pass')) authorized = true;
  if (!authorized) { res.status(401).json({ error: 'Unauthorized' }); return; }

  const { code } = req.body;
  if (!code) { res.status(400).json({ error: 'Code required' }); return; }

  const pendingSecret = getSetting('totp_pending_secret');
  if (!pendingSecret) { res.status(400).json({ error: 'No TOTP setup in progress' }); return; }

  if (!verifyTOTPCode(code, pendingSecret)) {
    res.status(400).json({ error: 'Invalid code. Check your authenticator app and try again.' });
    return;
  }

  // Success: persist TOTP config
  setSetting('totp_secret', pendingSecret);
  setSetting('totp_enabled', 'true');

  // Generate recovery codes
  const codes = generateRecoveryCodes();
  const hashes = codes.map(hashRecoveryCode);
  setSetting('totp_recovery_codes', JSON.stringify(hashes));

  // Clean up pending secret
  setSetting('totp_pending_secret', '');

  res.json({ success: true, recoveryCodes: codes });
});

app.post('/api/portal/totp/disable', (req, res) => {
  const { password, code } = req.body;
  if (!password || !code) { res.status(400).json({ error: 'Password and TOTP code required' }); return; }

  const storedPass = getSetting('portal_pass');
  if (!storedPass || !verifyPassword(password, storedPass)) {
    res.status(401).json({ error: 'Invalid password' });
    return;
  }

  const secret = getSetting('totp_secret');
  if (!secret) { res.status(400).json({ error: '2FA is not enabled' }); return; }

  if (!verifyTOTPCode(code, secret)) {
    res.status(400).json({ error: 'Invalid TOTP code' });
    return;
  }

  // Clear all TOTP settings
  setSetting('totp_enabled', 'false');
  setSetting('totp_secret', '');
  setSetting('totp_recovery_codes', '');
  setSetting('totp_pending_secret', '');
  deleteAllTrustedDevices();

  res.json({ success: true });
});

app.get('/api/portal/login/totp', (req, res) => {
  const pending = req.query.pending as string;
  const code = req.query.code as string;
  const trust = req.query.trust as string;

  if (!pending || !code) { res.status(400).json({ error: 'Missing pending token or code' }); return; }

  // Validate pending token
  const expiry = getSetting(`totp_pending_login:${pending}`);
  if (!expiry || new Date(expiry) < new Date()) {
    res.status(401).json({ error: 'Login session expired. Please start over.' });
    return;
  }

  // Clean up pending token
  setSetting(`totp_pending_login:${pending}`, '');

  const secret = getSetting('totp_secret');
  if (!secret) { res.status(400).json({ error: '2FA is not configured' }); return; }

  // Try TOTP code first
  let valid = verifyTOTPCode(code, secret);

  // If not valid as TOTP, try as recovery code
  if (!valid) {
    const hashesJson = getSetting('totp_recovery_codes');
    if (hashesJson) {
      try {
        const hashes = JSON.parse(hashesJson) as string[];
        const result = verifyRecoveryCode(code, hashes);
        if (result.valid) {
          valid = true;
          setSetting('totp_recovery_codes', JSON.stringify(result.remaining));
        }
      } catch {}
    }
  }

  if (!valid) {
    res.status(401).json({ error: 'Invalid code' });
    return;
  }

  // Create session
  deleteExpiredSessions();
  const token = crypto.randomBytes(64).toString('hex');
  const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString();
  createSession(token, expiresAt);

  // Trust device if requested
  if (trust === '1') {
    deleteExpiredDevices();
    const deviceToken = crypto.randomBytes(32).toString('hex');
    const deviceExpiry = new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString();
    createTrustedDevice(deviceToken, 'Browser', deviceExpiry);
    res.setHeader('Set-Cookie', `bloby_device=${deviceToken}; HttpOnly; Secure; SameSite=Strict; Max-Age=7776000; Path=/`);
  }

  res.json({ token, expiresAt });
});

app.get('/api/portal/devices', (_req, res) => {
  res.json(listTrustedDevices());
});

app.delete('/api/portal/devices/:id', (req, res) => {
  deleteTrustedDevice(req.params.id);
  res.json({ ok: true });
});

app.post('/api/portal/devices/revoke', (req, res) => {
  const { id } = req.body;
  if (!id) { res.status(400).json({ error: 'Device ID required' }); return; }
  deleteTrustedDevice(id);
  res.json({ ok: true });
});

app.post('/api/onboard', (req, res) => {
  const { userName, agentName, provider, model, apiKey, baseUrl, portalUser, portalPass, whisperEnabled, whisperKey } = req.body;

  // Read old names before overwriting (needed for BLOBY.md re-onboard)
  const oldBotName = getSetting('agent_name') || '$BOT';
  const oldHumanName = getSetting('user_name') || '$HUMAN';

  setSetting('user_name', userName || '');
  setSetting('agent_name', agentName || 'Bloby');
  setSetting('onboard_complete', 'true');

  // Save portal credentials
  if (portalUser) {
    setSetting('portal_user', portalUser.trim().toLowerCase());
  }
  if (portalPass) {
    setSetting('portal_pass', hashPassword(portalPass));
  }

  // Save whisper config
  setSetting('whisper_enabled', whisperEnabled ? 'true' : 'false');
  if (whisperKey) {
    setSetting('whisper_key', whisperKey);
  }

  // Update bot and human names in BLOBY.md
  // On first onboard: replaces $BOT / $HUMAN placeholders
  // On re-onboard: replaces the previous names with the new ones
  const newBotName = agentName || 'Bloby';
  const newHumanName = userName || 'Human';
  const blobyMdPath = path.join(WORKSPACE_DIR, 'BLOBY.md');
  if (fs.existsSync(blobyMdPath)) {
    let blobyContent = fs.readFileSync(blobyMdPath, 'utf-8');
    if (oldBotName !== newBotName) {
      blobyContent = blobyContent.replace(new RegExp(oldBotName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), newBotName);
    }
    if (oldHumanName !== newHumanName) {
      blobyContent = blobyContent.replace(new RegExp(oldHumanName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), newHumanName);
    }
    fs.writeFileSync(blobyMdPath, blobyContent, 'utf-8');
    log.ok(`BLOBY.md: updated names — bot="${newBotName}", human="${newHumanName}"`);
  }

  // Re-read config from disk to preserve relay/handle data written by registration
  const currentCfg = loadConfig();
  log.ok(`Onboard: preserving relay data — token=${currentCfg.relay?.token ? 'yes' : 'no'}, username=${currentCfg.username || '(none)'}`);

  currentCfg.ai.provider = provider || '';
  currentCfg.ai.model = model || '';
  currentCfg.ai.baseUrl = baseUrl || undefined;

  // OAuth providers store their tokens in their own credentials files
  // (~/.codex/auth.json, ~/.claude/.credentials.json) and refresh them as
  // needed — config.ai.apiKey only holds raw API keys.
  if (!apiKey && provider === 'anthropic') {
    currentCfg.ai.apiKey = readClaudeAccessToken() || '';
  } else {
    currentCfg.ai.apiKey = apiKey || '';
  }

  saveConfig(currentCfg);

  res.json({ ok: true });
});

// ── Push notifications ──

app.get('/api/push/vapid-public-key', (_, res) => {
  const key = getSetting('vapid_public_key');
  if (!key) { res.status(500).json({ error: 'VAPID keys not initialized' }); return; }
  res.json({ publicKey: key });
});

app.post('/api/push/subscribe', (req, res) => {
  const { endpoint, keys } = req.body || {};
  if (!endpoint || !keys?.p256dh || !keys?.auth) {
    res.status(400).json({ error: 'Invalid subscription' });
    return;
  }
  addPushSubscription(endpoint, keys.p256dh, keys.auth);
  res.json({ ok: true });
});

app.delete('/api/push/unsubscribe', (req, res) => {
  const { endpoint } = req.body || {};
  if (!endpoint) { res.status(400).json({ error: 'Missing endpoint' }); return; }
  removePushSubscription(endpoint);
  res.json({ ok: true });
});

app.post('/api/push/send', async (req, res) => {
  const { title, body, tag, url } = req.body || {};
  const subs = getAllPushSubscriptions();
  log.info(`[push] Sending to ${subs.length} subscription(s): "${title}"`);
  const payload = JSON.stringify({ title: title || 'Bloby', body: body || '', tag: tag || 'bloby', url: url || '/' });

  const results = await Promise.allSettled(
    subs.map(async (sub) => {
      try {
        await webpush.sendNotification(
          { endpoint: sub.endpoint, keys: { p256dh: sub.keys_p256dh, auth: sub.keys_auth } },
          payload,
        );
      } catch (err: any) {
        if (err.statusCode === 410 || err.statusCode === 404) {
          removePushSubscription(sub.endpoint);
          log.info(`[push] Removed expired subscription: ${sub.endpoint.slice(0, 60)}...`);
        } else {
          log.warn(`[push] Send failed: ${err.message}`);
        }
        throw err;
      }
    }),
  );

  const sent = results.filter((r) => r.status === 'fulfilled').length;
  res.json({ sent, total: subs.length });
});

app.get('/api/push/status', (req, res) => {
  const endpoint = req.query.endpoint as string;
  if (!endpoint) { res.json({ subscribed: false }); return; }
  const sub = getPushSubscriptionByEndpoint(endpoint);
  res.json({ subscribed: !!sub });
});

// ── Whisper transcription ──

app.post('/api/whisper/transcribe', express.json({ limit: '10mb' }), async (req, res) => {
  const whisperEnabled = getSetting('whisper_enabled');
  const whisperKey = getSetting('whisper_key');

  if (whisperEnabled !== 'true' || !whisperKey) {
    res.status(400).json({ error: 'Whisper not enabled or API key missing' });
    return;
  }

  const { audio } = req.body; // base64 string (no data URL prefix expected, but handle both)
  if (!audio) {
    res.status(400).json({ error: 'No audio data provided' });
    return;
  }

  try {
    // Strip data URL prefix if present
    const raw = audio.includes(',') ? audio.split(',')[1] : audio;
    const audioBuffer = Buffer.from(raw, 'base64');

    // Build multipart/form-data manually
    const boundary = '----WhisperBoundary' + Date.now();
    const CRLF = '\r\n';

    const parts: Buffer[] = [];

    // file part
    parts.push(Buffer.from(
      `--${boundary}${CRLF}` +
      `Content-Disposition: form-data; name="file"; filename="audio.webm"${CRLF}` +
      `Content-Type: audio/webm${CRLF}${CRLF}`
    ));
    parts.push(audioBuffer);
    parts.push(Buffer.from(CRLF));

    // model part
    parts.push(Buffer.from(
      `--${boundary}${CRLF}` +
      `Content-Disposition: form-data; name="model"${CRLF}${CRLF}` +
      `whisper-1${CRLF}`
    ));

    // closing boundary
    parts.push(Buffer.from(`--${boundary}--${CRLF}`));

    const body = Buffer.concat(parts);

    const response = await fetch('https://api.openai.com/v1/audio/transcriptions', {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${whisperKey}`,
        'Content-Type': `multipart/form-data; boundary=${boundary}`,
      },
      body,
    });

    if (!response.ok) {
      const errText = await response.text();
      log.warn(`Whisper API error: ${response.status} ${errText}`);
      res.status(502).json({ error: 'Whisper API error' });
      return;
    }

    const result = await response.json() as { text: string };
    res.json({ transcript: result.text });
  } catch (err: any) {
    log.warn(`Whisper transcription failed: ${err.message}`);
    res.status(500).json({ error: 'Transcription failed' });
  }
});

// Serve stored files (audio, images, documents)
app.use('/api/files', express.static(paths.files));

log.ok('Worker routes initialized (in-process)');

return app;
}
