<?php

/**
 * MCP REST Controller
 *
 * NLWeb-compatible `/mcp` endpoint. Implements the Model Context Protocol
 * (JSON-RPC 2.0 over HTTP) so MCP-aware agent clients can query the same
 * Schema Aggregation data that /ask serves.
 *
 *   POST /wp-json/prorank-seo/v1/mcp
 *
 * Scope:
 *   - Methods: initialize, notifications/initialized, tools/list, tools/call,
 *     prompts/list, prompts/get, notifications/cancelled, ping.
 *   - Tools: `ask` (delegates to AskQueryService), `list_sites` (single-host
 *     token from home_url()).
 *   - `ask` modes: `list` is public; `summarize` and `generate` are BYOK
 *     and require an authenticated WP user with `edit_posts` capability.
 *     The mode enum is advertised dynamically in `tools/list` — free
 *     installs that don't ship the LLM helpers see list-only. Unknown
 *     or unavailable modes return a JSON-RPC error.
 *   - Auth failures for LLM-backed modes are JSON-RPC transport errors
 *     (-32001), NOT content blocks with isError: true, so compliant MCP
 *     clients see a real auth failure.
 *   - Prompts: none shipped (empty list). prompts/get on any name returns
 *     a JSON-RPC error. The method exists so clients probing for it don't
 *     see "Method not found".
 *   - Shared SchemaMapGate with /schema-map and /ask. Gate closed → the
 *     endpoint hard-closes with HTTP 404 and a raw
 *     `{code: "schema_map_disabled", message: ...}` body — same "not
 *     exposed" shape /ask returns. Introspection methods (initialize,
 *     tools/list, ping) do NOT answer when the gate is closed; the /mcp
 *     surface either fully serves or fully closes along with /ask and
 *     /schema-map.
 *
 * Out of scope: streaming/SSE, server-side conversation state, generate
 * mode, A2A, non-WP-native auth (dedicated API keys, OAuth). Unsupported
 * requests yield a clear JSON-RPC error.
 *
 * Wire format: raw JSON-RPC 2.0 envelopes — no {success, data} wrapper.
 * Errors are JSON-RPC `error` objects with standard codes (-32601 method
 * not found, -32602 invalid params, -32603 internal error).
 *
 * Reference: https://github.com/nlweb-ai/NLWeb/blob/main/docs/nlweb-rest-api.md
 *
 * @package ProRank\SEO\Core\RestApi
 * @since   2.4.0
 */

declare(strict_types=1);

namespace ProRank\SEO\Core\RestApi;

use ProRank\SEO\Core\Schema\AskQueryException;
use ProRank\SEO\Core\Schema\AskQueryService;
use ProRank\SEO\Core\Schema\SchemaMapGate;
use WP_REST_Request;
use WP_REST_Response;

if ( ! defined( 'ABSPATH' ) ) {
    exit;
}


class McpController extends BaseController {

    /**
     * MCP protocol version returned from initialize. Matches the version
     * NLWeb's reference server advertises.
     */
    private const MCP_PROTOCOL_VERSION = '2024-11-05';

    /**
     * JSON-RPC error codes we use. -32700 / -32600 / -32601 / -32602 / -32603
     * are the standard ones from the JSON-RPC 2.0 spec.
     */
    private const RPC_PARSE_ERROR      = -32700;
    private const RPC_INVALID_REQUEST  = -32600;
    private const RPC_METHOD_NOT_FOUND = -32601;
    private const RPC_INVALID_PARAMS   = -32602;
    private const RPC_INTERNAL_ERROR   = -32603;

    /**
     * Server-defined auth-required code. JSON-RPC 2.0 reserves -32000 to
     * -32099 for implementation-defined server errors; we use -32001 for
     * "the method needs auth but the caller didn't provide it".
     */
    private const RPC_AUTH_REQUIRED    = -32001;

    /**
     * Constructor.
     */
    public function __construct() {
        parent::__construct();
        $this->rest_base = 'mcp';
    }

    /**
     * Register routes.
     *
     * @return void
     */
    public function register_routes(): void {
        register_rest_route(
            $this->namespace,
            '/' . $this->rest_base,
            [
                [
                    'methods'             => \WP_REST_Server::CREATABLE,
                    'callback'            => [ $this, 'handle_mcp' ],
                    'permission_callback' => '__return_true',
                ],
            ]
        );
    }

    /**
     * POST /mcp handler.
     *
     * Order of concerns:
     *   1. Shared SchemaMapGate — closed → 404 with a raw NLWeb-style error
     *      so the endpoint is NOT exposed when the module or option is off.
     *      Same semantics as /ask's gate-closed response. No JSON-RPC
     *      envelope here — the REST 404 is the "not exposed" signal.
     *   2. JSON body parse — true parse failures (non-JSON input) emit
     *      -32700. Non-object/non-array JSON values (scalars, null) emit
     *      -32600 Invalid Request, NOT -32700.
     *   3. Dispatch: object → single request; array → batch; empty batch
     *      [] → single -32600 per the JSON-RPC 2.0 spec.
     *
     * @param WP_REST_Request $request Request.
     * @return WP_REST_Response
     */
    public function handle_mcp( WP_REST_Request $request ) {
        // Gate enforcement. If Schema Aggregation is disabled, the /mcp
        // endpoint closes entirely — initialize, tools/list, ping etc. all
        // refuse. This is the shared-gate contract: /schema-map, /ask and
        // /mcp either all serve or all close together.
        if ( ! SchemaMapGate::is_enabled() ) {
            return new WP_REST_Response(
                [
                    'code'    => 'schema_map_disabled',
                    'message' => __( 'Schema aggregation is disabled on this site.', 'prorank-seo' ),
                ],
                404
            );
        }

        $raw = $request->get_body();
        if ( ! is_string( $raw ) || trim( $raw ) === '' ) {
            return $this->rpc_error_response( null, self::RPC_INVALID_REQUEST, __( 'Empty request body.', 'prorank-seo' ) );
        }

        $decoded = json_decode( $raw, true );
        if ( $decoded === null && json_last_error() !== JSON_ERROR_NONE ) {
            return $this->rpc_error_response( null, self::RPC_PARSE_ERROR, __( 'Invalid JSON body.', 'prorank-seo' ) );
        }

        // Valid JSON but not a request object or batch (e.g. `1`, `"x"`,
        // `true`, `null`). JSON-RPC 2.0 says this is Invalid Request, not
        // Parse error — parsing succeeded, the shape is wrong.
        if ( ! is_array( $decoded ) ) {
            return $this->rpc_error_response( null, self::RPC_INVALID_REQUEST, __( 'Request must be a JSON-RPC object or batch.', 'prorank-seo' ) );
        }

        if ( $this->is_batch_request( $decoded ) ) {
            // Empty batch is Invalid Request per spec: "If the batch rpc call
            // itself fails to be recognized as a valid JSON or as an Array
            // with at least one value, the response from the Server MUST be
            // a single Response object."
            if ( $decoded === [] ) {
                return $this->rpc_error_response( null, self::RPC_INVALID_REQUEST, __( 'Batch must contain at least one request.', 'prorank-seo' ) );
            }
            $responses = [];
            foreach ( $decoded as $entry ) {
                if ( ! is_array( $entry ) || $this->is_batch_request( $entry ) ) {
                    // Non-object batch members always get an error response
                    // with id=null — they can't be notifications because
                    // they aren't valid Request objects to begin with.
                    $responses[] = $this->rpc_error_payload( null, self::RPC_INVALID_REQUEST, __( 'Batch entry must be a JSON-RPC request object.', 'prorank-seo' ) );
                    continue;
                }
                $single = $this->dispatch_single( $entry );
                if ( $single !== null ) {
                    $responses[] = $single;
                }
            }
            if ( empty( $responses ) ) {
                // All entries were well-formed notifications — spec says
                // return nothing at all (not an empty array).
                return new WP_REST_Response( null, 204 );
            }
            return new WP_REST_Response( $responses, 200 );
        }

        $response = $this->dispatch_single( $decoded );
        if ( $response === null ) {
            // Notification — no body per JSON-RPC 2.0.
            return new WP_REST_Response( null, 204 );
        }
        return new WP_REST_Response( $response, 200 );
    }

    /**
     * Whether the decoded body is a batch (a list of JSON-RPC requests).
     *
     * A JSON-RPC batch is a JSON array. PHP's json_decode returns both
     * arrays and objects as PHP arrays; distinguish by whether the
     * top-level array has sequential int keys (list) or string keys
     * (object). An empty PHP array `[]` is ambiguous in PHP but
     * unambiguous in JSON — it came from `[]` at the wire, which is a
     * batch. We treat it as a batch so the empty-batch guard can return
     * -32600 per spec rather than misrouting it to single-request logic.
     *
     * @param array<mixed, mixed> $body Decoded body.
     * @return bool
     */
    private function is_batch_request( array $body ): bool {
        if ( $body === [] ) {
            return true;
        }
        foreach ( array_keys( $body ) as $key ) {
            if ( ! is_int( $key ) ) {
                return false;
            }
        }
        return true;
    }

    /**
     * Dispatch a single JSON-RPC request.
     *
     * Validation happens in two stages:
     *   1. Structural validity — jsonrpc === "2.0", method is a non-empty
     *      string, params (if present) is an object. Failing any of these
     *      means the request is Invalid, NOT a notification — so we emit
     *      -32600 with id=null even if the caller omitted id. A genuine
     *      notification has no id AND is otherwise well-formed.
     *   2. Method dispatch — delegated to the per-method handlers.
     *
     * Returns the JSON-RPC response payload (array) or null if and only if
     * the request was a well-formed notification.
     *
     * @param array<string, mixed> $request Parsed request.
     * @return array<string, mixed>|null
     */
    private function dispatch_single( array $request ): ?array {
        $has_id = array_key_exists( 'id', $request );
        $raw_id = $has_id ? $request['id'] : null;

        // Validate id type BEFORE any other structural check. JSON-RPC 2.0
        // requires that when the server cannot determine a valid id, the
        // response id MUST be null. Subsequent structural error paths
        // (jsonrpc/version, method presence) need a sanitised $id so they
        // never echo back a non-conformant value like an array or object.
        //
        // Rule: id is allowed to be string, integer, float, or null.
        // Anything else → id is "undeterminable", so from this point on
        // the response id is null even if the caller supplied something.
        if ( $has_id && $raw_id !== null && ! is_string( $raw_id ) && ! is_int( $raw_id ) && ! is_float( $raw_id ) ) {
            return $this->rpc_error_payload(
                null,
                self::RPC_INVALID_REQUEST,
                __( 'Request id must be a string, number, or null.', 'prorank-seo' )
            );
        }

        // Sanitised id: present-and-valid or null. Every error path below
        // must echo *this* value, never the raw id, so id validation
        // failure can't sneak past the type check above.
        $id              = $has_id ? $raw_id : null;
        $is_notification = ! $has_id;
        $method          = isset( $request['method'] ) && is_string( $request['method'] ) ? $request['method'] : '';

        // JSON-RPC 2.0 §4: every Request MUST have a jsonrpc member exactly
        // equal to "2.0". Missing or wrong → Invalid Request. This is a
        // structural failure, not a notification — emit an error even if
        // the caller omitted id.
        $version = $request['jsonrpc'] ?? null;
        if ( $version !== '2.0' ) {
            return $this->rpc_error_payload(
                $id,
                self::RPC_INVALID_REQUEST,
                __( 'Request must include "jsonrpc": "2.0".', 'prorank-seo' )
            );
        }

        // Method must be a non-empty string. If it isn't, the request is
        // structurally invalid — return -32600 with the sanitised id so
        // correlated clients can still match when id was supplied cleanly.
        if ( $method === '' ) {
            return $this->rpc_error_payload(
                $id,
                self::RPC_INVALID_REQUEST,
                __( 'Request must include a non-empty method.', 'prorank-seo' )
            );
        }

        // JSON-RPC 2.0 §4.2: params, if present, MUST be a Structured value
        // — an Array (positional) or Object (named). Scalars and null are
        // not valid. PHP's json_decode maps both JSON arrays and objects to
        // PHP arrays, so is_array() covers both structured forms. Silently
        // coercing a scalar params to [] would let bogus requests execute
        // against the dispatch switch (e.g. tools/call with params=null
        // would degrade to "missing tool name" instead of failing cleanly).
        if ( array_key_exists( 'params', $request ) && ! is_array( $request['params'] ) ) {
            return $this->rpc_error_payload(
                $id,
                self::RPC_INVALID_REQUEST,
                __( 'Request params must be a structured value (object or array).', 'prorank-seo' )
            );
        }
        $params = $request['params'] ?? [];

        try {
            switch ( $method ) {
                case 'initialize':
                    $result = $this->handle_initialize( $params );
                    break;

                case 'initialized':
                case 'notifications/initialized':
                case 'notifications/cancelled':
                    // Pure notifications — no response, no state we need
                    // to track (we're stateless per request).
                    return $is_notification ? null : [
                        'jsonrpc' => '2.0',
                        'id'      => $id,
                        'result'  => new \stdClass(),
                    ];

                case 'tools/list':
                case 'list_tools':
                    $result = $this->handle_tools_list();
                    break;

                case 'tools/call':
                case 'call_tool':
                    $result = $this->handle_tools_call( $params );
                    break;

                case 'prompts/list':
                case 'list_prompts':
                    $result = $this->handle_prompts_list();
                    break;

                case 'prompts/get':
                case 'get_prompt':
                    $result = $this->handle_prompts_get( $params );
                    break;

                case 'ping':
                    // MCP ping — some clients use it for keepalives. Empty
                    // result object is the standard response.
                    $result = new \stdClass();
                    break;

                default:
                    if ( $is_notification ) {
                        return null;
                    }
                    return $this->rpc_error_payload(
                        $id,
                        self::RPC_METHOD_NOT_FOUND,
                        sprintf(
                            /* translators: %s is the JSON-RPC method name. */
                            __( 'Method not found: %s', 'prorank-seo' ),
                            $method
                        )
                    );
            }
        } catch ( McpRpcError $e ) {
            if ( $is_notification ) {
                return null;
            }
            return $this->rpc_error_payload( $id, $e->getRpcCode(), $e->getMessage(), $e->getRpcData() );
        } catch ( \Throwable $e ) {
            prorank_log( '[ProRank SEO] /mcp error (' . $method . '): ' . $e->getMessage() );
            if ( $is_notification ) {
                return null;
            }
            return $this->rpc_error_payload( $id, self::RPC_INTERNAL_ERROR, __( 'Internal server error.', 'prorank-seo' ) );
        }

        if ( $is_notification ) {
            return null;
        }

        return [
            'jsonrpc' => '2.0',
            'id'      => $id,
            'result'  => $result,
        ];
    }

    /**
     * initialize — return server info + advertised capabilities.
     *
     * @param array<string, mixed> $params Client-supplied params.
     * @return array<string, mixed>
     */
    private function handle_initialize( array $params ): array {
        // Client info is informational; we don't track it (stateless server).
        // `instructions` is computed dynamically from the same
        // supported_modes() helper tools/list uses so the two surfaces
        // can't drift — free installs advertise list only, Pro+ adds
        // summarize/generate.
        return [
            'protocolVersion' => self::MCP_PROTOCOL_VERSION,
            'capabilities'    => [
                // We advertise only what we actually implement. Empty
                // objects (not arrays) are intentional — MCP capabilities
                // use object-shaped feature flags.
                'tools'   => new \stdClass(),
                'prompts' => new \stdClass(),
            ],
            'serverInfo'      => [
                'name'    => 'prorank-seo-mcp',
                'version' => '1.0.0',
            ],
            'instructions'    => $this->initialize_instructions(),
        ];
    }

    /**
     * Human-readable server instructions returned from `initialize`.
     *
     * Exactly enumerates the modes the server actually advertises in
     * tools/list, covering partial-helper states where only one of
     * summarize/generate is present (e.g. during a phased upgrade).
     * Each branch is its own translation string so grammar, singular /
     * plural, and the "is/are" agreement are baked in per locale.
     *
     * @return string
     */
    private function initialize_instructions(): string {
        $modes         = $this->supported_modes();
        $has_summarize = in_array( 'summarize', $modes, true );
        $has_generate  = in_array( 'generate', $modes, true );

        if ( $has_summarize && $has_generate ) {
            return __(
                'Query this WordPress site\'s aggregated Schema.org data (posts, pages, products, LocalBusiness, FAQPage, HowTo) via the ask tool. mode=list is public; mode=summarize and mode=generate are BYOK and require a WordPress application password for a user with edit_posts capability.',
                'prorank-seo'
            );
        }

        if ( $has_summarize ) {
            return __(
                'Query this WordPress site\'s aggregated Schema.org data (posts, pages, products, LocalBusiness, FAQPage, HowTo) via the ask tool. mode=list is public; mode=summarize is BYOK and requires a WordPress application password for a user with edit_posts capability.',
                'prorank-seo'
            );
        }

        if ( $has_generate ) {
            return __(
                'Query this WordPress site\'s aggregated Schema.org data (posts, pages, products, LocalBusiness, FAQPage, HowTo) via the ask tool. mode=list is public; mode=generate is BYOK and requires a WordPress application password for a user with edit_posts capability.',
                'prorank-seo'
            );
        }

        return __(
            'Query this WordPress site\'s aggregated Schema.org data (posts, pages, products, LocalBusiness, FAQPage, HowTo) via the ask tool. Only mode=list is available on this install.',
            'prorank-seo'
        );
    }

    /**
     * tools/list — return the available MCP tools.
     *
     * @return array<string, mixed>
     */
    private function handle_tools_list(): array {
        $supported_modes = $this->supported_modes();
        $has_llm_modes   = count( $supported_modes ) > 1;
        return [
            'tools' => [
                [
                    'name'        => 'ask',
                    'description' => $has_llm_modes
                        ? 'Query the site\'s aggregated Schema.org data. mode=list returns ranked Schema.org items (public). mode=summarize adds a BYOK LLM summary. mode=generate returns a grounded RAG-style answer with citations. BYOK modes require an authenticated caller (WordPress application password with edit_posts capability).'
                        : 'Query the site\'s aggregated Schema.org data in list mode. Only mode=list is available on this install.',
                    'inputSchema' => [
                        'type'       => 'object',
                        'properties' => [
                            'query'    => [
                                'type'        => 'string',
                                'description' => 'Natural-language query.',
                            ],
                            'mode'     => [
                                'type'        => 'string',
                                'enum'        => $supported_modes,
                                'description' => $has_llm_modes
                                    ? 'Query mode. list is public; summarize and generate require an authenticated caller.'
                                    : 'Query mode. Only list is available.',
                                'default'     => 'list',
                            ],
                            'limit'    => [
                                'type'        => 'integer',
                                'minimum'     => 1,
                                'maximum'     => 50,
                                'description' => 'Maximum results.',
                                'default'     => AskQueryService::DEFAULT_LIMIT,
                            ],
                            'query_id' => [
                                'type'        => 'string',
                                'description' => 'Optional caller-supplied query ID.',
                            ],
                            'site'     => [
                                'type'        => 'string',
                                'description' => 'Optional backend subset token. Ignored on single-site WordPress installs.',
                            ],
                        ],
                        'required'   => [ 'query' ],
                    ],
                ],
                [
                    'name'        => 'list_sites',
                    'description' => 'List the site tokens this server can answer queries for. For single-site WordPress installs this is always one entry.',
                    'inputSchema' => [
                        'type'       => 'object',
                        'properties' => new \stdClass(),
                    ],
                ],
            ],
        ];
    }

    /**
     * tools/call — dispatch by tool name. Returns the MCP content-block
     * envelope: `{content: [...], isError?: bool}`.
     *
     * @param array<string, mixed> $params JSON-RPC params.
     * @return array<string, mixed>
     * @throws McpRpcError On invalid params / unknown tool.
     */
    private function handle_tools_call( array $params ): array {
        $tool_name = isset( $params['name'] ) && is_string( $params['name'] ) ? $params['name'] : '';
        if ( $tool_name === '' ) {
            throw new McpRpcError( self::RPC_INVALID_PARAMS, __( 'Missing tool name.', 'prorank-seo' ) );
        }
        $arguments = isset( $params['arguments'] ) && is_array( $params['arguments'] ) ? $params['arguments'] : [];

        switch ( $tool_name ) {
            case 'ask':
                return $this->call_ask_tool( $arguments );

            case 'list_sites':
                return $this->call_list_sites_tool();

            default:
                throw new McpRpcError(
                    self::RPC_INVALID_PARAMS,
                    sprintf(
                        /* translators: %s is the tool name. */
                        __( 'Unknown tool: %s', 'prorank-seo' ),
                        $tool_name
                    )
                );
        }
    }

    /**
     * Invoke the `ask` tool — delegates to AskQueryService.
     *
     * Auth handling for richer modes:
     *   - When the caller requests an auth-required mode (summarize, and
     *     later generate) without being authenticated, this method escalates
     *     to a JSON-RPC auth error via McpRpcError instead of returning a
     *     content-block "success envelope". That's deliberate — auth
     *     failures belong in the JSON-RPC transport layer so compliant
     *     clients handle them like any other -32xxx error.
     *   - All other AskQueryExceptions (gate, mode, empty query, provider
     *     failure) stay as content-block tool errors with `isError: true`,
     *     matching MCP's tool-error convention.
     *
     * The auth decision itself lives in AskQueryService (shared policy with
     * /ask). This method only projects the result onto the right MCP shape.
     *
     * @param array<string, mixed> $arguments Tool arguments.
     * @return array<string, mixed>
     * @throws McpRpcError On auth-required failures.
     */
    private function call_ask_tool( array $arguments ): array {
        $raw_query = isset( $arguments['query'] ) && is_string( $arguments['query'] ) ? $arguments['query'] : '';
        $mode      = isset( $arguments['mode'] ) && is_string( $arguments['mode'] ) ? $arguments['mode'] : 'list';
        $limit     = isset( $arguments['limit'] ) ? (int) $arguments['limit'] : AskQueryService::DEFAULT_LIMIT;
        $query_id  = isset( $arguments['query_id'] ) && is_string( $arguments['query_id'] ) && $arguments['query_id'] !== ''
            ? $arguments['query_id']
            : null;

        // Reject modes we don't advertise in tools/list on this install.
        // Clients that read the tool schema will see the actual enum and
        // never send unsupported modes; this is a defence-in-depth guard
        // against clients that skip introspection.
        if ( ! in_array( $mode, $this->supported_modes(), true ) ) {
            throw new McpRpcError(
                self::RPC_INVALID_PARAMS,
                sprintf(
                    /* translators: %s is the requested mode. */
                    __( 'Mode "%s" is not available on this install.', 'prorank-seo' ),
                    $mode
                ),
                [ 'requested_mode' => $mode, 'available_modes' => $this->supported_modes() ]
            );
        }

        // Logged-in AND holds a content-edit capability. Bare subscribers
        // should not be able to trigger BYOK LLM calls (see AskController
        // for the mirror check).
        $is_authenticated = (
            function_exists( 'is_user_logged_in' ) && is_user_logged_in()
            && function_exists( 'current_user_can' ) && current_user_can( 'edit_posts' )
        );

        $service = new AskQueryService();
        try {
            $result = $service->execute( $raw_query, $mode, $limit, $query_id, $is_authenticated );
        } catch ( AskQueryException $e ) {
            if ( $e->get_error_code() === 'auth_required' ) {
                // Escalate to JSON-RPC-layer error so MCP clients see a
                // transport-level auth failure rather than a "tool errored"
                // success envelope they might treat as a normal result.
                throw new McpRpcError(
                    self::RPC_AUTH_REQUIRED,
                    $e->getMessage(),
                    array_merge(
                        [ 'auth_methods' => [ 'wordpress_application_password' ] ],
                        $e->get_extra()
                    )
                );
            }
            $payload = array_merge(
                [
                    'code'    => $e->get_error_code(),
                    'message' => $e->getMessage(),
                    'status'  => $e->get_http_status(),
                ],
                $e->get_extra()
            );
            return [
                'content' => [
                    [
                        'type' => 'text',
                        'text' => $this->json_encode( $payload ),
                    ],
                ],
                'isError' => true,
            ];
        }

        // MCP content blocks: the canonical "machine-readable result" shape
        // is a text block holding the JSON envelope. Clients that render
        // these for humans typically pretty-print; agentic clients parse
        // the JSON back out. This mirrors NLWeb's reference server.
        $envelope = array_merge( $result, [ 'total' => count( $result['results'] ) ] );
        return [
            'content' => [
                [
                    'type' => 'text',
                    'text' => $this->json_encode( $envelope ),
                ],
            ],
        ];
    }

    /**
     * Invoke the `list_sites` tool — single-site WP returns one entry.
     *
     * @return array<string, mixed>
     */
    private function call_list_sites_tool(): array {
        $home = home_url( '/' );
        $host = wp_parse_url( $home, PHP_URL_HOST );
        $site = is_string( $host ) && $host !== '' ? strtolower( $host ) : $home;
        $payload = [ 'sites' => [ $site ] ];
        return [
            'content' => [
                [
                    'type' => 'text',
                    'text' => $this->json_encode( $payload ),
                ],
            ],
        ];
    }

    /**
     * prompts/list — we ship no prompts yet. The method exists so clients
     * probing for it don't see "Method not found"; they get an empty list.
     *
     * @return array<string, mixed>
     */
    private function handle_prompts_list(): array {
        return [ 'prompts' => [] ];
    }

    /**
     * prompts/get — always fails for 5A since we ship no prompts. Returns
     * an invalid-params RPC error so clients can distinguish "no such
     * prompt" from a transport-level problem.
     *
     * @param array<string, mixed> $params JSON-RPC params.
     * @return array<string, mixed>
     * @throws McpRpcError
     */
    private function handle_prompts_get( array $params ): array {
        $name = isset( $params['name'] ) && is_string( $params['name'] ) ? $params['name'] : '';
        throw new McpRpcError(
            self::RPC_INVALID_PARAMS,
            $name === ''
                ? __( 'Missing prompt name.', 'prorank-seo' )
                : sprintf(
                    /* translators: %s is the prompt name. */
                    __( 'Unknown prompt: %s', 'prorank-seo' ),
                    $name
                )
        );
    }

    /**
     * Modes advertised by the ask tool. `summarize` is only listed when
     * the premium LLM helper is actually available. Mirrors
     * AskController::supported_modes — keep them in sync. A shared helper
     * on AskQueryService would be cleaner; this local copy is deliberate
     * for 5B.1 so the two controllers can diverge if future modes land on
     * one surface before the other.
     *
     * @return string[]
     */
    private function supported_modes(): array {
        $modes = [ 'list' ];
        if ( class_exists( '\\ProRank\\SEO\\AI\\AskSummarizer' ) ) {
            $modes[] = 'summarize';
        }
        if ( class_exists( '\\ProRank\\SEO\\AI\\AskGenerator' ) ) {
            $modes[] = 'generate';
        }
        return $modes;
    }

    /**
     * JSON-encode a payload with options that preserve Unicode characters
     * and leave slashes unescaped so the output is human-readable and
     * stable across environments.
     *
     * @param mixed $value Value to encode.
     * @return string
     */
    private function json_encode( $value ): string {
        $encoded = wp_json_encode( $value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE );
        return is_string( $encoded ) ? $encoded : '';
    }

    /**
     * Build a JSON-RPC error response payload.
     *
     * @param mixed  $id      Request id (null for parse errors).
     * @param int    $code    JSON-RPC error code.
     * @param string $message Human-readable message.
     * @param mixed  $data    Optional extra data.
     * @return array<string, mixed>
     */
    private function rpc_error_payload( $id, int $code, string $message, $data = null ): array {
        $error = [
            'code'    => $code,
            'message' => $message,
        ];
        if ( $data !== null ) {
            $error['data'] = $data;
        }
        return [
            'jsonrpc' => '2.0',
            'id'      => $id,
            'error'   => $error,
        ];
    }

    /**
     * Build a JSON-RPC error response wrapped as a WP_REST_Response.
     *
     * @param mixed  $id      Request id (null for parse errors).
     * @param int    $code    JSON-RPC error code.
     * @param string $message Human-readable message.
     * @param mixed  $data    Optional extra data.
     * @return WP_REST_Response
     */
    private function rpc_error_response( $id, int $code, string $message, $data = null ): WP_REST_Response {
        return new WP_REST_Response( $this->rpc_error_payload( $id, $code, $message, $data ), 200 );
    }
}


/**
 * Internal RPC error carrier — caught by dispatch_single() and projected
 * onto a JSON-RPC error envelope. Keeps dispatcher logic linear instead of
 * threading (code, message) return tuples through every handler.
 */
final class McpRpcError extends \RuntimeException {

    private int $rpc_code;

    /** @var mixed */
    private $rpc_data;

    /**
     * @param int    $rpc_code JSON-RPC error code.
     * @param string $message  Human-readable message.
     * @param mixed  $data     Optional extra data.
     */
    public function __construct( int $rpc_code, string $message, $data = null ) {
        parent::__construct( $message );
        $this->rpc_code = $rpc_code;
        $this->rpc_data = $data;
    }

    public function getRpcCode(): int {
        return $this->rpc_code;
    }

    /**
     * @return mixed
     */
    public function getRpcData() {
        return $this->rpc_data;
    }
}
