import {
  BadRequestException,
  HttpException,
  INestApplication,
} from "@nestjs/common";
import { NestContainer } from "@nestjs/core";

import { IMcpRouteReflect } from "../decorators/internal/IMcpRouteReflect";

/**
 * MCP (Model Context Protocol) adaptor.
 *
 * `McpAdaptor` exposes every method decorated with {@link McpRoute} as an MCP
 * tool, reachable by LLM clients through a stateless Streamable HTTP endpoint.
 *
 * At bootstrap the adaptor walks the {@link NestContainer}, collects every
 * controller method carrying `"nestia/McpRoute"` metadata, and caches a tool
 * registry. A fresh MCP server and transport pair is spun up per incoming HTTP
 * request, following MCP stateless Streamable HTTP mode. This adaptor
 * intentionally does not manage `Mcp-Session-Id` state.
 *
 * Typia-generated JSON Schemas flow through unchanged; the Zod-based high-level
 * registration API of `McpServer` is bypassed by accessing the low-level
 * `.server` handler.
 *
 * Error mapping follows the MCP specification:
 *
 * - Unknown tool name: JSON-RPC `-32601`.
 * - Typia validation failure: JSON-RPC `-32602` with structured diagnostics.
 * - Handler throws {@link HttpException}: success response with `isError: true`,
 *   so the LLM can read the message and recover.
 * - Any other throw: JSON-RPC `-32603`.
 *
 * @author wildduck - https://github.com/wildduck2
 * @example
 *   ```typescript
 *   import core from "@nestia/core";
 *   import { NestFactory } from "@nestjs/core";
 *
 *   const app = await NestFactory.create(AppModule);
 *   await core.McpAdaptor.upgrade(app, { path: "/mcp" });
 *   await app.listen(3000);
 *   ```;
 */
export class McpAdaptor {
  /**
   * Upgrade a running Nest application with a stateless MCP endpoint.
   *
   * Scans the application container for methods decorated with {@link McpRoute},
   * then registers a catch-all HTTP route at the configured path. Each incoming
   * request builds a fresh MCP server + transport on demand, wires the
   * registered tools into it, and delegates handling.
   *
   * Must be called after `NestFactory.create(...)` but before `app.listen(...)`
   * if you want the MCP endpoint to be reachable alongside your regular HTTP
   * routes.
   *
   * @param app Running Nest application instance.
   * @param options Transport and identity overrides.
   */
  public static async upgrade(
    app: INestApplication,
    options: McpAdaptor.IOptions = {},
  ): Promise<void> {
    if ("sessioned" in (options as Record<string, unknown>))
      throw new Error(
        "McpAdaptor.upgrade() supports stateless Streamable HTTP only; sessioned mode is not implemented.",
      );

    const tools: McpAdaptor.ITool[] = [];
    const container = (app as any).container as NestContainer;
    for (const module of container.getModules().values()) {
      for (const wrapper of module.controllers.values()) {
        const instance = wrapper.instance;
        if (!instance) continue;
        const visited: Set<string> = new Set();
        for (
          let proto: any = Object.getPrototypeOf(instance);
          proto !== null && proto !== Object.prototype;
          proto = Object.getPrototypeOf(proto)
        ) {
          for (const key of Object.getOwnPropertyNames(proto)) {
            if (key === "constructor" || visited.has(key)) continue;
            visited.add(key);
            const method = proto[key];
            if (typeof method !== "function") continue;

            const meta: IMcpRouteReflect | undefined = Reflect.getMetadata(
              "nestia/McpRoute",
              method,
            );
            if (!meta) continue;

            const params: IMcpRouteReflect.IArgument[] =
              Reflect.getMetadata("nestia/McpRoute/Parameters", proto, key) ??
              [];
            const paramValidator = params.find(
              (p) => p.category === "params",
            )?.validate;

            tools.push({
              meta,
              source: `${wrapper.metatype?.name ?? proto.constructor?.name ?? "UnknownController"}.${String(key)}`,
              validateArgs: paramValidator,
              handler: async (args) => method.call(instance, args),
            });
          }
        }
      }
    }
    assertUniqueTools(tools);

    const serverInfo = options.serverInfo ?? {
      name: "nestia-mcp",
      version: "1.0.0",
    };
    const {
      CallToolRequestSchema,
      ErrorCode,
      ListToolsRequestSchema,
      McpError,
      McpServer,
      StreamableHTTPServerTransport,
    } = await loadMcpSdk();

    const http = app.getHttpAdapter();
    const route = options.path ?? "/mcp";
    http.all(route, async (req: any, res: any) => {
      // Stateless mode requires a fresh transport per request; sharing one
      // across clients races on internal initialization and request IDs.
      const mcp = new McpServer(serverInfo, {
        capabilities: { tools: {} },
      });
      const server = mcp.server;

      server.setRequestHandler(ListToolsRequestSchema, async () => ({
        tools: tools.map((t) => ({
          name: t.meta.name,
          title: t.meta.title,
          description: t.meta.description,
          inputSchema: t.meta.inputSchema,
          outputSchema: t.meta.outputSchema,
          annotations: t.meta.annotations,
        })),
      }));

      server.setRequestHandler(CallToolRequestSchema, async (reqMsg: any) => {
        const tool = tools.find((t) => t.meta.name === reqMsg.params.name);
        if (!tool)
          throw new McpError(
            ErrorCode.MethodNotFound,
            `Tool not found: ${reqMsg.params.name}`,
          );

        const args = reqMsg.params.arguments ?? {};
        if (tool.validateArgs) {
          const err: Error | null = tool.validateArgs(args);
          if (err !== null) {
            const body =
              err instanceof BadRequestException
                ? (err.getResponse() as any)
                : undefined;
            throw new McpError(ErrorCode.InvalidParams, err.message, {
              errors: body?.errors,
              path: body?.path,
              expected: body?.expected,
              value: body?.value,
              reason: body?.reason,
            });
          }
        }

        try {
          const result = await tool.handler(args);
          if (result === undefined) return { content: [] };
          return {
            content: [
              {
                type: "text" as const,
                text:
                  typeof result === "string" ? result : JSON.stringify(result),
              },
            ],
          };
        } catch (e) {
          if (e instanceof HttpException) {
            return {
              content: [{ type: "text" as const, text: e.message }],
              isError: true,
            };
          }
          throw new McpError(
            ErrorCode.InternalError,
            e instanceof Error ? e.message : "Internal error",
          );
        }
      });

      const transport = new StreamableHTTPServerTransport({
        sessionIdGenerator: undefined,
        enableJsonResponse: true,
      });
      try {
        await mcp.connect(transport);
        await transport.handleRequest(req.raw ?? req, res.raw ?? res, req.body);
      } finally {
        await transport.close().catch(() => {});
        await mcp.close().catch(() => {});
      }
    });
  }
}

const assertUniqueTools = (tools: McpAdaptor.ITool[]): void => {
  const dict: Map<string, McpAdaptor.ITool[]> = new Map();
  for (const tool of tools) {
    const array = dict.get(tool.meta.name) ?? [];
    array.push(tool);
    dict.set(tool.meta.name, array);
  }
  const duplicated = Array.from(dict.entries()).filter(
    ([, list]) => list.length > 1,
  );
  if (duplicated.length === 0) return;
  throw new Error(
    [
      "Duplicated MCP tool names are not allowed.",
      ...duplicated.map(
        ([name, list]) =>
          `  - ${JSON.stringify(name)}: ${list.map((tool) => tool.source).join(", ")}`,
      ),
    ].join("\n"),
  );
};

const loadMcpSdk = async () => {
  try {
    const [server, transport, types] = await Promise.all([
      import("@modelcontextprotocol/sdk/server/mcp.js"),
      import("@modelcontextprotocol/sdk/server/streamableHttp.js"),
      import("@modelcontextprotocol/sdk/types.js"),
    ]);
    return {
      McpServer: server.McpServer,
      StreamableHTTPServerTransport: transport.StreamableHTTPServerTransport,
      CallToolRequestSchema: types.CallToolRequestSchema,
      ErrorCode: types.ErrorCode,
      ListToolsRequestSchema: types.ListToolsRequestSchema,
      McpError: types.McpError,
    };
  } catch {
    throw new Error(
      "McpAdaptor.upgrade() requires @modelcontextprotocol/sdk. Install it before enabling MCP routes.",
    );
  }
};

export namespace McpAdaptor {
  /** Configuration options for {@link McpAdaptor.upgrade}. */
  export interface IOptions {
    /**
     * HTTP path where the MCP endpoint will be mounted.
     *
     * @default "/mcp"
     */
    path?: string;

    /**
     * Identity advertised to MCP clients during the initialize handshake. Shows
     * up in Claude Desktop / Cursor's MCP panel.
     *
     * @default { name: "nestia-mcp", version: "1.0.0" }
     */
    serverInfo?: { name: string; version: string };
  }

  /** @internal */
  export interface ITool {
    meta: IMcpRouteReflect;
    source: string;
    handler: (args: unknown) => Promise<unknown>;
    validateArgs?: (input: any) => Error | null;
  }
}
