/**
 * HTTP client for communicating with the mcpd daemon.
 * Wraps fetch calls with auth headers and error handling.
 */

/** Thrown when mcpd returns a 401 Unauthorized response. */
export class AuthenticationError extends Error {
  constructor(message = 'Authentication failed: invalid or expired token') {
    super(message);
    this.name = 'AuthenticationError';
  }
}

/** Thrown when mcpd is unreachable (connection refused, DNS failure, etc.). */
export class ConnectionError extends Error {
  constructor(url: string, cause?: unknown) {
    const msg = `Cannot connect to mcpd at ${url}`;
    super(cause instanceof Error ? `${msg}: ${cause.message}` : msg);
    this.name = 'ConnectionError';
  }
}

/** Default timeout for mcpd requests (ms). Prevents indefinite hangs on slow upstream tool calls. */
export const DEFAULT_TIMEOUT_MS = 30_000;

/**
 * Discovery-class operations (tools/list, resources/list, prompts/list) should not share
 * the full tool-call timeout budget — a single dead upstream would stall session init for
 * the entire window. Override via `MCPLOCAL_DISCOVERY_TIMEOUT_MS`.
 */
export const DISCOVERY_TIMEOUT_MS = Number(process.env['MCPLOCAL_DISCOVERY_TIMEOUT_MS']) || 8_000;

export class McpdClient {
  private readonly baseUrl: string;
  private readonly token: string;
  private readonly extraHeaders: Record<string, string>;
  private readonly timeoutMs: number;

  constructor(baseUrl: string, token: string, extraHeaders?: Record<string, string>, timeoutMs?: number) {
    // Strip trailing slash for consistent URL joining
    this.baseUrl = baseUrl.replace(/\/+$/, '');
    this.token = token;
    this.extraHeaders = extraHeaders ?? {};
    this.timeoutMs = timeoutMs ?? DEFAULT_TIMEOUT_MS;
  }

  /**
   * Create a new client with additional default headers.
   * Inherits base URL, token, and timeout from the current client.
   */
  withHeaders(headers: Record<string, string>): McpdClient {
    return new McpdClient(this.baseUrl, this.token, { ...this.extraHeaders, ...headers }, this.timeoutMs);
  }

  /**
   * Create a new client with a different per-request timeout. Used by mcplocal's
   * discovery path to avoid sharing the slow tool-call budget.
   */
  withTimeout(timeoutMs: number): McpdClient {
    return new McpdClient(this.baseUrl, this.token, { ...this.extraHeaders }, timeoutMs);
  }

  /**
   * Create a new client with a different Bearer token. The HTTP-mode mcplocal
   * pod has no credentials of its own — each incoming client request carries
   * its McpToken, and this method is how we thread that token through to the
   * McpdUpstream instances created during project discovery.
   */
  withToken(token: string): McpdClient {
    return new McpdClient(this.baseUrl, token, { ...this.extraHeaders }, this.timeoutMs);
  }

  async get<T>(path: string): Promise<T> {
    return this.request<T>('GET', path);
  }

  async post<T>(path: string, body?: unknown): Promise<T> {
    return this.request<T>('POST', path, body);
  }

  async put<T>(path: string, body?: unknown): Promise<T> {
    return this.request<T>('PUT', path, body);
  }

  async delete(path: string): Promise<void> {
    await this.request<unknown>('DELETE', path);
  }

  /**
   * Forward a raw request to mcpd. Returns the status code and body
   * so the proxy route can relay them directly.
   *
   * @param authOverride - If provided, used as the Bearer token instead of the
   *   service token. This allows forwarding end-user tokens for RBAC enforcement.
   */
  async forward(
    method: string,
    path: string,
    query: string,
    body: unknown | undefined,
    authOverride?: string,
  ): Promise<{ status: number; body: unknown }> {
    const url = `${this.baseUrl}${path}${query ? `?${query}` : ''}`;
    const headers: Record<string, string> = {
      ...this.extraHeaders,
      'Authorization': `Bearer ${authOverride ?? this.token}`,
      'Accept': 'application/json',
    };

    const init: RequestInit = {
      method,
      headers,
      signal: AbortSignal.timeout(this.timeoutMs),
    };
    if (body !== undefined && body !== null && method !== 'GET' && method !== 'HEAD') {
      headers['Content-Type'] = 'application/json';
      init.body = JSON.stringify(body);
    }

    let res: Response;
    try {
      res = await fetch(url, init);
    } catch (err: unknown) {
      if (err instanceof DOMException && err.name === 'TimeoutError') {
        throw new ConnectionError(this.baseUrl, new Error(`Request timed out after ${this.timeoutMs}ms`));
      }
      throw new ConnectionError(this.baseUrl, err);
    }

    if (res.status === 401) {
      throw new AuthenticationError();
    }

    const text = await res.text();
    let parsed: unknown;
    try {
      parsed = JSON.parse(text);
    } catch {
      parsed = text;
    }

    return { status: res.status, body: parsed };
  }

  private async request<T>(method: string, path: string, body?: unknown): Promise<T> {
    const result = await this.forward(method, path, '', body);

    if (result.status >= 400) {
      const detail = typeof result.body === 'object' && result.body !== null
        ? JSON.stringify(result.body)
        : String(result.body);
      throw new Error(`mcpd returned ${String(result.status)}: ${detail}`);
    }

    return result.body as T;
  }
}
