// server/adapters/express.host.adapter.ts
import * as http from 'node:http';

import cors from 'cors';
import express from 'express';

import { fileExists, unlink } from '@frontmcp/utils';

import {
  type CorsOptions,
  type HttpMethod,
  type ServerRequest,
  type ServerRequestHandler,
  type ServerResponse,
} from '../../common';
import type { SecurityOptions } from '../../common/types/options/http/interfaces';
import { PayloadTooLargeError } from '../../errors/mcp.error';
import { createHostValidationMiddleware } from '../middleware/host-validation.middleware';
import { HostServerAdapter } from './base.host.adapter';

/**
 * Default request body size for the Express host. Lifts body-parser's silent
 * 100KB default, which routinely rejected base64-encoded PDFs, DOCXes, and
 * large HTML inputs before they reached MCP tool handlers (issue #410).
 */
export const DEFAULT_EXPRESS_BODY_LIMIT = '4mb';

/**
 * Options for ExpressHostAdapter.
 */
export interface ExpressHostAdapterOptions {
  /**
   * CORS configuration.
   * At the adapter level, CORS is disabled by default (no middleware installed).
   * Note: FrontMcpServerInstance provides a permissive default when `cors` is omitted.
   */
  cors?: CorsOptions;

  /**
   * Security options for transport hardening.
   * Includes bind address and DNS rebinding protection.
   */
  security?: SecurityOptions;

  /**
   * Maximum body size for `express.json()`. Accepts a number of bytes or a
   * body-parser-compatible string ('4mb', '500kb', etc.). Defaults to '4mb'.
   */
  bodyLimit?: number | string;

  /**
   * Maximum body size for `express.urlencoded()`. Falls back to `bodyLimit`
   * when omitted.
   */
  urlencodedLimit?: number | string;
}

export class ExpressHostAdapter extends HostServerAdapter {
  private app = express();
  private router = express.Router();
  private prepared = false;

  constructor(options?: ExpressHostAdapterOptions) {
    super();

    // CORS must run BEFORE the body parsers so the 413-on-too-large response
    // still carries `Access-Control-Allow-Origin` and friends. If CORS runs
    // after the parsers, body-parser's `entity.too.large` short-circuits to
    // our error handler before CORS ever sees the request — and browsers
    // refuse to surface the structured 413 body to JS (CodeRabbit on PR #422).
    // CORS middleware is only enabled when an explicit origin is provided —
    // prevents accidental enabling with `{ credentials: true }` alone.
    const corsOptions = options?.cors;
    const corsEnabled = corsOptions?.origin !== undefined && corsOptions.origin !== false;
    if (corsEnabled) {
      this.app.use(
        cors({
          origin: corsOptions.origin,
          credentials: corsOptions.credentials ?? false,
          maxAge: corsOptions.maxAge ?? 300,
        }),
      );
    }

    const jsonLimit = options?.bodyLimit ?? DEFAULT_EXPRESS_BODY_LIMIT;
    const formLimit = options?.urlencodedLimit ?? jsonLimit;
    // Tolerant Content-Type matching (#473). Some OAuth clients (e.g. the MCP
    // Inspector token refresh) send a hybrid header such as
    // `application/json, application/x-www-form-urlencoded`. body-parser's
    // default `type` matcher can't parse a comma-list, so NEITHER stock parser
    // fired and `req.body` arrived empty — surfacing as an opaque
    // "Invalid request body" at /oauth/token. We widen the matchers so:
    //   - a header that CONTAINS `x-www-form-urlencoded` parses as urlencoded
    //     (form wins; an actual form body would fail JSON.parse), and
    //   - a header that CONTAINS `application/json` (and NOT the form type)
    //     parses as JSON.
    // Clean single-type headers keep their existing behavior.
    const headerContains = (req: http.IncomingMessage, needle: string): boolean =>
      (req.headers['content-type'] ?? '').toLowerCase().includes(needle);
    const isUrlencoded = (req: http.IncomingMessage): boolean =>
      headerContains(req, 'application/x-www-form-urlencoded');
    const isJson = (req: http.IncomingMessage): boolean =>
      headerContains(req, 'application/json') && !isUrlencoded(req);
    this.app.use(express.urlencoded({ extended: true, limit: formLimit, type: isUrlencoded }));
    this.app.use(express.json({ limit: jsonLimit, type: isJson }));

    // Translate body-parser's `entity.too.large` (raised when a request body
    // exceeds the configured `limit`) into a structured JSON-RPC 413 response
    // so MCP clients receive a parseable error envelope instead of Express's
    // default HTML error page. The envelope shape is owned by `PayloadTooLargeError`
    // so protocol error shape stays centralized in the SDK error layer.
    this.app.use((err: unknown, _req: express.Request, res: express.Response, next: express.NextFunction) => {
      const e = err as { type?: string; limit?: number; length?: number } | undefined;
      if (e?.type === 'entity.too.large') {
        const payloadError = new PayloadTooLargeError(e.limit, e.length);
        res.status(payloadError.statusCode).json({
          jsonrpc: '2.0',
          id: null,
          error: payloadError.toJsonRpcError(),
        });
        return;
      }
      next(err);
    });

    // Host validation middleware (DNS rebinding protection)
    const securityOpts = options?.security;
    if (securityOpts?.dnsRebindingProtection?.enabled || securityOpts?.strict) {
      const allowedHosts = securityOpts.dnsRebindingProtection?.allowedHosts;
      const allowedOrigins = securityOpts.dnsRebindingProtection?.allowedOrigins;

      // In strict mode without explicit allowedHosts, derive from localhost
      const effectiveHosts = allowedHosts ?? (securityOpts.strict ? ['localhost', '127.0.0.1'] : undefined);

      if (!effectiveHosts?.length && !allowedOrigins?.length) {
        throw new Error(
          'security.dnsRebindingProtection is enabled but no allowedHosts or allowedOrigins are configured. ' +
            'Provide at least one allowedHosts entry or disable dnsRebindingProtection.',
        );
      }

      const hostValidation = createHostValidationMiddleware({
        enabled: true,
        allowedHosts: effectiveHosts,
        allowedOrigins,
      });
      this.app.use(hostValidation as express.RequestHandler);
    }

    // When creating the HTTP(S) server that hosts /mcp:
    this.app.use((req, res, next) => {
      // Only set CORS-specific headers when CORS is enabled
      if (corsEnabled) {
        res.setHeader('Access-Control-Expose-Headers', 'WWW-Authenticate, Mcp-Session-Id');
      }
      res.setHeader('Cache-Control', 'no-cache, no-transform');
      res.setHeader('Content-Type', 'application/json; charset=utf-8');
      next();
    });
  }

  registerRoute(method: HttpMethod, path: string, handler: ServerRequestHandler) {
    this.router[method.toLowerCase()](path, this.enhancedHandler(handler));
  }

  registerMiddleware(entryPath: string, handler: ServerRequestHandler) {
    this.router.use(entryPath, handler as any);
  }

  enhancedHandler(handler: ServerRequestHandler) {
    return (req: express.Request, res: express.Response, next: express.NextFunction) => {
      // TODO: add request/response enhancements here if needed
      const request = req as ServerRequest;
      const response = res as ServerResponse;
      return handler(request, response, next);
    };
  }

  /**
   * Prepares the Express app with routes but does NOT start the HTTP server.
   * Used for serverless deployments (Vercel, AWS Lambda, etc.)
   * This method is idempotent - safe to call multiple times.
   */
  prepare(): void {
    if (this.prepared) return;
    this.prepared = true;
    this.app.use('/', this.router);
  }

  /**
   * Returns the Express app for serverless exports.
   * Automatically calls prepare() to ensure routes are registered.
   */
  getHandler(): express.Application {
    this.prepare();
    return this.app;
  }

  async start(portOrSocketPath: number | string, bindAddress?: string) {
    this.prepare();
    const server = http.createServer(this.app);
    server.requestTimeout = 0;
    server.headersTimeout = 0;
    server.keepAliveTimeout = 75_000;

    if (typeof portOrSocketPath === 'string') {
      // Unix socket mode - clean up stale socket file before listening
      await this.cleanupStaleSocket(portOrSocketPath);
      await new Promise<void>((resolve, reject) => {
        server.on('error', reject);
        server.listen(portOrSocketPath, () => {
          // Set socket file permissions (owner + group read/write)
          // Using node:fs chmodSync directly - no chmod equivalent in @frontmcp/utils
          try {
            const fs = require('node:fs');
            fs.chmodSync(portOrSocketPath, 0o660);
          } catch {
            // chmod may fail on some platforms, non-critical
          }
          console.log(`MCP HTTP (Express) on unix://${portOrSocketPath}`);
          resolve();
        });
      });
    } else {
      const host = bindAddress ?? '0.0.0.0';
      await new Promise<void>((resolve, reject) => {
        server.on('error', reject);
        server.listen(portOrSocketPath, host, () => {
          console.log(`MCP HTTP (Express) on ${host}:${portOrSocketPath}`);
          resolve();
        });
      });
    }
  }

  private async cleanupStaleSocket(socketPath: string): Promise<void> {
    try {
      if (await fileExists(socketPath)) {
        await unlink(socketPath);
      }
    } catch {
      // Ignore cleanup errors - listen will fail if socket is still in use
    }
  }
}
