# frozen_string_literal: true

require "test_helper"

describe JsonRpcHandler do
  before do
    @registry = {}
    @response = nil
    @response_json = nil
  end

  describe "#handle" do
    # Comments verbatim from https://www.jsonrpc.org/specification
    #
    # JSON-RPC 2.0 Specification
    #
    # 1 Overview
    # ...
    # 2 Conventions
    # ...
    # 3 Compatibility
    # ...
    # 4 Request object
    #
    # A rpc call is represented by sending a Request object to a Server. The Request object has the following members:
    #
    # jsonrpc
    #   A String specifying the version of the JSON-RPC protocol. MUST be exactly "2.0".

    it "returns a result when jsonrpc is 2.0" do
      register("add") { |params| params[:a] + params[:b] }

      handle jsonrpc: "2.0", id: 1, method: "add", params: { a: 1, b: 2 }

      assert_rpc_success expected_result: 3
    end

    it "returns an error when jsonrpc is not 2.0" do
      handle jsonrpc: "3.0", id: 1, method: "add", params: { a: 1, b: 2 }

      assert_rpc_error expected_error: {
        code: -32600,
        message: "Invalid Request",
        data: "JSON-RPC version must be 2.0",
      }
      assert_equal 1, @response[:id]
    end

    it "returns an error preserving the request id when jsonrpc is missing" do
      handle id: 4, method: "add", params: { a: 1, b: 2 }

      assert_rpc_error expected_error: {
        code: -32600,
        message: "Invalid Request",
        data: "JSON-RPC version must be 2.0",
      }
      assert_equal 4, @response[:id]
    end

    # method
    #   A String containing the name of the method to be invoked. Method names that begin with the word rpc followed by
    #   a period character (U+002E or ASCII 46) are reserved for rpc-internal methods and extensions and MUST NOT be
    #   used for anything else.

    it "returns an error when method is not a string" do
      handle jsonrpc: "2.0", id: 1, method: 42, params: { a: 1, b: 2 }

      assert_rpc_error expected_error: {
        code: -32600,
        message: "Invalid Request",
        data: 'Method name must be a string and not start with "rpc."',
      }
      assert_equal 1, @response[:id]
    end

    it "returns an error when method begins with 'rpc.'" do
      handle jsonrpc: "2.0", id: 1, method: "rpc.add", params: { a: 1, b: 2 }

      assert_rpc_error expected_error: {
        code: -32600,
        message: "Invalid Request",
        data: 'Method name must be a string and not start with "rpc."',
      }
      assert_equal 1, @response[:id]
    end

    # params
    #   A Structured value that holds the parameter values to be used during the invocation of the method. This member
    #   MAY be omitted.

    it "returns a result when parameters are omitted" do
      register("greet") { "Hello, world!" }

      handle jsonrpc: "2.0", id: 1, method: "greet"

      assert_rpc_success expected_result: "Hello, world!"
    end

    # id
    #   An identifier established by the Client that MUST contain a String, Number, or NULL value if included. If it is
    #   not included it is assumed to be a notification. The value SHOULD normally not be Null and Numbers SHOULD NOT
    #   contain fractional parts.
    #
    #   The Server MUST reply with the same value in the Response object if included. This member is used to correlate the
    #   context between the two objects.

    it "returns a response with the same request id when the id is a valid string" do
      register("add") { |params| params[:a] + params[:b] }
      id = "request-123_abc"

      handle jsonrpc: "2.0", id: id, method: "add", params: { a: 1, b: 2 }

      assert_rpc_success expected_result: 3
      assert_equal id, @response[:id]
    end

    it "returns a response with the same request id when the id is an integer" do
      register("add") { |params| params[:a] + params[:b] }
      id = 42

      handle jsonrpc: "2.0", id: id, method: "add", params: { a: 1, b: 2 }

      assert_rpc_success expected_result: 3
      assert_equal id, @response[:id]
    end

    it "returns an error when request id is not of a valid type" do
      handle jsonrpc: "2.0", id: true, method: "add", params: { a: 1, b: 2 }

      assert_rpc_error expected_error: {
        code: -32600,
        message: "Invalid Request",
        data: "Request ID must match validation pattern, or be an integer or null",
      }
    end

    it "accepts string id with alphanumerics, dashes, and underscores" do
      register("add") { |params| params[:a] + params[:b] }
      id = "request-123_ABC"

      handle jsonrpc: "2.0", id: id, method: "add", params: { a: 1, b: 2 }

      assert_rpc_success expected_result: 3
      assert_equal id, @response[:id]
    end

    it "accepts UUID format strings" do
      register("add") { |params| params[:a] + params[:b] }
      id = "550e8400-e29b-41d4-a716-446655440000"

      handle jsonrpc: "2.0", id: id, method: "add", params: { a: 1, b: 2 }

      assert_rpc_success expected_result: 3
      assert_equal id, @response[:id]
    end

    it "returns an error when request id contains HTML content (XSS prevention)" do
      handle jsonrpc: "2.0", id: "<script>alert('xss')</script>", method: "add", params: { a: 1, b: 2 }

      assert_rpc_error expected_error: {
        code: -32600,
        message: "Invalid Request",
        data: "Request ID must match validation pattern, or be an integer or null",
      }
    end

    it "returns an error when request id contains spaces" do
      handle jsonrpc: "2.0", id: "request 123", method: "add", params: { a: 1, b: 2 }

      assert_rpc_error expected_error: {
        code: -32600,
        message: "Invalid Request",
        data: "Request ID must match validation pattern, or be an integer or null",
      }
    end

    it "returns an error when request id contains special characters" do
      handle jsonrpc: "2.0", id: "request@123", method: "add", params: { a: 1, b: 2 }

      assert_rpc_error expected_error: {
        code: -32600,
        message: "Invalid Request",
        data: "Request ID must match validation pattern, or be an integer or null",
      }
    end

    it "returns an error when request id is an empty string" do
      handle jsonrpc: "2.0", id: "", method: "add", params: { a: 1, b: 2 }

      assert_rpc_error expected_error: {
        code: -32600,
        message: "Invalid Request",
        data: "Request ID must match validation pattern, or be an integer or null",
      }
    end

    it "returns an error when id is a number with a fractional part" do
      handle jsonrpc: "2.0", id: 3.14, method: "add", params: { a: 1, b: 2 }

      assert_rpc_error expected_error: {
        code: -32600,
        message: "Invalid Request",
        data: "Request ID must match validation pattern, or be an integer or null",
      }
    end

    # 4.1 Notification
    #
    # A Notification is a Request object without an "id" member. A Request object that is a Notification signifies the
    # Client's lack of interest in the corresponding Response object, and as such no Response object needs to be
    # returned to the client. The Server MUST NOT reply to a Notification, including those that are within a batch
    # request.
    #
    # Notifications are not confirmable by definition, since they do not have a Response object to be returned. As such,
    # the Client would not be aware of any errors (like e.g. "Invalid params","Internal error").

    describe "with a notification request" do
      it "returns nil even if the method returns a result" do
        register("ping") { "pong" }

        handle jsonrpc: "2.0", method: "ping"

        assert_nil @response
      end

      it "returns nil even if the method raises an error" do
        register("ping") { raise StandardError, "Something bad happened" }

        handle jsonrpc: "2.0", method: "ping"

        assert_nil @response
      end
    end

    # 4.2 Parameter Structures
    #
    # If present, parameters for the rpc call MUST be provided as a Structured value. Either by-position through an
    # Array or by-name through an Object.
    #
    # * by-position: params MUST be an Array, containing the values in the Server expected order.
    # * by-name: params MUST be an Object, with member names that match the Server expected parameter names. The absence
    #   of expected names MAY result in an error being generated. The names MUST match exactly, including case, to the
    #   method's expected parameters.

    it "with array params returns a result" do
      register("sum", &:sum)

      handle jsonrpc: "2.0", id: 1, method: "sum", params: [1, 2, 3]

      assert_rpc_success expected_result: 6
    end

    it "with hash params returns a result" do
      register("sum") { |params| params[:a] + params[:b] }

      handle jsonrpc: "2.0", id: 1, method: "sum", params: { a: 1, b: 2 }

      assert_rpc_success expected_result: 3
    end

    # 5 Response object
    #
    # When a rpc call is made, the Server MUST reply with a Response, except for in the case of Notifications. The
    # Response is expressed as a single JSON Object, with the following members:

    # jsonrpc
    #   A String specifying the version of the JSON-RPC protocol. MUST be exactly "2.0".

    it "returns a result with jsonrpc set to 2.0" do
      register("add") { |params| params[:a] + params[:b] }

      handle jsonrpc: "2.0", id: 1, method: "add", params: { a: 1, b: 2 }

      assert_equal "2.0", @response[:jsonrpc]
    end

    # result
    #   This member is REQUIRED on success.
    #   This member MUST NOT exist if there was an error invoking the method.
    #   The value of this member is determined by the method invoked on the Server.
    #
    # error
    #   This member is REQUIRED on error.
    #   This member MUST NOT exist if there was no error triggered during invocation.
    #   The value for this member MUST be an Object as defined in section 5.1.
    #
    # id
    #   This member is REQUIRED.
    #   It MUST be the same as the value of the id member in the Request Object.
    #   If there was an error in detecting the id in the Request object (e.g. Parse error/Invalid Request), it MUST be
    #   Null.
    #
    # Either the result member or error member MUST be included, but both members MUST NOT be included.

    it "returns a result object and no error object on success" do
      register("ping") { "pong" }

      handle jsonrpc: "2.0", id: 1, method: "ping"

      assert_rpc_success expected_result: "pong"
      assert_equal 1, @response[:id]
      assert_nil @response[:error]
    end

    it "returns an error object and no result object on error" do
      register("ping") { raise StandardError, "Something bad happened" }

      handle jsonrpc: "2.0", id: 1, method: "ping"

      assert_rpc_error expected_error: {
        code: -32603,
        message: "Internal error",
      }
      assert_equal 1, @response[:id]
      assert_nil @response[:result]
    end

    it "returns nil for id when there is an error and and error detecting the id" do
      register("ping") { "pong" }

      handle jsonrpc: "2.0", id: {}, method: "ping"

      assert_rpc_error expected_error: {
        code: -32600,
        message: "Invalid Request",
        data: "Request ID must match validation pattern, or be an integer or null",
      }
      assert_nil @response[:id]
    end

    it "returns the same request id on an Invalid Request error when the id is detectable" do
      handle jsonrpc: "1.0", id: 3, method: "ping", params: {}

      assert_rpc_error expected_error: {
        code: -32600,
        message: "Invalid Request",
        data: "JSON-RPC version must be 2.0",
      }
      assert_equal 3, @response[:id]
    end

    it "returns nil for id on an Invalid Request error when the request has no id" do
      handle jsonrpc: "1.0", method: "ping", params: {}

      assert_rpc_error expected_error: {
        code: -32600,
        message: "Invalid Request",
        data: "JSON-RPC version must be 2.0",
      }
      assert_nil @response[:id]
    end

    it "returns nil for id on an Invalid Request error when the request id is explicitly null" do
      handle jsonrpc: "1.0", id: nil, method: "ping", params: {}

      assert_rpc_error expected_error: {
        code: -32600,
        message: "Invalid Request",
        data: "JSON-RPC version must be 2.0",
      }
      assert_nil @response[:id]
    end

    it "returns nil for id on an Invalid Request error when the id fails validation" do
      handle jsonrpc: "1.0", id: "<script>alert('xss')</script>", method: "ping", params: {}

      assert_rpc_error expected_error: {
        code: -32600,
        message: "Invalid Request",
        data: "JSON-RPC version must be 2.0",
      }
      assert_nil @response[:id]
    end

    # 5.1 Error object
    #
    # When a rpc call encounters an error, the Response Object MUST contain the error member with a value that is a
    # Object with the following members:
    #
    # code
    #   A Number that indicates the error type that occurred.
    #   This MUST be an integer.
    # message
    #   A String providing a short description of the error.
    #   The message SHOULD be limited to a concise single sentence.
    # data
    #   A Primitive or Structured value that contains additional information about the error.
    #   This may be omitted.
    #   The value of this member is defined by the Server (e.g. detailed error information, nested errors etc.).
    #
    # | code   | message          | meaning                                       |
    # | ------ | ---------------- | --------------------------------------------- |
    # | -32700 | Parse error      | Invalid JSON was received by the server.      |
    # | -32600 | Invalid Request  | The JSON sent is not a valid Request object.  |
    # | -32601 | Method not found | The method does not exist / is not available. |
    # | -32602 | Invalid params   | Invalid method parameter(s).                  |
    # | -32603 | Internal error   | Internal JSON-RPC error.                      |

    it "returns an error with the code set to -32700 there is a JSON parse error" do
      # Defer to handle_json for JSON parsing
      handle_json "Invalid JSON"

      assert_rpc_error expected_error: {
        code: -32700,
        message: "Parse error",
        data: "Invalid JSON",
      }
    end

    it "returns an error with code set to -32600 when the request is not an array or a hash" do
      handle true

      assert_rpc_error expected_error: {
        code: -32600,
        message: "Invalid Request",
        data: "Request must be an array or a hash",
      }
    end

    it "returns an error with the code set to -32601 when the method does not exist" do
      handle jsonrpc: "2.0", id: 1, method: "add", params: { a: 1, b: 2 }

      assert_rpc_error expected_error: {
        code: -32601,
        message: "Method not found",
        data: "add",
      }
    end

    it "returns nil when the method does not exist and the id is nil" do
      handle jsonrpc: "2.0", method: "add", params: { a: 1, b: 2 }

      assert_nil @response
    end

    it "returns an error with the code set to -32602 when the method parameters are invalid" do
      handle jsonrpc: "2.0", id: 1, method: "set_active", params: true

      assert_rpc_error expected_error: {
        code: -32602,
        message: "Invalid params",
        data: "Method parameters must be an array or an object or null",
      }
    end

    it "returns an error with the code set to -32603 when there is an internal error" do
      register("add") { raise StandardError, "Something bad happened" }

      handle jsonrpc: "2.0", id: 1, method: "add"

      assert_rpc_error expected_error: {
        code: -32603,
        message: "Internal error",
      }
    end

    it "returns an error with the code set to -32600 when error_type of RequestHandlerError is :invalid_request" do
      register("test_method") do
        raise MCP::Server::RequestHandlerError.new(
          "Invalid request data",
          {},
          error_type: :invalid_request,
        )
      end

      handle jsonrpc: "2.0", id: 1, method: "test_method"

      assert_rpc_error expected_error: {
        code: -32600,
        message: "Invalid Request",
        data: "Invalid request data",
      }
    end

    it "returns an error with the code set to -32602 when error_type of RequestHandlerError is :invalid_params" do
      register("test_method") do
        raise MCP::Server::RequestHandlerError.new(
          "Parameter validation failed",
          {},
          error_type: :invalid_params,
        )
      end

      handle jsonrpc: "2.0", id: 1, method: "test_method"

      assert_rpc_error expected_error: {
        code: -32602,
        message: "Invalid params",
        data: "Parameter validation failed",
      }
    end

    it "returns an error with the code set to -32700 when error_type of RequestHandlerError is :parse_error" do
      register("test_method") do
        raise MCP::Server::RequestHandlerError.new(
          "Failed to parse input",
          {},
          error_type: :parse_error,
        )
      end

      handle jsonrpc: "2.0", id: 1, method: "test_method"

      assert_rpc_error expected_error: {
        code: -32700,
        message: "Parse error",
        data: "Failed to parse input",
      }
    end

    it "returns an error with the code set to -32603 when error_type of RequestHandlerError is :internal_error" do
      register("test_method") do
        raise MCP::Server::RequestHandlerError.new(
          "Internal processing error",
          {},
          error_type: :internal_error,
        )
      end

      handle jsonrpc: "2.0", id: 1, method: "test_method"

      assert_rpc_error expected_error: {
        code: -32603,
        message: "Internal error",
        data: "Internal processing error",
      }
    end

    it "returns an error with the code set to -32603 when error_type of RequestHandlerError is unknown" do
      register("test_method") do
        raise MCP::Server::RequestHandlerError.new(
          "Unknown error occurred",
          {},
          error_type: :unknown,
        )
      end

      handle jsonrpc: "2.0", id: 1, method: "test_method"

      assert_rpc_error expected_error: {
        code: -32603,
        message: "Internal error",
        data: "Unknown error occurred",
      }
    end

    it "returns a custom error code when RequestHandlerError has error_code set" do
      register("test_method") do
        raise MCP::Server::RequestHandlerError.new(
          "Custom error",
          nil,
          error_code: -32042,
          error_data: { elicitations: [{ id: "abc" }] },
        )
      end

      handle jsonrpc: "2.0", id: 1, method: "test_method"

      assert_rpc_error expected_error: {
        code: -32042,
        message: "Custom error",
        data: { elicitations: [{ id: "abc" }] },
      }
    end

    it "falls back to error_type when error_code is nil" do
      register("test_method") do
        raise MCP::Server::RequestHandlerError.new(
          "Invalid params error",
          nil,
          error_type: :invalid_params,
        )
      end

      handle jsonrpc: "2.0", id: 1, method: "test_method"

      assert_rpc_error expected_error: {
        code: -32602,
        message: "Invalid params",
        data: "Invalid params error",
      }
    end

    # 6 Batch
    #
    # To send several Request objects at the same time, the Client MAY send an Array filled with Request objects.
    #
    # The Server should respond with an Array containing the corresponding Response objects, after all of the batch
    # Request objects have been processed. A Response object SHOULD exist for each Request object, except that there
    # SHOULD NOT be any Response objects for notifications. The Server MAY process a batch rpc call as a set of
    # concurrent tasks, processing them in any order and with any width of parallelism.
    #
    # The Response objects being returned from a batch call MAY be returned in any order within the Array. The Client
    # SHOULD match contexts between the set of Request objects and the resulting set of Response objects based on the id
    # member within each Object.
    #
    # If the batch rpc call itself fails to be recognized as an valid JSON or as an Array with at least one value, the
    # response from the Server MUST be a single Response object. If there are no Response objects contained within the
    # Response array as it is to be sent to the client, the server MUST NOT return an empty Array and should return
    # nothing at all.

    describe "with batch request" do
      it "returns an invalid request error when the request is an empty array" do
        handle []

        assert_rpc_error expected_error: {
          code: -32600,
          message: "Invalid Request",
          data: "Request is an empty array",
        }
      end

      it "returns an array of Response objects" do
        register("add") { |params| params[:a] + params[:b] }
        register("mul") { |params| params[:a] * params[:b] }

        handle [
          { jsonrpc: "2.0", id: 100, method: "add", params: { a: 1, b: 2 } },
          { jsonrpc: "2.0", id: 200, method: "mul", params: { a: 3, b: 4 } },
        ]

        assert @response.is_a?(Array)
        assert @response.all? { |result| result[:jsonrpc] == "2.0" }
        assert_equal [100, 200], @response.map { |result| result[:id] }
        assert_equal [3, 12], @response.map { |result| result[:result] }
        assert @response.all? { |result| result[:error].nil? }
      end

      it "returns an array of Response objects excluding notifications" do
        register("ping") {}
        register("add") { |params| params[:a] + params[:b] }

        handle [
          { jsonrpc: "2.0", method: "ping" },
          { jsonrpc: "2.0", id: 100, method: "add", params: { a: 1, b: 2 } },
          { jsonrpc: "2.0", id: 200, method: "add", params: { a: 2, b: 3 } },
        ]

        assert @response.is_a?(Array)
        assert @response.all? { |result| result[:jsonrpc] == "2.0" }
        assert_equal [100, 200], @response.map { |result| result[:id] }
        assert_equal [3, 5], @response.map { |result| result[:result] }
        assert @response.all? { |result| result[:error].nil? }
      end

      it "returns a single response object when the batch has only a single response" do
        register("ping") {}
        register("add") { |params| params[:a] + params[:b] }

        handle [
          { jsonrpc: "2.0", method: "ping" },
          { jsonrpc: "2.0", id: 100, method: "add", params: { a: 1, b: 2 } },
        ]

        assert_rpc_success expected_result: 3
      end

      it "returns nil when the batch has only notifications" do
        register("ping") {}
        register("pong") {}

        handle [
          { jsonrpc: "2.0", method: "ping" },
          { jsonrpc: "2.0", method: "pong" },
        ]

        assert_nil @response
      end

      it "preserves the request id of an invalid entry within a batch" do
        register("add") { |params| params[:a] + params[:b] }

        handle [
          { jsonrpc: "2.0", id: 100, method: "add", params: { a: 1, b: 2 } },
          { jsonrpc: "1.0", id: 200, method: "add", params: { a: 3, b: 4 } },
        ]

        assert @response.is_a?(Array)
        assert_equal [100, 200], @response.map { |result| result[:id] }
        assert_equal 3, @response.first[:result]
        assert_equal(-32600, @response.last.dig(:error, :code))
      end

      it "returns an invalid request error for a non-object batch element instead of raising" do
        [[], [1], ["x"], [nil], [true]].each do |batch|
          handle batch

          assert_equal(-32600, @response.dig(:error, :code))
          assert_equal "Invalid Request", @response.dig(:error, :message)
          assert_nil @response[:id]
        end
      end

      it "handles a valid request followed by a non-object element without raising" do
        register("add") { |params| params[:a] + params[:b] }

        handle [
          { jsonrpc: "2.0", id: 100, method: "add", params: { a: 1, b: 2 } },
          [],
        ]

        assert @response.is_a?(Array)
        assert_equal 3, @response.first[:result]
        assert_equal(-32600, @response.last.dig(:error, :code))
        assert_nil @response.last[:id]
      end

      it "does not raise from handle_json when a batch element is not an object" do
        result = handle_json("[[]]")

        assert_equal(-32600, @response.dig(:error, :code))
        refute_nil result
      end
    end

    # 7 Examples
    # ...
    # 8 Extensions
    #
    # Method names that begin with rpc. are reserved for system extensions, and MUST NOT be used for anything else. Each
    # system extension is defined in a related specification. All system extensions are OPTIONAL.

    describe "ID pattern configuration" do
      it "uses the default pattern by default" do
        register("add") { |params| params[:a] + params[:b] }

        handle jsonrpc: "2.0", id: "valid-id_123", method: "add", params: { a: 1, b: 2 }

        assert_rpc_success expected_result: 3
      end

      it "rejects IDs that don't match the default pattern" do
        handle jsonrpc: "2.0", id: "invalid@id", method: "add", params: { a: 1, b: 2 }

        assert_rpc_error expected_error: {
          code: -32600,
          message: "Invalid Request",
          data: "Request ID must match validation pattern, or be an integer or null",
        }
      end

      it "uses default pattern and rejects @ signs" do
        register("add") { |params| params[:a] + params[:b] }

        # Default pattern should reject @ signs
        handle jsonrpc: "2.0", id: "user@example.com", method: "add", params: { a: 1, b: 2 }

        assert_rpc_error expected_error: {
          code: -32600,
          message: "Invalid Request",
          data: "Request ID must match validation pattern, or be an integer or null",
        }
      end

      it "accepts custom pattern as parameter to handle" do
        register("add") { |params| params[:a] + params[:b] }
        custom_pattern = /\A[a-zA-Z0-9_.\-@]+\z/

        @response = JsonRpcHandler.handle(
          { jsonrpc: "2.0", id: "user@example.com", method: "add", params: { a: 1, b: 2 } },
          id_validation_pattern: custom_pattern,
        ) { |method_name, _request_id| @registry[method_name] }

        assert_rpc_success expected_result: 3
        assert_equal "user@example.com", @response[:id]
      end

      it "validates against custom pattern parameter" do
        custom_pattern = /\A[a-zA-Z0-9_.\-@]+\z/

        @response = JsonRpcHandler.handle(
          { jsonrpc: "2.0", id: "id<script>", method: "add", params: { a: 1, b: 2 } },
          id_validation_pattern: custom_pattern,
        ) { |method_name, _request_id| @registry[method_name] }

        assert_rpc_error expected_error: {
          code: -32600,
          message: "Invalid Request",
          data: "Request ID must match validation pattern, or be an integer or null",
        }
      end

      it "accepts custom pattern as parameter to handle_json" do
        register("add") { |params| params[:a] + params[:b] }
        custom_pattern = /\A[a-zA-Z0-9_.\-@]+\z/

        @response_json = JsonRpcHandler.handle_json(
          { jsonrpc: "2.0", id: "user@example.com", method: "add", params: { a: 1, b: 2 } }.to_json,
          id_validation_pattern: custom_pattern,
        ) { |method_name, _request_id| @registry[method_name] }
        @response = JSON.parse(@response_json, symbolize_names: true)

        assert_rpc_success expected_result: 3
        assert_equal "user@example.com", @response[:id]
      end

      it "applies custom pattern to batch requests" do
        register("add") { |params| params[:a] + params[:b] }
        register("mul") { |params| params[:a] * params[:b] }
        custom_pattern = /\A[a-zA-Z0-9_.\-@]+\z/

        @response = JsonRpcHandler.handle(
          [
            { jsonrpc: "2.0", id: "req@1", method: "add", params: { a: 1, b: 2 } },
            { jsonrpc: "2.0", id: "req@2", method: "mul", params: { a: 3, b: 4 } },
          ],
          id_validation_pattern: custom_pattern,
        ) { |method_name, _request_id| @registry[method_name] }

        assert @response.is_a?(Array)
        assert_equal ["req@1", "req@2"], @response.map { |r| r[:id] }
        assert_equal [3, 12], @response.map { |r| r[:result] }
      end

      it "parameter pattern overrides default pattern" do
        register("add") { |params| params[:a] + params[:b] }
        # Use permissive parameter pattern (default is restrictive)
        custom_pattern = /\A[a-zA-Z0-9_.\-@]+\z/

        @response = JsonRpcHandler.handle(
          { jsonrpc: "2.0", id: "user@example.com", method: "add", params: { a: 1, b: 2 } },
          id_validation_pattern: custom_pattern,
        ) { |method_name, _request_id| @registry[method_name] }

        assert_rpc_success expected_result: 3
        assert_equal "user@example.com", @response[:id]
      end

      it "accepts any string when pattern is nil" do
        register("add") { |params| params[:a] + params[:b] }

        @response = JsonRpcHandler.handle(
          { jsonrpc: "2.0", id: "<script>alert('xss')</script>", method: "add", params: { a: 1, b: 2 } },
          id_validation_pattern: nil,
        ) { |method_name, _request_id| @registry[method_name] }

        assert_rpc_success expected_result: 3
        assert_equal "<script>alert('xss')</script>", @response[:id]
      end
    end
  end

  describe "#handle_json" do
    it "returns a Response object when the request is valid and not a notification" do
      register("add") { |params| params[:a] + params[:b] }

      handle_json({ jsonrpc: "2.0", id: 1, method: "add", params: { a: 1, b: 2 } }.to_json)

      assert_rpc_success(expected_result: 3)
    end

    it "returns nil for notifications" do
      register("ping") {}

      handle_json({ jsonrpc: "2.0", method: "ping" }.to_json)

      assert_nil @response
    end

    it "returns an error preserving the request id when the request is invalid" do
      handle_json({ jsonrpc: "0.0", id: 1, method: "add", params: { a: 1, b: 2 } }.to_json)

      assert_equal 1, @response[:id]
      assert_equal(-32600, @response.dig(:error, :code))
    end
  end

  private

  def register(method_name, &block)
    @registry[method_name] = block
  end

  def handle(request)
    @response = JsonRpcHandler.handle(request) { |method_name, _request_id| @registry[method_name] }
  end

  def handle_json(request_json)
    @response_json = JsonRpcHandler.handle_json(request_json) { |method_name, _request_id| @registry[method_name] }
    @response = JSON.parse(@response_json, symbolize_names: true) if @response_json
  end

  def assert_rpc_success(expected_result:)
    assert_equal(expected_result, @response[:result])
    assert_nil(@response[:error])
  end

  def assert_rpc_error(expected_error:)
    assert_equal(expected_error, @response[:error])
    assert_nil(@response[:result])
  end
end
