# frozen_string_literal: true

require "test_helper"

module MCP
  class ToolTest < ActiveSupport::TestCase
    class TestTool < Tool
      tool_name "test_tool"
      description "a test tool for testing"
      icons [Icon.new(mime_type: "image/png", sizes: ["48x48", "96x96"], src: "https://example.com", theme: "light")]
      input_schema({ properties: { message: { type: "string" } }, required: ["message"] })
      annotations(
        destructive_hint: false,
        idempotent_hint: true,
        open_world_hint: false,
        read_only_hint: true,
        title: "Test Tool",
      )
      meta(
        foo: "bar",
      )

      class << self
        def call(message:, server_context: nil)
          Tool::Response.new([{ type: "text", content: "OK" }])
        end
      end
    end

    test "#to_h returns a hash including name, description, icons, and inputSchema" do
      expected = {
        name: "mock_tool",
        title: "Mock Tool",
        description: "a mock tool for testing",
        icons: [{ mimeType: "image/png", sizes: ["48x48", "96x96"], src: "https://example.com", theme: "light" }],
        inputSchema: { "$schema": "https://json-schema.org/draft/2020-12/schema", type: "object" },
      }
      tool = Tool.define(
        name: "mock_tool",
        title: "Mock Tool",
        description: "a mock tool for testing",
        icons: [Icon.new(mime_type: "image/png", sizes: ["48x48", "96x96"], src: "https://example.com", theme: "light")],
      )

      assert_equal(expected, tool.to_h)
    end

    test "#to_h does not have `:title` key when title is omitted" do
      tool = Tool.define(
        name: "mock_tool",
        description: "a mock tool for testing",
      )
      refute tool.to_h.key?(:title)
    end

    test "#to_h does not have `:icons` key when icons is empty" do
      tool = Tool.define(
        name: "tool_without_icons",
        description: "a tool without icons",
      )

      refute tool.to_h.key?(:icons)
    end

    test "#to_h does not have `:icons` key when icons is nil" do
      tool = Tool.define(
        name: "tool_without_icons",
        description: "a tool without icons",
        icons: nil,
      )

      refute tool.to_h.key?(:icons)
    end

    test "#to_h includes annotations when present" do
      tool = TestTool
      expected_annotations = {
        destructiveHint: false,
        idempotentHint: true,
        openWorldHint: false,
        readOnlyHint: true,
        title: "Test Tool",
      }
      assert_equal expected_annotations, tool.to_h[:annotations]
    end

    test "#to_h includes meta when present" do
      tool = TestTool
      expected_meta = {
        foo: "bar",
      }
      assert_equal expected_meta, tool.to_h[:_meta]
    end

    test "#call invokes the tool block and returns the response" do
      tool = TestTool
      response = tool.call(message: "test")
      assert_equal [{ type: "text", content: "OK" }], response.content
      refute response.error?
    end

    test "allows declarative definition of tools as classes" do
      class MockTool < Tool
        tool_name "my_mock_tool"
        description "a mock tool for testing"
        input_schema({ properties: { message: { type: "string" } }, required: ["message"] })
      end

      tool = MockTool
      assert_equal "my_mock_tool",  tool.name_value
      assert_equal "a mock tool for testing", tool.description
      assert_equal(
        {
          "$schema": "https://json-schema.org/draft/2020-12/schema",
          type: "object",
          properties: { message: { type: "string" } },
          required: ["message"],
        },
        tool.input_schema.to_h,
      )
    end

    test "defaults to class name as tool name" do
      class DefaultNameTool < Tool
      end

      tool = DefaultNameTool

      assert_equal "default_name_tool", tool.tool_name
    end

    test "input schema defaults to an empty hash" do
      class NoInputSchemaTool < Tool; end

      tool = NoInputSchemaTool

      expected = { "$schema": "https://json-schema.org/draft/2020-12/schema", type: "object" }
      assert_equal expected, tool.input_schema.to_h
    end

    test "accepts input schema as an InputSchema object" do
      class InputSchemaTool < Tool
        input_schema InputSchema.new(properties: { message: { type: "string" } }, required: ["message"])
      end

      tool = InputSchemaTool

      expected = {
        "$schema": "https://json-schema.org/draft/2020-12/schema",
        type: "object",
        properties: { message: { type: "string" } },
        required: ["message"],
      }
      assert_equal expected, tool.input_schema.to_h
    end

    test "raises detailed error message for invalid schema" do
      error = assert_raises(ArgumentError) do
        Class.new(MCP::Tool) do
          input_schema(
            properties: {
              count: { type: "integer", minimum: "not a number" },
            },
            required: ["count"],
          )
        end
      end

      assert_includes error.message, "Invalid JSON Schema"
      assert_includes error.message, "properties/count/minimum"
      assert_includes error.message, "number"
    end

    test ".define allows definition of simple tools with a block" do
      tool = Tool.define(
        name: "mock_tool",
        title: "Mock Tool",
        description: "a mock tool for testing",
      ) do
        Tool::Response.new([{ type: "text", content: "OK" }])
      end

      assert_equal "mock_tool", tool.name_value
      assert_equal "a mock tool for testing", tool.description
      assert_equal Tool::InputSchema.new, tool.input_schema
    end

    test ".define allows definition of tools with annotations" do
      tool = Tool.define(
        name: "mock_tool",
        title: "Mock Tool",
        description: "a mock tool for testing",
        annotations: {
          read_only_hint: true,
          title: "Mock Tool",
        },
      ) do
        Tool::Response.new([{ type: "text", content: "OK" }])
      end

      assert_equal "mock_tool", tool.name_value
      assert_equal "Mock Tool", tool.title
      assert_equal "a mock tool for testing", tool.description
      assert_equal tool.input_schema, Tool::InputSchema.new
      assert_equal({ destructiveHint: true, idempotentHint: false, openWorldHint: true, readOnlyHint: true, title: "Mock Tool" }, tool.annotations_value.to_h)
    end

    test ".define allows definition of tools with meta" do
      tool = Tool.define(
        name: "mock_tool",
        title: "Mock Tool",
        description: "a mock tool for testing",
        meta: { foo: "bar" },
      ) do |_|
        Tool::Response.new([{ type: "text", content: "OK" }])
      end

      assert_equal "mock_tool", tool.name_value
      assert_equal "Mock Tool", tool.title
      assert_equal "a mock tool for testing", tool.description
      assert_equal tool.input_schema, Tool::InputSchema.new
      assert_equal({ foo: "bar" }, tool.meta_value)
    end

    test "Tool class method annotations can be set and retrieved" do
      class AnnotationsTestTool < Tool
        tool_name "annotations_test"
        annotations(
          read_only_hint: true,
          title: "Annotations Test",
        )
      end

      tool = AnnotationsTestTool
      assert_instance_of Tool::Annotations, tool.annotations_value
      assert_equal "Annotations Test", tool.annotations_value.title
      assert tool.annotations_value.read_only_hint
    end

    test "Tool class method annotations can be updated" do
      class UpdatableAnnotationsTool < Tool
        tool_name "updatable_annotations"
      end

      tool = UpdatableAnnotationsTool
      tool.annotations(title: "Initial")
      assert_equal "Initial", tool.annotations_value.title

      tool.annotations(title: "Updated")
      assert_equal "Updated", tool.annotations_value.title
    end

    test "Tool class method meta can be set and retrieved" do
      class MetaTestTool < Tool
        tool_name "meta_test"
        meta(foo: "bar")
      end

      tool = MetaTestTool
      assert_instance_of Hash, tool.meta_value
      assert_equal "bar", tool.meta_value[:foo]
    end

    test "Tool class method meta can be updated" do
      class UpdatableMetaTool < Tool
        tool_name "updatable_meta"
      end

      tool = UpdatableMetaTool
      tool.meta(foo: "baz")
      assert_equal({ foo: "baz" }, tool.meta_value)

      tool.meta(foo: "qux")
      assert_equal({ foo: "qux" }, tool.meta_value)
    end

    test "#call with Sorbet typed tools invokes the tool block and returns the response" do
      skip "sorbet-static-and-runtime requires Ruby 3.0+." if RUBY_VERSION < "3.0"

      class TypedTestTool < Tool
        tool_name "test_tool"
        description "a test tool for testing"
        input_schema({ properties: { message: { type: "string" } }, required: ["message"] })

        class << self
          extend T::Sig

          sig { params(message: String, server_context: T.nilable(T.untyped)).returns(Tool::Response) }
          def call(message:, server_context: nil)
            Tool::Response.new([{ type: "text", content: "OK" }])
          end
        end
      end

      tool = TypedTestTool
      response = tool.call(message: "test")
      assert_equal [{ type: "text", content: "OK" }], response.content
      refute response.error?
    end

    class TestToolWithoutServerContext < Tool
      tool_name "test_tool_without_server_context"
      description "a test tool for testing without server context"
      input_schema({ properties: { message: { type: "string" } }, required: ["message"] })

      class << self
        def call(message:)
          Tool::Response.new([{ type: "text", content: "OK" }])
        end
      end
    end

    class TestToolWithoutRequired < Tool
      tool_name "test_tool_without_required"
      description "a test tool for testing without required server context"

      class << self
        def call(message, server_context: nil)
          Tool::Response.new([{ type: "text", content: "OK" }])
        end
      end
    end

    test "tool call without server context" do
      tool = TestToolWithoutServerContext
      response = tool.call(message: "test")
      assert_equal [{ type: "text", content: "OK" }], response.content
    end

    test "tool call with server context and without required" do
      tool = TestToolWithoutRequired
      response = tool.call("test", server_context: { foo: "bar" })
      assert_equal [{ type: "text", content: "OK" }], response.content
    end

    test "input_schema accepts $ref in schema" do
      schema_with_ref = {
        properties: {
          foo: { "$ref" => "#/definitions/bar" },
        },
        required: ["foo"],
        definitions: {
          bar: { type: "string" },
        },
      }
      tool_class = Class.new(MCP::Tool) do
        input_schema schema_with_ref
      end
      assert_equal "#/definitions/bar", tool_class.input_schema.to_h[:properties][:foo][:$ref]
    end

    test "#to_h includes outputSchema when present" do
      expected = {
        name: "mock_tool",
        title: "Mock Tool",
        description: "a mock tool for testing",
        icons: [{ mimeType: "image/png", sizes: ["48x48", "96x96"], src: "https://example.com", theme: "light" }],
        inputSchema: { "$schema": "https://json-schema.org/draft/2020-12/schema", type: "object" },
        outputSchema: {
          "$schema": "https://json-schema.org/draft/2020-12/schema",
          type: "object",
          properties: { result: { type: "string" } },
          required: ["result"],
        },
      }
      tool = Tool.define(
        name: "mock_tool",
        title: "Mock Tool",
        description: "a mock tool for testing",
        icons: [Icon.new(mime_type: "image/png", sizes: ["48x48", "96x96"], src: "https://example.com", theme: "light")],
        output_schema: { properties: { result: { type: "string" } }, required: ["result"] },
      )

      assert_equal expected, tool.to_h
    end

    test "#to_h does not include outputSchema when not set" do
      tool = Tool.define(
        name: "mock_tool",
        description: "a mock tool for testing",
      )
      refute tool.to_h.key?(:outputSchema)
    end

    test "output_schema defaults to nil" do
      class NoOutputSchemaTool < Tool; end
      tool = NoOutputSchemaTool
      assert_nil tool.output_schema
    end

    test "accepts output_schema as a hash" do
      class HashOutputSchemaTool < Tool
        output_schema({ properties: { result: { type: "string" } }, required: ["result"] })
      end

      tool = HashOutputSchemaTool
      expected = {
        "$schema": "https://json-schema.org/draft/2020-12/schema",
        type: "object",
        properties: { result: { type: "string" } },
        required: ["result"],
      }
      assert_equal expected, tool.output_schema.to_h
    end

    test "accepts output_schema as an OutputSchema object" do
      class OutputSchemaObjectTool < Tool
        output_schema Tool::OutputSchema.new(properties: { result: { type: "string" } }, required: ["result"])
      end

      tool = OutputSchemaObjectTool
      expected = {
        "$schema": "https://json-schema.org/draft/2020-12/schema",
        type: "object",
        properties: { result: { type: "string" } },
        required: ["result"],
      }
      assert_equal expected, tool.output_schema.to_h
    end

    test "output_schema raises detailed error message for invalid schema" do
      error = assert_raises(ArgumentError) do
        Class.new(MCP::Tool) do
          output_schema(
            properties: {
              count: { type: "integer", minimum: "not a number" },
            },
            required: ["count"],
          )
        end
      end

      assert_includes error.message, "Invalid JSON Schema"
      assert_includes error.message, "properties/count/minimum"
      assert_includes error.message, "number"
    end

    test "output_schema accepts $ref in schema" do
      schema_with_ref = {
        properties: {
          foo: { "$ref" => "#/definitions/bar" },
        },
        required: ["foo"],
        definitions: {
          bar: { type: "string" },
        },
      }
      tool_class = Class.new(MCP::Tool) do
        output_schema schema_with_ref
      end
      assert_equal "#/definitions/bar", tool_class.output_schema.to_h[:properties][:foo][:$ref]
    end

    test ".define allows definition of tools with output_schema" do
      tool = Tool.define(
        name: "mock_tool",
        title: "Mock Tool",
        description: "a mock tool for testing",
        output_schema: { properties: { result: { type: "string" } }, required: ["result"] },
      ) do
        Tool::Response.new([{ type: "text", content: "OK" }])
      end

      assert_equal "mock_tool", tool.name_value
      assert_equal "a mock tool for testing", tool.description
      assert_instance_of Tool::OutputSchema, tool.output_schema
      expected_output_schema = {
        "$schema": "https://json-schema.org/draft/2020-12/schema",
        type: "object",
        properties: { result: { type: "string" } },
        required: ["result"],
      }
      assert_equal expected_output_schema, tool.output_schema.to_h
    end

    class TestToolWithOutputSchema < Tool
      tool_name "test_tool_with_output"
      description "a test tool with output schema"
      input_schema({ properties: { message: { type: "string" } }, required: ["message"] })
      output_schema({ properties: { result: { type: "string" }, success: { type: "boolean" } }, required: ["result", "success"] })

      class << self
        def call(message:, server_context: nil)
          Tool::Response.new([{ type: "text", content: "OK" }])
        end
      end
    end

    test "declarative definition of tools with output schema" do
      tool = TestToolWithOutputSchema
      assert_equal "test_tool_with_output", tool.name_value
      assert_equal "a test tool with output schema", tool.description

      expected_input = {
        "$schema": "https://json-schema.org/draft/2020-12/schema",
        type: "object",
        properties: { message: { type: "string" } },
        required: ["message"],
      }
      assert_equal expected_input, tool.input_schema.to_h

      expected_output = {
        "$schema": "https://json-schema.org/draft/2020-12/schema",
        type: "object",
        properties: { result: { type: "string" }, success: { type: "boolean" } },
        required: ["result", "success"],
      }
      assert_equal expected_output, tool.output_schema.to_h
    end

    test "accepts valid tool names" do
      assert Tool.define(name: "getUser")
      assert Tool.define(name: "DATA_EXPORT_v2")
      assert Tool.define(name: "admin.tools.list")
      assert Tool.define(name: "a" * 128)
    end

    test "raises an error when tool name is empty in class definition" do
      error = assert_raises(ArgumentError) do
        class EmptyTitleNameTool < Tool
          tool_name ""
        end
      end
      assert_equal("Tool names should be between 1 and 128 characters in length (inclusive).", error.message)
    end

    test "allows nil tool name in class definition" do
      assert_nothing_raised do
        class EmptyTitleNameTool < Tool
          tool_name nil
        end
      end
    end

    test "raises an error when tool name is empty" do
      error = assert_raises(ArgumentError) { Tool.define(name: "") }
      assert_equal("Tool names should be between 1 and 128 characters in length (inclusive).", error.message)
    end

    test "raises an error when tool name exceeds 128 characters" do
      error = assert_raises(ArgumentError) { Tool.define(name: "a" * 129) }
      assert_equal("Tool names should be between 1 and 128 characters in length (inclusive).", error.message)
    end

    test "raises an error when tool name includes invalid characters (e.g., spaces)" do
      error = assert_raises(ArgumentError) { Tool.define(name: "foo bar") }
      assert_equal(<<~MESSAGE, error.message)
        Tool names only allowed characters: uppercase and lowercase ASCII letters (A-Z, a-z), digits (0-9), underscore (_), hyphen (-), and dot (.).
      MESSAGE
    end
  end
end
