// Browser setup wizard server API. Mounts three endpoints that let a fresh,
// no-auth `docker compose up` install configure its LLM connection (+ optional
// port / auth) from the browser, without hand-editing config.yaml.
//
//   GET  /api/setup/status  — public; never returns secrets
//   POST /api/setup/probe   — setup-token gated; live model probe (SSRF-guarded)
//   POST /api/setup/apply   — setup-token gated; writes a narrow config patch
//
// Security model (see docs/superpowers/specs/2026-06-16-browser-setup-wizard-design.md):
// during the no-auth window a host-readable one-time token (data/.setup-token)
// gates every mutating path — including no-auth `/api/config` mutations (Codex
// P1 #3) — so that exposing the port doesn't let an anonymous caller bootstrap
// an admin via `auth.local.bootstrapAdmin`. Token comparison is constant-time.
//
// The pure LLM logic (parseEndpoint / probeModels / camelCase worker builder /
// isLlmConfigured) is shared with the CLI wizard via scripts/setup-lib.mjs,
// imported at runtime by the SAME relative path from dev and dist layouts
// (Codex P2 #7).

import express, { type Application, type Request, type Response, type RequestHandler } from 'express';
import {
  closeSync,
  constants as fsConstants,
  existsSync,
  mkdirSync,
  openSync,
  readFileSync,
  unlinkSync,
  writeSync,
} from 'node:fs';
import { join } from 'node:path';
import { randomBytes, timingSafeEqual } from 'node:crypto';
import { lookup as dnsLookup } from 'node:dns/promises';
import { isIP } from 'node:net';
import { ConfigManager } from '../config-manager.js';
import type { AuthProviderConfig } from '../config.js';
import { isProviderConfigured } from './auth.js';
import { isPrivateOrForbidden, pinnedFetch } from '../net/ssrf-strict.js';
import { logger } from '../logger.js';

// ---------------------------------------------------------------------------
// Shared setup-core loader (CLI + browser share ONE implementation).
// ---------------------------------------------------------------------------

type SetupLib = typeof import('../../scripts/setup-lib.mjs');
let _setupLib: Promise<SetupLib> | null = null;
function setupLib(): Promise<SetupLib> {
  return (_setupLib ??= import('../../scripts/setup-lib.mjs'));
}

// ---------------------------------------------------------------------------
// Setup-token persistence (Codex P1 #4).
// ---------------------------------------------------------------------------

const SETUP_TOKEN_FILE = '.setup-token';

export function setupTokenPath(dataDir: string): string {
  return join(dataDir, SETUP_TOKEN_FILE);
}

/**
 * Create the one-time setup token atomically (O_CREAT|O_EXCL, 0600) or reuse an
 * existing one. Reusing on EEXIST avoids a generate race across boots/processes
 * that would otherwise print one token but persist another. Call ONLY during the
 * no-auth + needs-setup window.
 */
export function ensureSetupToken(dataDir: string, _attempt = 0): string {
  mkdirSync(dataDir, { recursive: true });
  const p = setupTokenPath(dataDir);
  try {
    const fd = openSync(p, fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_WRONLY, 0o600);
    try {
      const token = randomBytes(32).toString('hex');
      writeSync(fd, token);
      return token;
    } finally {
      closeSync(fd);
    }
  } catch (e) {
    if ((e as NodeJS.ErrnoException).code === 'EEXIST') {
      const existing = readFileSync(p, 'utf8').trim();
      if (existing) return existing;
      // Empty/corrupt file — replace it. Bound the retry so a pathological
      // FS (file keeps reappearing empty, or unlink/create racing) can't spin
      // forever.
      if (_attempt >= 3) {
        throw new Error('[setup] could not initialize the setup token (empty token file kept reappearing)');
      }
      unlinkSync(p);
      return ensureSetupToken(dataDir, _attempt + 1);
    }
    throw e;
  }
}

export function readSetupToken(dataDir: string): string | null {
  try {
    const t = readFileSync(setupTokenPath(dataDir), 'utf8').trim();
    return t || null;
  } catch (e) {
    if ((e as NodeJS.ErrnoException).code === 'ENOENT') return null;
    throw e;
  }
}

export function clearSetupToken(dataDir: string): void {
  try {
    unlinkSync(setupTokenPath(dataDir));
  } catch (e) {
    if ((e as NodeJS.ErrnoException).code !== 'ENOENT') throw e;
  }
}

/** Constant-time token compare with length normalization (no early return). */
export function tokensMatch(provided: string, actual: string): boolean {
  const a = Buffer.from(provided, 'utf8');
  const b = Buffer.from(actual, 'utf8');
  if (a.length !== b.length) {
    // Burn an equal-length compare so a length mismatch isn't faster.
    timingSafeEqual(b, b);
    return false;
  }
  return timingSafeEqual(a, b);
}

// ---------------------------------------------------------------------------
// SSRF guard for probe (Codex P1 #6).
// ---------------------------------------------------------------------------

// SSRF policy for probe/apply targets. We ALLOW the safe-private ranges a
// self-hosted LLM legitimately uses (loopback, RFC1918, CGNAT, IPv6 ULA) but
// BLOCK everything isPrivateOrForbidden() flags as dangerous: link-local /
// cloud-metadata (169.254.0.0/16 incl. 169.254.169.254 + IMDS), IPv4-mapped,
// NAT64, AWS IMDS IPv6, multicast, reserved, broadcast. The block set is the
// vetted ssrf-strict policy MINUS the safe-private subset, so any new dangerous
// range added there is inherited here automatically.
function isSafePrivate(ip: string, family: 4 | 6): boolean {
  const a = ip.toLowerCase();
  if (family === 4) {
    return (
      /^127\./.test(a) || // loopback
      /^10\./.test(a) || // RFC1918
      /^192\.168\./.test(a) || // RFC1918
      /^172\.(1[6-9]|2\d|3[01])\./.test(a) || // RFC1918 172.16/12
      /^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(a) // 100.64.0.0/10 CGNAT
    );
  }
  if (a === '::1') return true; // loopback
  if (/^f[cd]/.test(a)) return true; // fc00::/7 ULA
  return false;
}

function isBlockedProbeAddress(ip: string, family: 4 | 6): boolean {
  return isPrivateOrForbidden(ip, family) && !isSafePrivate(ip, family);
}

// Resolve the endpoint host ONCE, vet every resolved address, and return the
// pinned IP. The probe fetch then connects to THIS IP (via pinnedFetch) so a
// rebinding DNS server can't pass a benign IP at check time and serve a
// metadata IP at fetch time (Codex P1 #4 TOCTOU). Brackets/zone-ids are
// stripped so an IPv6 literal like `[fe80::1]` or `[::ffff:a9fe:a9fe]` can't
// slip past the address check. Literal IPs skip DNS.
async function resolveProbeTarget(
  hostname: string,
): Promise<{ ok: true; ip: string; family: 4 | 6 } | { ok: false; reason: string }> {
  const host = hostname.replace(/^\[/, '').replace(/\]$/, '').split('%')[0];
  const lit = isIP(host);
  if (lit === 4 || lit === 6) {
    const fam = lit as 4 | 6;
    if (isBlockedProbeAddress(host, fam)) return { ok: false, reason: 'endpoint host is not allowed' };
    return { ok: true, ip: host, family: fam };
  }
  let addrs: Array<{ address: string; family: number }>;
  try {
    addrs = await dnsLookup(host, { all: true });
  } catch {
    return { ok: false, reason: 'could not resolve endpoint host' };
  }
  if (!addrs.length) return { ok: false, reason: 'endpoint host did not resolve' };
  for (const a of addrs) {
    if (isBlockedProbeAddress(a.address, a.family as 4 | 6)) {
      return { ok: false, reason: 'endpoint resolves to a blocked address' };
    }
  }
  return { ok: true, ip: addrs[0].address, family: addrs[0].family as 4 | 6 };
}

// ---------------------------------------------------------------------------
// Auth block validation — shares completeness logic with boot fail-closed
// (Codex P1 #5). We only persist a block that WILL make auth active next boot.
// ---------------------------------------------------------------------------

interface AuthApplyInput {
  mode?: unknown;
  local?: { email?: unknown; password?: unknown; allowSignup?: unknown };
  oauth?: {
    provider?: unknown;
    clientId?: unknown;
    clientSecret?: unknown;
    callbackUrl?: unknown;
    baseUrl?: unknown;
    adminEmail?: unknown;
  };
}

type AuthValidation =
  | { ok: true; patch: Record<string, unknown>; adminEmail?: string }
  | { ok: false; error: string };

function validateAuthBlock(auth: AuthApplyInput): AuthValidation {
  const mode = auth.mode;
  if (mode === 'local') {
    const email = String(auth.local?.email ?? '').trim();
    const password = String(auth.local?.password ?? '');
    if (!email || !password) return { ok: false, error: 'local auth requires email and password' };
    if (password.length < 8) return { ok: false, error: 'local auth password must be at least 8 characters' };
    if (password.length > 1024) return { ok: false, error: 'local auth password is too long (max 1024 characters)' };
    return {
      ok: true,
      adminEmail: email,
      patch: {
        local: {
          enabled: true,
          allowSignup: auth.local?.allowSignup === true,
          bootstrapAdmin: { email, password },
        },
      },
    };
  }
  if (mode === 'oauth') {
    const o = auth.oauth ?? {};
    const provider = o.provider;
    if (provider !== 'google' && provider !== 'gitea') {
      return { ok: false, error: 'oauth provider must be google or gitea' };
    }
    const candidate: AuthProviderConfig = {
      clientId: o.clientId ? String(o.clientId) : '',
      clientSecret: o.clientSecret ? String(o.clientSecret) : '',
      callbackUrl: o.callbackUrl ? String(o.callbackUrl) : '',
      ...(provider === 'gitea' ? { baseUrl: o.baseUrl ? String(o.baseUrl) : '' } : {}),
    };
    // Reuse the exact boot-time completeness check so we never write a partial
    // OAuth block that would fail-closed (refuse to boot) on restart.
    if (!isProviderConfigured(candidate, provider)) {
      return { ok: false, error: `oauth ${provider} config is incomplete (need clientId, clientSecret, callbackUrl${provider === 'gitea' ? ', baseUrl' : ''})` };
    }
    // Require an admin email and write it into auth.adminEmails. Without this,
    // the first OAuth user logs in as 'pending' with NO admin existing — the
    // operator can't reach Settings/config and the instance is bricked until a
    // manual config edit (Codex P2 #5). adminEmails auto-promotes a matching
    // pending user to admin on first login (auth.ts).
    const adminEmail = String(auth.oauth?.adminEmail ?? '').trim();
    if (!adminEmail || !adminEmail.includes('@')) {
      return { ok: false, error: 'oauth setup requires an admin email (the account that should become admin on first login)' };
    }
    return { ok: true, adminEmail, patch: { providers: { [provider]: candidate }, adminEmails: [adminEmail] } };
  }
  return { ok: false, error: "auth.mode must be 'local' or 'oauth'" };
}

// ---------------------------------------------------------------------------
// Mount.
// ---------------------------------------------------------------------------

export interface MountSetupApiOptions {
  /** True when an OAuth provider or local auth is active (no setup window). */
  authActive: boolean;
  /** Resolved listen port (for the restart hint shown by the wizard). */
  listenPort: number;
  /** Directory holding data/.setup-token (defaults derived by caller). */
  dataDir: string;
  /** Optional override; auto-detected from /.dockerenv otherwise. */
  deployHint?: 'docker' | 'source';
}

function detectDeployHint(): 'docker' | 'source' {
  return existsSync('/.dockerenv') ? 'docker' : 'source';
}

/**
 * Recompute needs-setup on EVERY request (never cache): the moment the runtime
 * has a usable LLM worker, probe/apply close (410). Operates on the resolved
 * `provider.workers` so the synthetic empty-model default and OLLAMA_* env
 * overrides are accounted for (Codex P1 #2).
 */
async function computeNeedsSetup(configManager: ConfigManager): Promise<boolean> {
  const { isLlmConfigured } = await setupLib();
  const workers = configManager.getConfig().provider?.workers ?? [];
  return !isLlmConfigured(workers);
}

export function mountSetupApi(
  app: Application,
  configManager: ConfigManager,
  opts: MountSetupApiOptions,
): void {
  const { authActive, listenPort, dataDir } = opts;
  const deployHint = opts.deployHint ?? detectDeployHint();

  // GET status — public, no secrets. Drives the UI gate.
  app.get('/api/setup/status', async (_req: Request, res: Response) => {
    try {
      const needsSetup = await computeNeedsSetup(configManager);
      const tokenRequired = !authActive && readSetupToken(dataDir) !== null;
      res.json({ needsSetup, authActive, port: listenPort, deployHint, tokenRequired });
    } catch (e) {
      logger.warn(`[setup-api] status failed: ${String(e)}`);
      res.status(500).json({ needsSetup: false, authActive, error: 'status unavailable' });
    }
  });

  // Token gate for probe/apply. "Closed" (410) takes precedence over
  // "forbidden" (403) so a configured/auth-active server signals closure
  // regardless of the supplied token.
  const requireSetupToken: RequestHandler = (req, res, next) => {
    void (async () => {
      if (authActive) {
        res.status(410).json({ ok: false, error: 'setup is closed (auth active)' });
        return;
      }
      if (!(await computeNeedsSetup(configManager))) {
        res.status(410).json({ ok: false, error: 'setup is closed (llm already configured)' });
        return;
      }
      const actual = readSetupToken(dataDir);
      if (!actual) {
        res.status(410).json({ ok: false, error: 'setup is closed' });
        return;
      }
      const provided = String(req.header('x-setup-token') ?? '');
      if (!provided || !tokensMatch(provided, actual)) {
        res.status(403).json({ ok: false, error: 'invalid setup token' });
        return;
      }
      next();
    })().catch((e) => {
      logger.warn(`[setup-api] token gate failed: ${String(e)}`);
      res.status(500).json({ ok: false, error: 'setup gate error' });
    });
  };

  // POST probe — live model discovery, SSRF-guarded.
  app.post('/api/setup/probe', express.json(), requireSetupToken, async (req: Request, res: Response) => {
    try {
      const { CONNECTION_TYPES, parseEndpoint, probeModels } = await setupLib();
      const body = (req.body ?? {}) as { connectionType?: unknown; endpoint?: unknown; apiKey?: unknown };
      const connectionType = String(body.connectionType ?? '');
      if (!CONNECTION_TYPES.includes(connectionType as never)) {
        res.status(400).json({ ok: false, error: `connectionType must be one of ${CONNECTION_TYPES.join(', ')}` });
        return;
      }
      const ep = parseEndpoint(body.endpoint);
      if (ep.error || !ep.endpoint) {
        res.status(400).json({ ok: false, error: ep.error ?? 'invalid endpoint' });
        return;
      }
      const apiKey = body.apiKey ? String(body.apiKey) : undefined;
      if (connectionType === 'aao_gateway' && !apiKey) {
        res.status(400).json({ ok: false, error: 'apiKey is required for aao_gateway' });
        return;
      }
      const target = await resolveProbeTarget(new URL(ep.endpoint).hostname);
      if (!target.ok) {
        res.status(400).json({ ok: false, models: [], error: target.reason });
        return;
      }
      // Pin the connection to the vetted IP and forbid redirects so a 3xx can't
      // bounce to an internal target.
      const pinned: typeof fetch = (url, init) =>
        pinnedFetch(String(url), {
          ...(init as RequestInit),
          pinnedIp: target.ip,
          family: target.family,
          redirect: 'manual',
        });
      const result = await probeModels({ endpoint: ep.endpoint, base: ep.base, apiKey, fetchImpl: pinned });
      res.json(result);
    } catch (e) {
      logger.warn(`[setup-api] probe failed: ${String(e)}`);
      res.status(500).json({ ok: false, models: [], error: 'probe failed' });
    }
  });

  // POST apply — narrow config patch (LLM + optional port + optional auth).
  app.post('/api/setup/apply', express.json(), requireSetupToken, async (req: Request, res: Response) => {
    try {
      const { CONNECTION_TYPES, parseEndpoint, buildLlmWorkerCamel } = await setupLib();
      const body = (req.body ?? {}) as {
        llm?: { connectionType?: unknown; endpoint?: unknown; model?: unknown; apiKey?: unknown };
        port?: unknown;
        auth?: AuthApplyInput;
      };

      const patch: Record<string, unknown> = {};
      let restartRequired = false;
      let adminEmail: string | undefined;

      // --- LLM (camelCase v2 shape; Codex P1 #1) ---
      if (body.llm) {
        const connectionType = String(body.llm.connectionType ?? '');
        if (!CONNECTION_TYPES.includes(connectionType as never)) {
          res.status(400).json({ ok: false, error: `llm.connectionType must be one of ${CONNECTION_TYPES.join(', ')}` });
          return;
        }
        const ep = parseEndpoint(body.llm.endpoint);
        if (ep.error || !ep.endpoint) {
          res.status(400).json({ ok: false, error: `llm.endpoint: ${ep.error ?? 'invalid endpoint'}` });
          return;
        }
        const model = String(body.llm.model ?? '').trim();
        if (!model) {
          res.status(400).json({ ok: false, error: 'llm.model is required' });
          return;
        }
        const apiKey = body.llm.apiKey ? String(body.llm.apiKey) : undefined;
        if (connectionType === 'aao_gateway' && !apiKey) {
          res.status(400).json({ ok: false, error: 'llm.apiKey is required for aao_gateway' });
          return;
        }
        // Apply the same SSRF denylist as probe before PERSISTING the endpoint:
        // a token holder could otherwise skip probe and write a link-local /
        // metadata target straight into config, which every later worker LLM
        // call would then hit. Keeps probe and apply consistent.
        const epTarget = await resolveProbeTarget(new URL(ep.endpoint).hostname);
        if (!epTarget.ok) {
          res.status(400).json({ ok: false, error: `llm.endpoint: ${epTarget.reason}` });
          return;
        }
        const worker = buildLlmWorkerCamel({
          connectionType: connectionType as 'direct' | 'aao_gateway',
          endpoint: ep.endpoint,
          model,
          apiKey,
        });
        patch.llm = { workers: [worker] };
      }

      // --- Port (optional; restart) ---
      if (body.port !== undefined && body.port !== null && body.port !== '') {
        const port = Number(body.port);
        if (!Number.isInteger(port) || port < 1 || port > 65535) {
          res.status(400).json({ ok: false, error: 'port must be an integer 1-65535' });
          return;
        }
        patch.server = { port };
        restartRequired = true;
      }

      // --- Auth (optional; restart) ---
      if (body.auth) {
        const v = validateAuthBlock(body.auth);
        if (!v.ok) {
          res.status(400).json({ ok: false, error: v.error });
          return;
        }
        patch.auth = v.patch;
        adminEmail = v.adminEmail;
        restartRequired = true;
      }

      if (Object.keys(patch).length === 0) {
        res.status(400).json({ ok: false, error: 'nothing to apply' });
        return;
      }

      const etag = configManager.getConfigForApi().etag;
      const result = configManager.updateConfig(patch, etag);
      if (!result.ok) {
        const status = (result as { conflict?: boolean }).conflict ? 409 : 400;
        res.status(status).json(result);
        return;
      }

      // LLM-only apply with no restart: the setup window's job is done. Drop the
      // token so no-auth `/api/config` reverts to its prior (open) behavior and
      // probe/apply 410. When a restart IS required (port/auth), keep the token
      // until the next boot clears it — this also keeps the just-written
      // bootstrapAdmin protected against an overwrite in the no-auth gap.
      if (!restartRequired) clearSetupToken(dataDir);

      logger.info(
        `[setup-api] apply ok llm=${!!patch.llm} port=${!!patch.server} auth=${!!patch.auth} restart=${restartRequired}`,
      );
      res.json({ ok: true, restartRequired, ...(adminEmail ? { adminEmail } : {}) });
    } catch (e) {
      logger.warn(`[setup-api] apply failed: ${String(e)}`);
      res.status(500).json({ ok: false, error: 'apply failed' });
    }
  });
}

/**
 * Boot-resolved state for the no-auth `/api/config` gate. Starts 'pending'
 * (set synchronously at mount, BEFORE the async boot lifecycle determines
 * fresh-vs-configured) so a request landing in that window can't slip through
 * (Codex P1 #2 boot TOCTOU). The lifecycle flips it to:
 *   - 'token-required' on a fresh no-auth install (a setup token was created)
 *   - 'open' on a deployment that already had an LLM configured at boot
 */
export interface NoAuthConfigGateState {
  mode: 'pending' | 'open' | 'token-required';
}

export function newNoAuthConfigGateState(): NoAuthConfigGateState {
  return { mode: 'pending' };
}

/**
 * Guard no-auth `/api/config` mutations that would change `auth.*` (the only
 * privilege-escalation vector in no-auth mode — writing bootstrapAdmin/OAuth
 * to seize admin on the next restart). NON-auth writes (llm/port/tools) keep
 * the prior open no-auth behavior, so the Settings UI is not regressed.
 *
 * - 'pending'        → fail closed (503): the boot lifecycle hasn't resolved
 *   yet, so we must not let an auth write through during the boot race.
 * - 'open'           → allow (a deployment already configured at boot keeps its
 *   prior behavior; this is the no-regression path).
 * - 'token-required' → require a valid setup token for auth writes. Once the
 *   token is gone (cleared after setup completes) auth writes are refused
 *   outright, so completing the wizard can't reopen the bootstrap hole
 *   (Codex P1 #3). GET/HEAD always pass so the UI can read config.
 */
export function buildNoAuthConfigGate(dataDir: string, state: NoAuthConfigGateState): RequestHandler {
  return (req: Request, res: Response, next) => {
    if (req.method === 'GET' || req.method === 'HEAD') {
      next();
      return;
    }
    const body = req.body as unknown;
    const touchesAuth = !!body && typeof body === 'object' && !Array.isArray(body) && 'auth' in (body as object);
    if (!touchesAuth) {
      next();
      return;
    }
    if (state.mode === 'pending') {
      res.status(503).json({ ok: false, error: 'setup initializing, retry shortly' });
      return;
    }
    if (state.mode === 'open') {
      next();
      return;
    }
    // token-required
    const actual = readSetupToken(dataDir);
    const provided = String(req.header('x-setup-token') ?? '');
    if (actual && provided && tokensMatch(provided, actual)) {
      next();
      return;
    }
    res.status(403).json({ ok: false, error: 'setup token required to change auth config during initial setup' });
  };
}
