/**
 * HTTP transport for `gbrain serve --http` (legacy bearer-auth path).
 *
 * Engine-aware via SqlQuery (works on both Postgres and PGLite as of the
 * v0.31 wave). The access_tokens and mcp_request_log tables exist on both
 * engines (see src/core/pglite-schema.ts:478,495 and src/schema.sql).
 *
 * Security model:
 *   - Every request must include `Authorization: Bearer <token>` (except /health)
 *   - Tokens are validated against SHA-256 hashes in the access_tokens table
 *   - Create/manage tokens with auth.ts (gbrain auth create/list/revoke)
 *   - No open OAuth, no client_credentials, no self-service tokens
 *
 * Hardening:
 *   - CORS default-deny: allowlist via GBRAIN_HTTP_CORS_ORIGIN (comma-separated)
 *   - Rate limit: per-IP pre-auth (protects DB from brute-force load) + per-token-id post-auth
 *     (limits runaway clients). Default 30 req/min per IP, 60 req/min per token. Bounded LRU
 *     so attacker-controlled keys can't grow memory unbounded.
 *   - Body cap: 1 MiB default (GBRAIN_HTTP_MAX_BODY_BYTES). Stream-counted, not buffered —
 *     chunked transfers without Content-Length are still capped.
 *   - last_used_at debounce: only one UPDATE per token per 60s (SQL-level WHERE clause).
 *   - mcp_request_log: one row per request with token_name + operation + status + latency.
 *
 * Replaces the standalone HTTP+OAuth wrapper that was vulnerable to unauthenticated
 * client registration (see SECURITY.md).
 */

import { createHash } from 'crypto';
import type { BrainEngine } from '../core/engine.ts';
import { buildToolDefs } from './tool-defs.ts';
import { operations } from '../core/operations.ts';
import type { AuthInfo } from '../core/operations.ts';
import { VERSION } from '../version.ts';
import { dispatchToolCall } from './dispatch.ts';
import { buildDefaultLimiters, type RateLimiter } from './rate-limit.ts';
import { sqlQueryForEngine } from '../core/sql-query.ts';
import { parseLegacyTokenScope } from '../core/legacy-token-scope.ts';
export { parseLegacyTokenScope };

const DEFAULT_BODY_CAP = 1024 * 1024; // 1 MiB

function hashToken(token: string): string {
  return createHash('sha256').update(token).digest('hex');
}

function envInt(name: string, fallback: number): number {
  const v = process.env[name];
  if (!v) return fallback;
  const n = parseInt(v, 10);
  return Number.isFinite(n) && n > 0 ? n : fallback;
}

function parseCorsAllowlist(): Set<string> | null {
  const v = process.env.GBRAIN_HTTP_CORS_ORIGIN;
  if (!v) return null;
  return new Set(v.split(',').map(s => s.trim()).filter(Boolean));
}

interface HttpTransportOptions {
  port: number;
  engine: BrainEngine;
  /** Override limiters (for tests). Defaults to env-driven buildDefaultLimiters. */
  limiters?: { ip: RateLimiter; token: RateLimiter };
}

interface AuthResult {
  ok: boolean;
  tokenId?: string;
  tokenName?: string;
  /** v0.28: per-token allow-list for takes.holder. Default ['world'] when permissions row absent. */
  takesHoldersAllowList?: string[];
  /**
   * v0.34.1 (#861, D13): source-isolation scope for the auth'd request.
   * Legacy bearer tokens here default to 'default' to match the v0.33
   * effective behavior (the now-removed serve-http.ts fallback chain).
   * Operators migrate to the full OAuth transport (gbrain serve --http)
   * for narrower scoping.
   */
  sourceId?: string;
  /**
   * #1336: AuthInfo carrying the legacy token's stored federated_read grant
   * (`permissions.source_id` array). Threaded so `sourceScopeOpts` can scope
   * read ops to the operator-granted sources instead of just scalar `sourceId`.
   * Bounded to the stored grant — never widened to "all".
   */
  auth?: AuthInfo;
  /**
   * #3242: true when the token row carries an operator-set
   * `permissions.source_id` (string OR array — even a malformed one, which
   * fails closed to 'default' without widening). false = the historical
   * no-grant 'default' floor; ONLY that case gets the federated read set
   * (config.federated sources) threaded as localFederatedSourceIds.
   */
  hasSourceGrant?: boolean;
}

/* Legacy token source-scope parsing lives in core/legacy-token-scope.ts and is
 * re-exported above so the legacy HTTP transport and OAuth provider cannot drift. */

/** Read up to `cap` bytes off req.body. Returns null if cap exceeded. */
async function readBodyWithCap(req: Request, cap: number): Promise<string | null> {
  const cl = req.headers.get('content-length');
  if (cl) {
    const n = parseInt(cl, 10);
    if (Number.isFinite(n) && n > cap) return null;
  }
  const reader = req.body?.getReader();
  if (!reader) return '';
  const chunks: Uint8Array[] = [];
  let total = 0;
  while (true) {
    const { value, done } = await reader.read();
    if (done) break;
    if (!value) continue;
    total += value.byteLength;
    if (total > cap) {
      try { await reader.cancel(); } catch { /* noop */ }
      return null;
    }
    chunks.push(value);
  }
  // Concatenate without Buffer to keep this Node-vs-Bun-portable.
  const merged = new Uint8Array(total);
  let offset = 0;
  for (const c of chunks) {
    merged.set(c, offset);
    offset += c.byteLength;
  }
  return new TextDecoder().decode(merged);
}

/** Resolve client IP. Honors X-Forwarded-For only when GBRAIN_HTTP_TRUST_PROXY=1. */
function resolveClientIp(req: Request, server: { requestIP: (r: Request) => { address: string } | null }): string {
  if (process.env.GBRAIN_HTTP_TRUST_PROXY === '1') {
    const xff = req.headers.get('x-forwarded-for');
    if (xff) {
      const first = xff.split(',')[0]?.trim();
      if (first) return first;
    }
    const xRealIp = req.headers.get('x-real-ip');
    if (xRealIp) return xRealIp.trim();
  }
  const sock = server.requestIP(req);
  return sock?.address || 'unknown';
}

export async function startHttpTransport(opts: HttpTransportOptions) {
  const { port, engine } = opts;

  // Engine-aware: route SQL through the active BrainEngine. Both Postgres
  // and PGLite carry access_tokens + mcp_request_log in their schemas
  // (pglite-schema.ts:478,495 and schema.sql), so the legacy bearer-auth
  // path works on either engine without a postgres.js singleton.
  const sql = sqlQueryForEngine(engine);

  const limiters = opts.limiters || buildDefaultLimiters();
  const bodyCap = envInt('GBRAIN_HTTP_MAX_BODY_BYTES', DEFAULT_BODY_CAP);
  const corsAllowlist = parseCorsAllowlist();
  const tools = buildToolDefs(operations);

  /**
   * v0.41.3 (T6): single consolidated CORS header builder. Pre-fix there were
   * two parallel functions (`corsHeaders` for actual requests, `corsPreflightHeaders`
   * for OPTIONS) — the preflight variant unconditionally emitted
   * `Access-Control-Allow-Methods` + `Access-Control-Allow-Headers` to EVERY
   * Origin, leaking the API surface to attackers probing the preflight. The
   * actual-request path was correctly default-deny.
   *
   * One function, one allowlist gate. Methods/Headers only emit when
   * preflight=true AND origin is allowlisted. Allow-Origin emits only when
   * origin is allowlisted (unchanged). `Vary: Origin` pairs with Allow-Origin
   * so caches don't serve allowlisted responses to non-allowlisted requests.
   *
   * `extra` is for response-specific headers (Retry-After, etc.) and is
   * never gated by the allowlist.
   */
  interface CorsHeaderOpts {
    preflight?: boolean;
    extra?: Record<string, string>;
  }
  function corsHeaders(origin: string | null, opts: CorsHeaderOpts = {}): Record<string, string> {
    const { preflight = false, extra = {} } = opts;
    const headers: Record<string, string> = { ...extra };
    const allowed = corsAllowlist && origin && corsAllowlist.has(origin);
    if (allowed) {
      headers['Access-Control-Allow-Origin'] = origin;
      headers['Vary'] = 'Origin';
      if (preflight) {
        headers['Access-Control-Allow-Methods'] = 'POST, OPTIONS';
        headers['Access-Control-Allow-Headers'] = 'Content-Type, Authorization, Accept';
      }
    }
    return headers;
  }

  async function validateToken(authHeader: string | null): Promise<AuthResult> {
    if (!authHeader?.startsWith('Bearer ')) return { ok: false };
    const token = authHeader.slice(7);
    const hash = hashToken(token);
    try {
      const [row] = await sql`
        SELECT id, name, permissions FROM access_tokens
        WHERE token_hash = ${hash} AND revoked_at IS NULL
      `;
      if (!row) return { ok: false };
      const rowId = row.id as string;
      const rowName = row.name as string;
      // Debounced last_used_at update — only writes once per token per 60s.
      // SQL-level WHERE clause keeps this race-tolerant even under concurrent requests.
      sql`UPDATE access_tokens
          SET last_used_at = now()
          WHERE id = ${rowId}
            AND (last_used_at IS NULL OR last_used_at < now() - interval '60 seconds')`
        .catch(() => { /* fire-and-forget */ });
      // v0.28: extract per-token takes-holder allow-list. Fail-safe default
      // is ['world'] — a token with no permissions row sees public claims only.
      const perms = (row as { permissions?: { takes_holders?: unknown; source_id?: unknown } }).permissions;
      const allowList = Array.isArray(perms?.takes_holders)
        ? (perms!.takes_holders as unknown[]).filter(h => typeof h === 'string') as string[]
        : ['world'];
      // #1336: honor the operator-set source grant stored on the token.
      const { sourceId, allowedSources } = parseLegacyTokenScope(perms?.source_id);
      const auth: AuthInfo = {
        token,
        clientId: rowId,
        clientName: rowName,
        scopes: [],
        sourceId,
        ...(allowedSources ? { allowedSources } : {}),
      };
      return {
        ok: true,
        tokenId: rowId,
        tokenName: rowName,
        takesHoldersAllowList: allowList,
        // v0.34.1 (#861, D13): legacy bearer tokens default to 'default'
        // source unless the token carries an explicit grant (#1336 above).
        sourceId,
        auth,
        // #3242: distinguish "operator granted a scope" from "historical
        // no-grant floor" — only the latter widens to federated sources.
        hasSourceGrant: perms?.source_id != null,
      };
    } catch {
      return { ok: false };
    }
  }

  function logRequest(tokenName: string | null, operation: string, status: string, latencyMs: number) {
    sql`INSERT INTO mcp_request_log (token_name, operation, latency_ms, status)
        VALUES (${tokenName}, ${operation}, ${latencyMs}, ${status})`
      .catch(() => { /* best-effort */ });
  }

  const server = Bun.serve({
    port,
    async fetch(req, server) {
      const startedMs = Date.now();
      const url = new URL(req.url);
      const path = url.pathname;
      const origin = req.headers.get('origin');

      // CORS preflight
      if (req.method === 'OPTIONS') {
        return new Response(null, { headers: corsHeaders(origin, { preflight: true }) });
      }

      // Health check — no auth, no rate limit. Probes the DB so orchestration
      // doesn't see "ok" while clients are getting misleading 401s during a DB outage.
      if (path === '/health') {
        try {
          await sql`SELECT 1`;
          return Response.json(
            { status: 'ok', version: VERSION, transport: 'http', db: 'ok' },
            { headers: corsHeaders(origin) },
          );
        } catch (e: any) {
          return Response.json(
            { status: 'unhealthy', version: VERSION, transport: 'http', db: 'unreachable', error: e?.message ?? 'unknown' },
            { status: 503, headers: corsHeaders(origin) },
          );
        }
      }

      if (path !== '/mcp') {
        return Response.json({ error: 'not_found' }, { status: 404, headers: corsHeaders(origin) });
      }
      if (req.method !== 'POST') {
        return Response.json({ error: 'method_not_allowed' }, { status: 405, headers: corsHeaders(origin) });
      }

      const ip = resolveClientIp(req, server);

      // Pre-auth IP rate limit. Fires BEFORE the DB lookup so we actually limit brute-force load.
      const ipCheck = limiters.ip.check(ip);
      if (!ipCheck.allowed) {
        logRequest(null, 'unknown', 'rate_limited', Date.now() - startedMs);
        return Response.json(
          { error: 'rate_limited', message: 'Too many requests' },
          {
            status: 429,
            headers: corsHeaders(origin, { extra: { 'Retry-After': String(ipCheck.retryAfter ?? 60) } }),
          },
        );
      }

      // Body cap (stream-counted; chunked transfers caught here, not at req.json).
      const bodyText = await readBodyWithCap(req, bodyCap);
      if (bodyText === null) {
        logRequest(null, 'unknown', 'body_too_large', Date.now() - startedMs);
        return Response.json(
          { error: 'payload_too_large', message: `Request body exceeds ${bodyCap} bytes` },
          { status: 413, headers: corsHeaders(origin) },
        );
      }

      // Auth.
      const auth = await validateToken(req.headers.get('Authorization'));
      if (!auth.ok) {
        logRequest(null, 'unknown', 'auth_failed', Date.now() - startedMs);
        return Response.json(
          { error: 'invalid_token', message: 'Bearer token required. Create one: gbrain auth create <name>' },
          { status: 401, headers: corsHeaders(origin) },
        );
      }

      // Post-auth token-id rate limit. Limits runaway authed clients.
      const tokCheck = limiters.token.check(auth.tokenId!);
      if (!tokCheck.allowed) {
        logRequest(auth.tokenName!, 'unknown', 'rate_limited', Date.now() - startedMs);
        return Response.json(
          { error: 'rate_limited', message: 'Too many requests for this token' },
          {
            status: 429,
            headers: corsHeaders(origin, { extra: { 'Retry-After': String(tokCheck.retryAfter ?? 60) } }),
          },
        );
      }

      // Parse JSON-RPC body.
      let body: { method?: string; params?: any; id?: any };
      try {
        body = JSON.parse(bodyText);
      } catch (e: any) {
        logRequest(auth.tokenName!, 'unknown', 'parse_error', Date.now() - startedMs);
        return Response.json(
          { error: 'parse_error', message: e?.message ?? 'invalid JSON' },
          { status: 400, headers: corsHeaders(origin) },
        );
      }

      const { method, params, id } = body;

      // initialize
      if (method === 'initialize') {
        logRequest(auth.tokenName!, 'initialize', 'success', Date.now() - startedMs);
        return Response.json(
          {
            result: {
              protocolVersion: '2025-03-26',
              serverInfo: { name: 'gbrain', version: VERSION },
              capabilities: { tools: {} },
            },
            jsonrpc: '2.0',
            id,
          },
          { headers: corsHeaders(origin) },
        );
      }

      // notifications/initialized — acknowledge with 204
      if (method === 'notifications/initialized') {
        return new Response(null, { status: 204, headers: corsHeaders(origin) });
      }

      // tools/list
      if (method === 'tools/list') {
        logRequest(auth.tokenName!, 'tools/list', 'success', Date.now() - startedMs);
        return Response.json(
          { result: { tools }, jsonrpc: '2.0', id },
          { headers: corsHeaders(origin) },
        );
      }

      // tools/call — dispatch through shared dispatch.ts (parity with stdio)
      if (method === 'tools/call') {
        const toolName: string = params?.name ?? 'unknown';
        const args: Record<string, unknown> = params?.arguments ?? {};
        // v0.28: thread per-token takes-holder allow-list so takes_list /
        // takes_search / query (when it returns takes) can server-side filter.
        // v0.34.1 (#861): thread source-isolation scope. Legacy access_tokens
        // path defaults to 'default' per AuthResult.sourceId above.
        // #3242: a token with NO operator-set source grant reads across the
        // federated set (config.federated sources), not just the scalar
        // 'default' floor. Granted tokens (hasSourceGrant) never widen.
        let localFederated: string[] | undefined;
        if (auth.hasSourceGrant === false && auth.sourceId) {
          try {
            const { localFederatedSourceIds } = await import('../core/source-resolver.ts');
            localFederated = await localFederatedSourceIds(engine, auth.sourceId, 'seed_default');
          } catch { /* scalar scope stands */ }
        }
        const result = await dispatchToolCall(engine, toolName, args, {
          remote: true,
          takesHoldersAllowList: auth.takesHoldersAllowList,
          sourceId: auth.sourceId,
          ...(localFederated ? { localFederatedSourceIds: localFederated } : {}),
          // #1336: thread the token's federated_read grant so read ops scope
          // to the operator-granted sources via sourceScopeOpts.
          auth: auth.auth,
        });
        const status = result.isError ? 'error' : 'success';
        logRequest(auth.tokenName!, `tools/call:${toolName}`, status, Date.now() - startedMs);
        return Response.json(
          { result, jsonrpc: '2.0', id },
          { headers: corsHeaders(origin) },
        );
      }

      logRequest(auth.tokenName!, method ?? 'unknown', 'unknown_method', Date.now() - startedMs);
      return Response.json(
        { error: 'unknown_method', message: `Unknown method: ${method}` },
        { status: 400, headers: corsHeaders(origin) },
      );
    },
  });

  console.error(`GBrain HTTP MCP server running on port ${port}`);
  console.error(`  Health: http://localhost:${port}/health`);
  console.error(`  MCP:    http://localhost:${port}/mcp`);
  console.error(`  Auth:   Bearer token required (create with: gbrain auth create <name>)`);
  if (!corsAllowlist) {
    console.error('  CORS:   default-deny. Set GBRAIN_HTTP_CORS_ORIGIN=https://your.app to allow browser clients.');
  } else {
    console.error(`  CORS:   allowlist = ${[...corsAllowlist].join(', ')}`);
  }
  console.error('');
  console.error('⚠️  Do NOT use open OAuth registration for remote MCP access.');
  console.error('   Tokens are managed via: gbrain auth create/list/revoke');

  return server;
}
