/**
 * @license
 * Copyright 2025 Google LLC
 * SPDX-License-Identifier: Apache-2.0
 */

import * as http from 'node:http';
import * as crypto from 'node:crypto';
import type * as net from 'node:net';
import { URL } from 'node:url';
import { openBrowserSecurely } from '../utils/secure-browser-launcher.js';
import type { OAuthToken } from './token-storage/types.js';
import { MCPOAuthTokenStorage } from './oauth-token-storage.js';
import { getErrorMessage, FatalCancellationError } from '../utils/errors.js';
import { OAuthUtils, ResourceMismatchError } from './oauth-utils.js';
import { coreEvents } from '../utils/events.js';
import { debugLogger } from '../utils/debugLogger.js';
import { getConsentForOauth } from '../utils/authConsent.js';

export const OAUTH_DISPLAY_MESSAGE_EVENT = 'oauth-display-message' as const;

/**
 * OAuth configuration for an MCP server.
 */
export interface MCPOAuthConfig {
  enabled?: boolean; // Whether OAuth is enabled for this server
  clientId?: string;
  clientSecret?: string;
  authorizationUrl?: string;
  tokenUrl?: string;
  scopes?: string[];
  audiences?: string[];
  redirectUri?: string;
  tokenParamName?: string; // For SSE connections, specifies the query parameter name for the token
  registrationUrl?: string;
}

/**
 * OAuth authorization response.
 */
export interface OAuthAuthorizationResponse {
  code: string;
  state: string;
}

/**
 * OAuth token response from the authorization server.
 */
export interface OAuthTokenResponse {
  access_token: string;
  token_type: string;
  expires_in?: number;
  refresh_token?: string;
  scope?: string;
}

/**
 * Dynamic client registration request (RFC 7591).
 */
export interface OAuthClientRegistrationRequest {
  client_name: string;
  redirect_uris: string[];
  grant_types: string[];
  response_types: string[];
  token_endpoint_auth_method: string;
  scope?: string;
}

/**
 * Dynamic client registration response (RFC 7591).
 */
export interface OAuthClientRegistrationResponse {
  client_id: string;
  client_secret?: string;
  client_id_issued_at?: number;
  client_secret_expires_at?: number;
  redirect_uris: string[];
  grant_types: string[];
  response_types: string[];
  token_endpoint_auth_method: string;
  scope?: string;
}

/**
 * PKCE (Proof Key for Code Exchange) parameters.
 */
interface PKCEParams {
  codeVerifier: string;
  codeChallenge: string;
  state: string;
}

const REDIRECT_PATH = '/oauth/callback';
const HTTP_OK = 200;

/**
 * Provider for handling OAuth authentication for MCP servers.
 */
export class MCPOAuthProvider {
  private readonly tokenStorage: MCPOAuthTokenStorage;

  constructor(tokenStorage: MCPOAuthTokenStorage = new MCPOAuthTokenStorage()) {
    this.tokenStorage = tokenStorage;
  }

  /**
   * Register a client dynamically with the OAuth server.
   *
   * @param registrationUrl The client registration endpoint URL
   * @param config OAuth configuration
   * @param redirectPort The port to use for the redirect URI
   * @returns The registered client information
   */
  private async registerClient(
    registrationUrl: string,
    config: MCPOAuthConfig,
    redirectPort: number,
  ): Promise<OAuthClientRegistrationResponse> {
    const redirectUri =
      config.redirectUri || `http://localhost:${redirectPort}${REDIRECT_PATH}`;

    const registrationRequest: OAuthClientRegistrationRequest = {
      client_name: 'Gemini CLI MCP Client',
      redirect_uris: [redirectUri],
      grant_types: ['authorization_code', 'refresh_token'],
      response_types: ['code'],
      token_endpoint_auth_method: 'none', // Public client
      scope: config.scopes?.join(' ') || '',
    };

    const response = await fetch(registrationUrl, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify(registrationRequest),
    });

    if (!response.ok) {
      const errorText = await response.text();
      throw new Error(
        `Client registration failed: ${response.status} ${response.statusText} - ${errorText}`,
      );
    }

    // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
    return (await response.json()) as OAuthClientRegistrationResponse;
  }

  /**
   * Discover OAuth configuration from an MCP server URL.
   *
   * @param mcpServerUrl The MCP server URL
   * @returns OAuth configuration if discovered, null otherwise
   */
  private async discoverOAuthFromMCPServer(
    mcpServerUrl: string,
  ): Promise<MCPOAuthConfig | null> {
    // Use the full URL with path preserved for OAuth discovery
    return OAuthUtils.discoverOAuthConfig(mcpServerUrl);
  }

  private async discoverAuthServerMetadataForRegistration(
    authorizationUrl: string,
  ): Promise<{
    issuerUrl: string;
    metadata: NonNullable<
      Awaited<ReturnType<typeof OAuthUtils.discoverAuthorizationServerMetadata>>
    >;
  }> {
    const authUrl = new URL(authorizationUrl);

    // Preserve path components for issuers with path-based discovery (e.g., Keycloak)
    // Extract issuer by removing the OIDC protocol-specific path suffix
    // For example: http://localhost:8888/realms/my-realm/protocol/openid-connect/auth
    //           -> http://localhost:8888/realms/my-realm
    const oidcPatterns = [
      '/protocol/openid-connect/auth',
      '/protocol/openid-connect/authorize',
      '/oauth2/authorize',
      '/oauth/authorize',
      '/authorize',
    ];

    let pathname = authUrl.pathname.replace(/\/$/, ''); // Trim trailing slash
    for (const pattern of oidcPatterns) {
      if (pathname.endsWith(pattern)) {
        pathname = pathname.slice(0, -pattern.length);
        break;
      }
    }

    const issuerCandidates = new Set<string>();
    issuerCandidates.add(authUrl.origin);

    if (pathname) {
      issuerCandidates.add(`${authUrl.origin}${pathname}`);

      const versionSegmentPattern = /^v\d+(\.\d+)?$/i;
      const segments = pathname.split('/').filter(Boolean);
      const lastSegment = segments.at(-1);
      if (lastSegment && versionSegmentPattern.test(lastSegment)) {
        const withoutVersionPath = segments.slice(0, -1);
        if (withoutVersionPath.length) {
          issuerCandidates.add(
            `${authUrl.origin}/${withoutVersionPath.join('/')}`,
          );
        }
      }
    }

    const attemptedIssuers = Array.from(issuerCandidates);
    let selectedIssuer = attemptedIssuers[0];
    let discoveredMetadata: NonNullable<
      Awaited<ReturnType<typeof OAuthUtils.discoverAuthorizationServerMetadata>>
    > | null = null;

    for (const issuer of attemptedIssuers) {
      debugLogger.debug(`   Trying issuer URL: ${issuer}`);
      const metadata =
        await OAuthUtils.discoverAuthorizationServerMetadata(issuer);
      if (metadata) {
        selectedIssuer = issuer;
        discoveredMetadata = metadata;
        break;
      }
    }

    if (!discoveredMetadata) {
      throw new Error(
        `Failed to fetch authorization server metadata for client registration (attempted issuers: ${attemptedIssuers.join(', ')})`,
      );
    }

    debugLogger.debug(`   Selected issuer URL: ${selectedIssuer}`);
    return {
      issuerUrl: selectedIssuer,
      metadata: discoveredMetadata,
    };
  }

  /**
   * Generate PKCE parameters for OAuth flow.
   *
   * @returns PKCE parameters including code verifier, challenge, and state
   */
  private generatePKCEParams(): PKCEParams {
    // Generate code verifier (43-128 characters)
    // using 64 bytes results in ~86 characters, safely above the minimum of 43
    const codeVerifier = crypto.randomBytes(64).toString('base64url');

    // Generate code challenge using SHA256
    const codeChallenge = crypto
      .createHash('sha256')
      .update(codeVerifier)
      .digest('base64url');

    // Generate state for CSRF protection
    const state = crypto.randomBytes(16).toString('base64url');

    return { codeVerifier, codeChallenge, state };
  }

  /**
   * Start a local HTTP server to handle OAuth callback.
   * The server will listen on the specified port (or port 0 for OS assignment).
   *
   * @param expectedState The state parameter to validate
   * @returns Object containing the port (available immediately) and a promise for the auth response
   */
  private startCallbackServer(
    expectedState: string,
    port?: number,
  ): {
    port: Promise<number>;
    response: Promise<OAuthAuthorizationResponse>;
  } {
    let portResolve: (port: number) => void;
    let portReject: (error: Error) => void;
    const portPromise = new Promise<number>((resolve, reject) => {
      portResolve = resolve;
      portReject = reject;
    });

    const responsePromise = new Promise<OAuthAuthorizationResponse>(
      (resolve, reject) => {
        let serverPort: number;

        const server = http.createServer(
          async (req: http.IncomingMessage, res: http.ServerResponse) => {
            try {
              const url = new URL(req.url!, `http://localhost:${serverPort}`);

              if (url.pathname !== REDIRECT_PATH) {
                res.writeHead(404);
                res.end('Not found');
                return;
              }

              const code = url.searchParams.get('code');
              const state = url.searchParams.get('state');
              const error = url.searchParams.get('error');

              if (error) {
                res.writeHead(HTTP_OK, { 'Content-Type': 'text/html' });
                res.end(`
              <html>
                <body>
                  <h1>Authentication Failed</h1>
                  <p>Error: ${error.replace(/</g, '&lt;').replace(/>/g, '&gt;')}</p>
                  <p>${(url.searchParams.get('error_description') || '').replace(/</g, '&lt;').replace(/>/g, '&gt;')}</p>
                  <p>You can close this window.</p>
                </body>
              </html>
            `);
                server.close();
                reject(new Error(`OAuth error: ${error}`));
                return;
              }

              if (!code || !state) {
                res.writeHead(400);
                res.end('Missing code or state parameter');
                return;
              }

              if (state !== expectedState) {
                res.writeHead(400);
                res.end('Invalid state parameter');
                server.close();
                reject(new Error('State mismatch - possible CSRF attack'));
                return;
              }

              // Send success response to browser
              res.writeHead(HTTP_OK, { 'Content-Type': 'text/html' });
              res.end(`
            <html>
              <body>
                <h1>Authentication Successful!</h1>
                <p>You can close this window and return to Gemini CLI.</p>
                <script>window.close();</script>
              </body>
            </html>
          `);

              server.close();
              resolve({ code, state });
            } catch (error) {
              server.close();
              reject(error);
            }
          },
        );

        server.on('error', (error) => {
          portReject(error);
          reject(error);
        });

        // Determine which port to use (env var, argument, or OS-assigned)
        let listenPort = 0; // Default to OS-assigned port

        const portStr = process.env['OAUTH_CALLBACK_PORT'];
        if (portStr) {
          const envPort = parseInt(portStr, 10);
          if (isNaN(envPort) || envPort <= 0 || envPort > 65535) {
            const error = new Error(
              `Invalid value for OAUTH_CALLBACK_PORT: "${portStr}"`,
            );
            portReject(error);
            reject(error);
            return;
          }
          listenPort = envPort;
        } else if (port !== undefined) {
          listenPort = port;
        }

        server.listen(listenPort, () => {
          // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
          const address = server.address() as net.AddressInfo;
          serverPort = address.port;
          debugLogger.log(
            `OAuth callback server listening on port ${serverPort}`,
          );
          portResolve(serverPort); // Resolve port promise immediately
        });

        // Timeout after 5 minutes
        setTimeout(
          () => {
            server.close();
            reject(new Error('OAuth callback timeout'));
          },
          5 * 60 * 1000,
        );
      },
    );

    return { port: portPromise, response: responsePromise };
  }

  /**
   * Extract the port number from a URL string if available and valid.
   *
   * @param urlString The URL string to parse
   * @returns The port number or undefined if not found or invalid
   */
  private getPortFromUrl(urlString?: string): number | undefined {
    if (!urlString) {
      return undefined;
    }

    try {
      const url = new URL(urlString);
      if (url.port) {
        const parsedPort = parseInt(url.port, 10);
        if (!isNaN(parsedPort) && parsedPort > 0 && parsedPort <= 65535) {
          return parsedPort;
        }
      }
    } catch {
      // Ignore invalid URL
    }

    return undefined;
  }

  /**
   * Build the authorization URL for the OAuth flow.

   *
   * @param config OAuth configuration
   * @param pkceParams PKCE parameters
   * @param redirectPort The port to use for the redirect URI
   * @param mcpServerUrl The MCP server URL to use as the resource parameter
   * @returns The authorization URL
   */
  private buildAuthorizationUrl(
    config: MCPOAuthConfig,
    pkceParams: PKCEParams,
    redirectPort: number,
    mcpServerUrl?: string,
  ): string {
    const redirectUri =
      config.redirectUri || `http://localhost:${redirectPort}${REDIRECT_PATH}`;

    const params = new URLSearchParams({
      client_id: config.clientId!,
      response_type: 'code',
      redirect_uri: redirectUri,
      state: pkceParams.state,
      code_challenge: pkceParams.codeChallenge,
      code_challenge_method: 'S256',
    });

    if (config.scopes && config.scopes.length > 0) {
      params.append('scope', config.scopes.join(' '));
    }

    if (config.audiences && config.audiences.length > 0) {
      params.append('audience', config.audiences.join(' '));
    }

    // Add resource parameter for MCP OAuth spec compliance
    // Only add if we have an MCP server URL (indicates MCP OAuth flow, not standard OAuth)
    if (mcpServerUrl) {
      try {
        params.append(
          'resource',
          OAuthUtils.buildResourceParameter(mcpServerUrl),
        );
      } catch (error) {
        debugLogger.warn(
          `Could not add resource parameter: ${getErrorMessage(error)}`,
        );
      }
    }

    const url = new URL(config.authorizationUrl!);
    params.forEach((value, key) => {
      url.searchParams.append(key, value);
    });
    return url.toString();
  }

  /**
   * Exchange authorization code for tokens.
   *
   * @param config OAuth configuration
   * @param code Authorization code
   * @param codeVerifier PKCE code verifier
   * @param redirectPort The port to use for the redirect URI
   * @param mcpServerUrl The MCP server URL to use as the resource parameter
   * @returns The token response
   */
  private async exchangeCodeForToken(
    config: MCPOAuthConfig,
    code: string,
    codeVerifier: string,
    redirectPort: number,
    mcpServerUrl?: string,
  ): Promise<OAuthTokenResponse> {
    const redirectUri =
      config.redirectUri || `http://localhost:${redirectPort}${REDIRECT_PATH}`;

    const params = new URLSearchParams({
      grant_type: 'authorization_code',
      code,
      redirect_uri: redirectUri,
      code_verifier: codeVerifier,
      client_id: config.clientId!,
    });

    if (config.clientSecret) {
      params.append('client_secret', config.clientSecret);
    }

    if (config.audiences && config.audiences.length > 0) {
      params.append('audience', config.audiences.join(' '));
    }

    // Add resource parameter for MCP OAuth spec compliance
    // Only add if we have an MCP server URL (indicates MCP OAuth flow, not standard OAuth)
    if (mcpServerUrl) {
      const resourceUrl = mcpServerUrl;
      try {
        params.append(
          'resource',
          OAuthUtils.buildResourceParameter(resourceUrl),
        );
      } catch (error) {
        debugLogger.warn(
          `Could not add resource parameter: ${getErrorMessage(error)}`,
        );
      }
    }

    const response = await fetch(config.tokenUrl!, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/x-www-form-urlencoded',
        Accept: 'application/json, application/x-www-form-urlencoded',
      },
      body: params.toString(),
    });

    const responseText = await response.text();
    const contentType = response.headers.get('content-type') || '';

    if (!response.ok) {
      // Try to parse error from form-urlencoded response
      let errorMessage: string | null = null;
      try {
        const errorParams = new URLSearchParams(responseText);
        const error = errorParams.get('error');
        const errorDescription = errorParams.get('error_description');
        if (error) {
          errorMessage = `Token exchange failed: ${error} - ${errorDescription || 'No description'}`;
        }
      } catch {
        // Fall back to raw error
      }
      throw new Error(
        errorMessage ||
          `Token exchange failed: ${response.status} - ${responseText}`,
      );
    }

    // Log unexpected content types for debugging
    if (
      !contentType.includes('application/json') &&
      !contentType.includes('application/x-www-form-urlencoded')
    ) {
      debugLogger.warn(
        `Token endpoint returned unexpected content-type: ${contentType}. ` +
          `Expected application/json or application/x-www-form-urlencoded. ` +
          `Will attempt to parse response.`,
      );
    }

    // Try to parse as JSON first, fall back to form-urlencoded
    try {
      // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
      return JSON.parse(responseText) as OAuthTokenResponse;
    } catch {
      // Parse form-urlencoded response
      const tokenParams = new URLSearchParams(responseText);
      const accessToken = tokenParams.get('access_token');
      const tokenType = tokenParams.get('token_type') || 'Bearer';
      const expiresIn = tokenParams.get('expires_in');
      const refreshToken = tokenParams.get('refresh_token');
      const scope = tokenParams.get('scope');

      if (!accessToken) {
        // Check for error in response
        const error = tokenParams.get('error');
        const errorDescription = tokenParams.get('error_description');
        throw new Error(
          `Token exchange failed: ${error || 'no_access_token'} - ${errorDescription || responseText}`,
        );
      }

      return {
        access_token: accessToken,
        token_type: tokenType,
        expires_in: expiresIn ? parseInt(expiresIn, 10) : undefined,
        refresh_token: refreshToken || undefined,
        scope: scope || undefined,
      } as OAuthTokenResponse;
    }
  }

  /**
   * Refresh an access token using a refresh token.
   *
   * @param config OAuth configuration
   * @param refreshToken The refresh token
   * @param tokenUrl The token endpoint URL
   * @param mcpServerUrl The MCP server URL to use as the resource parameter
   * @returns The new token response
   */
  async refreshAccessToken(
    config: MCPOAuthConfig,
    refreshToken: string,
    tokenUrl: string,
    mcpServerUrl?: string,
  ): Promise<OAuthTokenResponse> {
    const params = new URLSearchParams({
      grant_type: 'refresh_token',
      refresh_token: refreshToken,
      client_id: config.clientId!,
    });

    if (config.clientSecret) {
      params.append('client_secret', config.clientSecret);
    }

    if (config.scopes && config.scopes.length > 0) {
      params.append('scope', config.scopes.join(' '));
    }

    if (config.audiences && config.audiences.length > 0) {
      params.append('audience', config.audiences.join(' '));
    }

    // Add resource parameter for MCP OAuth spec compliance
    // Only add if we have an MCP server URL (indicates MCP OAuth flow, not standard OAuth)
    if (mcpServerUrl) {
      try {
        params.append(
          'resource',
          OAuthUtils.buildResourceParameter(mcpServerUrl),
        );
      } catch (error) {
        debugLogger.warn(
          `Could not add resource parameter: ${getErrorMessage(error)}`,
        );
      }
    }

    const response = await fetch(tokenUrl, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/x-www-form-urlencoded',
        Accept: 'application/json, application/x-www-form-urlencoded',
      },
      body: params.toString(),
    });

    const responseText = await response.text();
    const contentType = response.headers.get('content-type') || '';

    if (!response.ok) {
      // Try to parse error from form-urlencoded response
      let errorMessage: string | null = null;
      try {
        const errorParams = new URLSearchParams(responseText);
        const error = errorParams.get('error');
        const errorDescription = errorParams.get('error_description');
        if (error) {
          errorMessage = `Token refresh failed: ${error} - ${errorDescription || 'No description'}`;
        }
      } catch {
        // Fall back to raw error
      }
      throw new Error(
        errorMessage ||
          `Token refresh failed: ${response.status} - ${responseText}`,
      );
    }

    // Log unexpected content types for debugging
    if (
      !contentType.includes('application/json') &&
      !contentType.includes('application/x-www-form-urlencoded')
    ) {
      debugLogger.warn(
        `Token refresh endpoint returned unexpected content-type: ${contentType}. ` +
          `Expected application/json or application/x-www-form-urlencoded. ` +
          `Will attempt to parse response.`,
      );
    }

    // Try to parse as JSON first, fall back to form-urlencoded
    try {
      // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
      return JSON.parse(responseText) as OAuthTokenResponse;
    } catch {
      // Parse form-urlencoded response
      const tokenParams = new URLSearchParams(responseText);
      const accessToken = tokenParams.get('access_token');
      const tokenType = tokenParams.get('token_type') || 'Bearer';
      const expiresIn = tokenParams.get('expires_in');
      const refreshToken = tokenParams.get('refresh_token');
      const scope = tokenParams.get('scope');

      if (!accessToken) {
        // Check for error in response
        const error = tokenParams.get('error');
        const errorDescription = tokenParams.get('error_description');
        throw new Error(
          `Token refresh failed: ${error || 'unknown_error'} - ${errorDescription || responseText}`,
        );
      }

      return {
        access_token: accessToken,
        token_type: tokenType,
        expires_in: expiresIn ? parseInt(expiresIn, 10) : undefined,
        refresh_token: refreshToken || undefined,
        scope: scope || undefined,
      } as OAuthTokenResponse;
    }
  }

  /**
   * Perform the full OAuth authorization code flow with PKCE.
   *
   * @param serverName The name of the MCP server
   * @param config OAuth configuration
   * @param mcpServerUrl Optional MCP server URL for OAuth discovery
   * @param messageHandler Optional handler for displaying user-facing messages
   * @returns The obtained OAuth token
   */
  async authenticate(
    serverName: string,
    config: MCPOAuthConfig,
    mcpServerUrl?: string,
  ): Promise<OAuthToken> {
    // Helper function to display messages through handler or fallback to console.log
    const displayMessage = (message: string) => {
      coreEvents.emitFeedback('info', message);
    };

    // If no authorization URL is provided, try to discover OAuth configuration
    if (!config.authorizationUrl && mcpServerUrl) {
      debugLogger.debug(`Starting OAuth for MCP server "${serverName}"…
✓ No authorization URL; using OAuth discovery`);

      // First check if the server requires authentication via WWW-Authenticate header
      try {
        const headers: HeadersInit = OAuthUtils.isSSEEndpoint(mcpServerUrl)
          ? { Accept: 'text/event-stream' }
          : { Accept: 'application/json' };

        const response = await fetch(mcpServerUrl, {
          method: 'HEAD',
          headers,
        });

        if (response.status === 401 || response.status === 307) {
          const wwwAuthenticate = response.headers.get('www-authenticate');

          if (wwwAuthenticate) {
            const discoveredConfig =
              await OAuthUtils.discoverOAuthFromWWWAuthenticate(
                wwwAuthenticate,
                mcpServerUrl,
              );
            if (discoveredConfig) {
              // Merge discovered config with existing config, preserving clientId and clientSecret
              config = {
                ...config,
                authorizationUrl: discoveredConfig.authorizationUrl,
                tokenUrl: discoveredConfig.tokenUrl,
                scopes: config.scopes || discoveredConfig.scopes || [],
                // Preserve existing client credentials
                clientId: config.clientId,
                clientSecret: config.clientSecret,
              };
            }
          }
        }
      } catch (error) {
        // Re-throw security validation errors
        if (error instanceof ResourceMismatchError) {
          throw error;
        }

        debugLogger.debug(
          `Failed to check endpoint for authentication requirements: ${getErrorMessage(error)}`,
        );
      }

      // If we still don't have OAuth config, try the standard discovery
      if (!config.authorizationUrl) {
        const discoveredConfig =
          await this.discoverOAuthFromMCPServer(mcpServerUrl);
        if (discoveredConfig) {
          // Merge discovered config with existing config, preserving clientId and clientSecret
          config = {
            ...config,
            authorizationUrl: discoveredConfig.authorizationUrl,
            tokenUrl: discoveredConfig.tokenUrl,
            scopes: config.scopes || discoveredConfig.scopes || [],
            registrationUrl: discoveredConfig.registrationUrl,
            // Preserve existing client credentials
            clientId: config.clientId,
            clientSecret: config.clientSecret,
          };
        } else {
          throw new Error(
            'Failed to discover OAuth configuration from MCP server',
          );
        }
      }
    }

    // Generate PKCE parameters
    const pkceParams = this.generatePKCEParams();

    // Determine preferred port from redirectUri if available
    const preferredPort = this.getPortFromUrl(config.redirectUri);

    // Start callback server first to allocate port
    // This ensures we only create one server and eliminates race conditions
    const callbackServer = this.startCallbackServer(
      pkceParams.state,
      preferredPort,
    );

    // Wait for server to start and get the allocated port
    // We need this port for client registration and auth URL building
    const redirectPort = await callbackServer.port;
    debugLogger.debug(`Callback server listening on port ${redirectPort}`);

    // If no client ID is provided, try dynamic client registration
    if (!config.clientId) {
      let registrationUrl = config.registrationUrl;

      // If no registration URL was previously discovered, try to discover it
      if (!registrationUrl) {
        // Extract server URL from authorization URL
        if (!config.authorizationUrl) {
          throw new Error(
            'Cannot perform dynamic registration without authorization URL',
          );
        }

        debugLogger.debug('→ Attempting dynamic client registration...');
        const { metadata: authServerMetadata } =
          await this.discoverAuthServerMetadataForRegistration(
            config.authorizationUrl,
          );
        registrationUrl = authServerMetadata.registration_endpoint;
      }

      // Register client if registration endpoint is available
      if (registrationUrl) {
        const clientRegistration = await this.registerClient(
          registrationUrl,
          config,
          redirectPort,
        );

        config.clientId = clientRegistration.client_id;
        if (clientRegistration.client_secret) {
          config.clientSecret = clientRegistration.client_secret;
        }

        debugLogger.debug('✓ Dynamic client registration successful');
      } else {
        throw new Error(
          'No client ID provided and dynamic registration not supported',
        );
      }
    }

    // Validate configuration
    if (!config.clientId || !config.authorizationUrl || !config.tokenUrl) {
      throw new Error(
        'Missing required OAuth configuration after discovery and registration',
      );
    }

    // Build authorization URL
    const authUrl = this.buildAuthorizationUrl(
      config,
      pkceParams,
      redirectPort,
      mcpServerUrl,
    );

    const userConsent = await getConsentForOauth(
      `Authentication required for MCP Server: '${serverName}.'`,
    );
    if (!userConsent) {
      throw new FatalCancellationError('Authentication cancelled by user.');
    }

    displayMessage(`→ Opening your browser for OAuth sign-in...

If the browser does not open, copy and paste this URL into your browser:
${authUrl}

💡 TIP: Triple-click to select the entire URL, then copy and paste it into your browser.
⚠️  Make sure to copy the COMPLETE URL - it may wrap across multiple lines.`);

    // Open browser securely (callback server is already running)
    try {
      await openBrowserSecurely(authUrl);
    } catch (error) {
      debugLogger.warn(
        'Failed to open browser automatically:',
        getErrorMessage(error),
      );
    }

    // Wait for callback
    const { code } = await callbackServer.response;

    debugLogger.debug(
      '✓ Authorization code received, exchanging for tokens...',
    );

    // Exchange code for tokens
    const tokenResponse = await this.exchangeCodeForToken(
      config,
      code,
      pkceParams.codeVerifier,
      redirectPort,
      mcpServerUrl,
    );

    // Convert to our token format
    if (!tokenResponse.access_token) {
      throw new Error('No access token received from token endpoint');
    }

    const token: OAuthToken = {
      accessToken: tokenResponse.access_token,
      tokenType: tokenResponse.token_type || 'Bearer',
      refreshToken: tokenResponse.refresh_token,
      scope: tokenResponse.scope,
    };

    if (tokenResponse.expires_in) {
      token.expiresAt = Date.now() + tokenResponse.expires_in * 1000;
    }

    // Save token
    try {
      await this.tokenStorage.saveToken(
        serverName,
        token,
        config.clientId,
        config.tokenUrl,
        mcpServerUrl,
      );
      debugLogger.debug('✓ Authentication successful! Token saved.');

      // Verify token was saved
      const savedToken = await this.tokenStorage.getCredentials(serverName);
      if (savedToken && savedToken.token && savedToken.token.accessToken) {
        // Avoid leaking token material; log a short SHA-256 fingerprint instead.
        const tokenFingerprint = crypto
          .createHash('sha256')
          .update(savedToken.token.accessToken)
          .digest('hex')
          .slice(0, 8);
        debugLogger.debug(
          `✓ Token verification successful (fingerprint: ${tokenFingerprint})`,
        );
      } else {
        debugLogger.warn(
          'Token verification failed: token not found or invalid after save',
        );
      }
    } catch (saveError) {
      debugLogger.error('Failed to save auth token.', saveError);
      throw saveError;
    }

    return token;
  }

  /**
   * Get a valid access token for an MCP server, refreshing if necessary.
   *
   * @param serverName The name of the MCP server
   * @param config OAuth configuration
   * @returns A valid access token or null if not authenticated
   */
  async getValidToken(
    serverName: string,
    config: MCPOAuthConfig,
  ): Promise<string | null> {
    debugLogger.debug(`Getting valid token for server: ${serverName}`);
    const credentials = await this.tokenStorage.getCredentials(serverName);

    if (!credentials) {
      debugLogger.debug(`No credentials found for server: ${serverName}`);
      return null;
    }

    const { token } = credentials;
    debugLogger.debug(
      `Found token for server: ${serverName}, expired: ${this.tokenStorage.isTokenExpired(token)}`,
    );

    // Check if token is expired
    if (!this.tokenStorage.isTokenExpired(token)) {
      debugLogger.debug(`Returning valid token for server: ${serverName}`);
      return token.accessToken;
    }

    // Try to refresh if we have a refresh token
    if (token.refreshToken && config.clientId && credentials.tokenUrl) {
      try {
        debugLogger.log(
          `Refreshing expired token for MCP server: ${serverName}`,
        );

        const newTokenResponse = await this.refreshAccessToken(
          config,
          token.refreshToken,
          credentials.tokenUrl,
          credentials.mcpServerUrl,
        );

        // Update stored token
        const newToken: OAuthToken = {
          accessToken: newTokenResponse.access_token,
          tokenType: newTokenResponse.token_type,
          refreshToken: newTokenResponse.refresh_token || token.refreshToken,
          scope: newTokenResponse.scope || token.scope,
        };

        if (newTokenResponse.expires_in) {
          newToken.expiresAt = Date.now() + newTokenResponse.expires_in * 1000;
        }

        await this.tokenStorage.saveToken(
          serverName,
          newToken,
          config.clientId,
          credentials.tokenUrl,
          credentials.mcpServerUrl,
        );

        return newToken.accessToken;
      } catch (error) {
        coreEvents.emitFeedback(
          'error',
          'Failed to refresh auth token.',
          error,
        );
        // Remove invalid token
        await this.tokenStorage.deleteCredentials(serverName);
      }
    }

    return null;
  }
}
