<?php

/*
 * This file is part of the official PHP MCP SDK.
 *
 * A collaboration between Symfony and the PHP Foundation.
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

namespace Mcp;

use Mcp\Client\Builder;
use Mcp\Client\Configuration;
use Mcp\Client\Protocol;
use Mcp\Client\Transport\TransportInterface;
use Mcp\Exception\ConnectionException;
use Mcp\Exception\RequestException;
use Mcp\Exception\RuntimeException;
use Mcp\Schema\Enum\LoggingLevel;
use Mcp\Schema\Enum\ProtocolVersion;
use Mcp\Schema\Implementation;
use Mcp\Schema\JsonRpc\Error;
use Mcp\Schema\JsonRpc\Request;
use Mcp\Schema\JsonRpc\Response;
use Mcp\Schema\Notification\RootsListChangedNotification;
use Mcp\Schema\PromptReference;
use Mcp\Schema\Request\CallToolRequest;
use Mcp\Schema\Request\CompletionCompleteRequest;
use Mcp\Schema\Request\GetPromptRequest;
use Mcp\Schema\Request\ListPromptsRequest;
use Mcp\Schema\Request\ListResourcesRequest;
use Mcp\Schema\Request\ListResourceTemplatesRequest;
use Mcp\Schema\Request\ListToolsRequest;
use Mcp\Schema\Request\PingRequest;
use Mcp\Schema\Request\ReadResourceRequest;
use Mcp\Schema\Request\SetLogLevelRequest;
use Mcp\Schema\ResourceReference;
use Mcp\Schema\Result\CallToolResult;
use Mcp\Schema\Result\CompletionCompleteResult;
use Mcp\Schema\Result\GetPromptResult;
use Mcp\Schema\Result\ListPromptsResult;
use Mcp\Schema\Result\ListResourcesResult;
use Mcp\Schema\Result\ListResourceTemplatesResult;
use Mcp\Schema\Result\ListToolsResult;
use Mcp\Schema\Result\ReadResourceResult;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;

/**
 * Main MCP Client facade.
 *
 * Provides a synchronous API for communicating with MCP servers.
 * All blocking operations are delegated to the transport.
 *
 * @author Kyrian Obikwelu <koshnawaza@gmail.com>
 */
class Client
{
    private const RETRY_BASE_DELAY_MS = 100;

    private ?TransportInterface $transport = null;

    public function __construct(
        private readonly Protocol $protocol,
        private readonly Configuration $config,
        private readonly LoggerInterface $logger = new NullLogger(),
    ) {
    }

    /**
     * Create a new client builder for fluent configuration.
     */
    public static function builder(): Builder
    {
        return new Builder();
    }

    /**
     * Connect to an MCP server using the provided transport.
     *
     * A failed attempt is closed and retried, see {@see Builder::setMaxRetries()}.
     *
     * @throws ConnectionException If connection or initialization fails on every attempt
     */
    public function connect(TransportInterface $transport): void
    {
        $this->transport = $transport;
        $this->protocol->connect($transport, $this->config);

        $maxAttempts = $this->config->maxRetries + 1;

        for ($attempt = 1; $attempt <= $maxAttempts; ++$attempt) {
            try {
                $transport->connect();

                $this->logger->info('Client connected and initialized', ['attempt' => $attempt]);

                return;
            } catch (ConnectionException $e) {
                // initialize() flags the session before sending the initialized
                // notification, so a failure in between leaves the flag set.
                $this->protocol->getState()->setInitialized(false);

                $transport->close();

                if ($attempt === $maxAttempts) {
                    throw $e;
                }

                $this->logger->warning('Connection attempt failed, retrying', [
                    'attempt' => $attempt,
                    'max_attempts' => $maxAttempts,
                    'exception' => $e,
                ]);

                usleep($attempt * self::RETRY_BASE_DELAY_MS * 1000);
            }
        }
    }

    /**
     * Check if connected and initialized.
     */
    public function isConnected(): bool
    {
        return null !== $this->transport && $this->protocol->getState()->isInitialized();
    }

    /**
     * Get server information from initialization.
     */
    public function getServerInfo(): ?Implementation
    {
        return $this->protocol->getState()->getServerInfo();
    }

    /**
     * Get server instructions.
     */
    public function getInstructions(): ?string
    {
        return $this->protocol->getState()->getInstructions();
    }

    /**
     * Protocol revision negotiated during the handshake.
     *
     * This is the version the server answered with, which is not necessarily the
     * one configured on the builder: a server that cannot speak the requested
     * revision counter-offers one it supports. Null until the handshake completed.
     */
    public function getProtocolVersion(): ?ProtocolVersion
    {
        return $this->protocol->getState()->getProtocolVersion();
    }

    /**
     * Send a ping request to the server.
     */
    public function ping(): void
    {
        $request = new PingRequest();

        $this->sendRequest($request);
    }

    /**
     * List available tools from the server.
     */
    public function listTools(?string $cursor = null): ListToolsResult
    {
        $request = new ListToolsRequest($cursor);

        $response = $this->sendRequest($request);

        $result = $response->result;

        // Filtered before parsing, because parsing is where a malformed
        // `x-mcp-header` annotation throws. One broken definition must cost the
        // caller that tool, not the whole listing (SEP-2243).
        if (\is_array($result['tools'] ?? null)) {
            $result['tools'] = $this->protocol->getToolCatalog()->record($result['tools']);
        }

        return ListToolsResult::fromArray($result);
    }

    /**
     * Call a tool on the server.
     *
     * @param string                                                                  $name       Tool name
     * @param array<string, mixed>                                                    $arguments  Tool arguments
     * @param (callable(float $progress, ?float $total, ?string $message): void)|null $onProgress
     *                                                                                            Optional callback for progress updates
     */
    public function callTool(string $name, array $arguments = [], ?callable $onProgress = null): CallToolResult
    {
        $catalog = $this->protocol->getToolCatalog();

        // A tool the listing showed to be malformed is refused here rather than
        // sent: the client cannot produce the headers its annotations demand,
        // so the call could only go out misdescribed (SEP-2243).
        if ($catalog->isRejected($name)) {
            throw new RuntimeException(\sprintf('Refusing to call tool "%s": its "x-mcp-header" annotations are invalid (%s).', $name, $catalog->reasonFor($name)));
        }

        $request = new CallToolRequest($name, $arguments);

        $response = $this->sendRequest($request, $onProgress);

        return CallToolResult::fromArray($response->result);
    }

    /**
     * List available resources from the server.
     */
    public function listResources(?string $cursor = null): ListResourcesResult
    {
        $request = new ListResourcesRequest($cursor);

        $response = $this->sendRequest($request);

        return ListResourcesResult::fromArray($response->result);
    }

    /**
     * List available resource templates from the server.
     */
    public function listResourceTemplates(?string $cursor = null): ListResourceTemplatesResult
    {
        $request = new ListResourceTemplatesRequest($cursor);

        $response = $this->sendRequest($request);

        return ListResourceTemplatesResult::fromArray($response->result);
    }

    /**
     * Read a resource by URI.
     *
     * @param string                                                                  $uri        The resource URI
     * @param (callable(float $progress, ?float $total, ?string $message): void)|null $onProgress
     *                                                                                            Optional callback for progress updates
     */
    public function readResource(string $uri, ?callable $onProgress = null): ReadResourceResult
    {
        $request = new ReadResourceRequest($uri);

        $response = $this->sendRequest($request, $onProgress);

        return ReadResourceResult::fromArray($response->result);
    }

    /**
     * List available prompts from the server.
     */
    public function listPrompts(?string $cursor = null): ListPromptsResult
    {
        $request = new ListPromptsRequest($cursor);

        $response = $this->sendRequest($request);

        return ListPromptsResult::fromArray($response->result);
    }

    /**
     * Get a prompt from the server.
     *
     * @param string                                                                  $name       Prompt name
     * @param array<string, string>                                                   $arguments  Prompt arguments
     * @param (callable(float $progress, ?float $total, ?string $message): void)|null $onProgress
     *                                                                                            Optional callback for progress updates
     */
    public function getPrompt(string $name, array $arguments = [], ?callable $onProgress = null): GetPromptResult
    {
        $request = new GetPromptRequest($name, $arguments);

        $response = $this->sendRequest($request, $onProgress);

        return GetPromptResult::fromArray($response->result);
    }

    /**
     * Request completion suggestions for a prompt or resource argument.
     *
     * @param PromptReference|ResourceReference  $ref      The prompt or resource reference
     * @param array{name: string, value: string} $argument The argument to complete
     */
    public function complete(PromptReference|ResourceReference $ref, array $argument): CompletionCompleteResult
    {
        $request = new CompletionCompleteRequest($ref, $argument);

        $response = $this->sendRequest($request);

        return CompletionCompleteResult::fromArray($response->result);
    }

    /**
     * Set the minimum logging level for server log messages.
     */
    public function setLoggingLevel(LoggingLevel $level): void
    {
        $request = new SetLogLevelRequest($level);

        $this->sendRequest($request);
    }

    /**
     * Notify the server that the client's list of roots has changed.
     *
     * The server should react by requesting an updated list via roots/list.
     *
     * @throws RuntimeException    if the client did not advertise the `roots.listChanged` capability
     * @throws ConnectionException if the client is not connected
     */
    public function sendRootsListChanged(): void
    {
        if (true !== $this->config->capabilities->rootsListChanged) {
            throw new RuntimeException('Cannot send a "roots/list_changed" notification without advertising the "roots.listChanged" capability. Build the client with new ClientCapabilities(roots: true, rootsListChanged: true).');
        }

        if (!$this->isConnected()) {
            throw new ConnectionException('Client is not connected. Call connect() first.');
        }

        $this->protocol->sendNotification(new RootsListChangedNotification());
    }

    /**
     * Send a request to the server and wait for response.
     *
     * @param (callable(float $progress, ?float $total, ?string $message): void)|null $onProgress
     *
     * @return Response<mixed>
     *
     * @throws RequestException|ConnectionException
     */
    private function sendRequest(Request $request, ?callable $onProgress = null): Response
    {
        if (!$this->isConnected()) {
            throw new ConnectionException('Client is not connected. Call connect() first.');
        }

        $withProgress = null !== $onProgress;
        $fiber = new \Fiber(fn () => $this->protocol->request($request, $this->config->requestTimeout, $withProgress));
        $response = $this->transport->runRequest($fiber, $onProgress);

        if ($response instanceof Error) {
            throw RequestException::fromError($response);
        }

        return $response;
    }

    /**
     * Disconnect from the server.
     */
    public function disconnect(): void
    {
        if (null !== $this->transport) {
            $this->transport->close();
            $this->transport = null;
            $this->logger->info('Client disconnected');
        }
    }
}