import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';

import type { FastifyBaseLogger } from 'fastify';

export interface MCPClientConfig {
  userId: string;
  mcpServerUrl: string;
  platformJWT?: string;
  logger: FastifyBaseLogger;
}

/**
 * MCP client connector for user's container
 * Manages connection to user-specific MCP server via Streamable HTTP transport
 */
export class MCPClientConnector {
  private client: Client | null = null;
  private connected = false;
  private reconnectPromise: Promise<void> | null = null;
  private config: MCPClientConfig;

  constructor(config: MCPClientConfig) {
    this.config = config;
  }

  /**
   * Connect to user's MCP server via Streamable HTTP transport.
   * Safe to call when already connecting (concurrent callers wait for the same attempt).
   */
  async connect(): Promise<void> {
    if (this.connected) {
      return;
    }

    // If a reconnect is already in progress, wait for it rather than racing
    if (this.reconnectPromise) {
      return this.reconnectPromise;
    }

    this.reconnectPromise = this._doConnect();
    try {
      await this.reconnectPromise;
    } finally {
      this.reconnectPromise = null;
    }
  }

  private async _doConnect(): Promise<void> {
    // Close stale client if this is a reconnect attempt
    if (this.client) {
      try {
        await this.client.close();
      } catch {
        // Ignore errors closing a stale/broken client
      }
      this.client = null;
    }

    const maxAttempts = 5;
    const retryDelayMs = 1500;

    this.config.logger.info(
      { userId: this.config.userId, url: this.config.mcpServerUrl },
      'Connecting to user MCP server'
    );

    let lastError: unknown;
    for (let attempt = 1; attempt <= maxAttempts; attempt++) {
      try {
        this.client = new Client(
          {
            name: 'dexorder-gateway',
            version: '0.1.0',
          },
          {
            capabilities: {
              sampling: {},
            },
          }
        );

        // Streamable HTTP: single /mcp endpoint, session tracked via mcp-session-id header
        const transport = new StreamableHTTPClientTransport(
          new URL(`${this.config.mcpServerUrl}/mcp`)
        );

        await this.client.connect(transport);

        // Hook client.onerror to detect transport failures (e.g. sandbox restart returning
        // 404 "session not found").  When fired, mark disconnected so the next callTool /
        // listTools call triggers a full reconnect + initialize handshake.
        const connectedClient = this.client;
        const origOnError = this.client.onerror;
        this.client.onerror = (error) => {
          origOnError?.(error);
          // Only act on the currently-active client (ignore stale closures after reconnect)
          if (this.client === connectedClient && this.connected) {
            this.config.logger.warn(
              { error },
              'MCP transport error — marking disconnected for lazy reconnect'
            );
            this.connected = false;
          }
        };

        this.connected = true;
        this.config.logger.info('Connected to user MCP server');
        return;
      } catch (error) {
        lastError = error;
        this.client = null;
        if (attempt < maxAttempts) {
          this.config.logger.warn(
            { error, userId: this.config.userId, attempt, maxAttempts },
            'MCP connect attempt failed, retrying...'
          );
          await new Promise(resolve => setTimeout(resolve, retryDelayMs));
        }
      }
    }

    this.config.logger.error(
      { error: lastError, userId: this.config.userId },
      'Failed to connect to user MCP server'
    );
    throw lastError;
  }

  /**
   * Ensure the client is connected, reconnecting if necessary.
   * Used as a preamble for every public method so a sandbox restart is
   * recovered transparently on the next tool call.
   */
  private async ensureConnected(): Promise<void> {
    if (!this.client || !this.connected) {
      this.config.logger.info(
        { userId: this.config.userId },
        'MCP not connected, attempting reconnect'
      );
      await this.connect();
    }
  }

  /**
   * Call a tool on the user's MCP server
   */
  async callTool(name: string, args: Record<string, unknown>): Promise<unknown> {
    await this.ensureConnected();

    try {
      this.config.logger.debug({ tool: name, args }, 'Calling MCP tool');

      // Use a generous timeout: ExecuteResearch runs a subprocess with a 300s limit,
      // so the default 60s MCP SDK timeout would fire before the script completes.
      const result = await this.client!.callTool({ name, arguments: args }, undefined, { timeout: 330000 });
      return result;
    } catch (error) {
      this.config.logger.error({ error, tool: name }, 'MCP tool call failed');
      throw error;
    }
  }

  /**
   * List available tools from user's MCP server
   * Returns all available tools from the MCP server
   */
  async listTools(): Promise<Array<{ name: string; description?: string; inputSchema?: any }>> {
    await this.ensureConnected();

    try {
      this.config.logger.debug('Requesting tool list from MCP server');
      const response = await this.client!.listTools();

      this.config.logger.debug(
        {
          hasTools: !!response.tools,
          toolCount: response.tools?.length || 0,
        },
        'Received tool list response'
      );

      // Handle case where response.tools might be undefined
      if (!response.tools || !Array.isArray(response.tools)) {
        this.config.logger.warn('MCP server returned no tools array');
        return [];
      }

      // Return all tools - agent-to-tool binding is handled by the tool registry
      const tools = response.tools.map((tool: any) => ({
        name: tool.name,
        description: tool.description,
        inputSchema: tool.inputSchema,
      }));

      this.config.logger.debug(
        { toolCount: tools.length },
        'Listed MCP tools'
      );

      return tools;
    } catch (error) {
      this.config.logger.error(
        {
          error,
          errorMessage: (error as Error)?.message,
          errorName: (error as Error)?.name,
          errorCode: (error as any)?.code,
          errorStack: (error as Error)?.stack,
        },
        'Failed to list MCP tools'
      );
      throw error;
    }
  }

  /**
   * List available resources from user's MCP server
   * Returns all available resources from the MCP server
   */
  async listResources(): Promise<Array<{ uri: string; name: string; description?: string; mimeType?: string }>> {
    await this.ensureConnected();

    try {
      const response = await this.client!.listResources();

      // Return all resources - agent-to-resource binding is handled by the tool registry
      const resources = response.resources.map((resource: any) => ({
        uri: resource.uri,
        name: resource.name,
        description: resource.description,
        mimeType: resource.mimeType,
      }));

      this.config.logger.debug(
        { resourceCount: resources.length },
        'Listed MCP resources'
      );

      return resources;
    } catch (error) {
      this.config.logger.error({ error }, 'Failed to list MCP resources');
      throw error;
    }
  }

  /**
   * Read a resource from user's MCP server
   */
  async readResource(uri: string): Promise<{ uri: string; mimeType?: string; text?: string; blob?: string }> {
    await this.ensureConnected();

    try {
      this.config.logger.debug({ uri }, 'Reading MCP resource');

      const response = await this.client!.readResource({ uri });

      // Extract the first content item (MCP returns array of contents)
      const content = response.contents[0];

      // Handle union type: content is either TextContent or BlobContent
      return {
        uri: content.uri,
        mimeType: content.mimeType,
        text: 'text' in content ? content.text : undefined,
        blob: 'blob' in content ? content.blob : undefined,
      };
    } catch (error) {
      this.config.logger.error({ error, uri }, 'MCP resource read failed');
      throw error;
    }
  }

  /**
   * Disconnect from MCP server
   */
  async disconnect(): Promise<void> {
    if (this.client) {
      try {
        await this.client.close();
        if (this.connected) {
          this.config.logger.info('Disconnected from user MCP server');
        }
      } catch (error) {
        this.config.logger.error({ error }, 'Error disconnecting from MCP server');
      }
    }
    this.connected = false;
    this.client = null;
    this.reconnectPromise = null;
  }

  isConnected(): boolean {
    return this.connected;
  }

  get userId(): string {
    return this.config.userId;
  }
}
