# frozen_string_literal: true

RSpec.describe Dispatch::Adapter::Claude::RequestBuilder::Tools do
  # Convenience: build a ToolDefinition from keyword args
  def tool(name:, description: "A tool", parameters: nil)
    Dispatch::Adapter::ToolDefinition.new(
      name: name,
      description: description,
      parameters: parameters || {
        "type" => "object",
        "properties" => {},
        "required" => []
      }
    )
  end

  # Build and return the wire hash for a single tool
  def built(tool_obj, is_oauth: false, disable_strict: false)
    described_class.build([tool_obj], is_oauth: is_oauth, disable_strict: disable_strict).first
  end

  # ── Basic structure ──────────────────────────────────────────────────────────

  describe ".build" do
    it "returns an empty array for nil tools" do
      expect(described_class.build(nil)).to eq([])
    end

    it "returns an empty array for empty tools" do
      expect(described_class.build([])).to eq([])
    end

    it "produces :name, :description, :input_schema keys for each tool" do
      wire = built(tool(name: "grep"))
      expect(wire).to have_key(:name)
      expect(wire).to have_key(:description)
      expect(wire).to have_key(:input_schema)
    end
  end

  # ── additionalProperties injection ──────────────────────────────────────────

  describe "additionalProperties injection" do
    it "sets additionalProperties: false on object nodes" do
      t = tool(name: "x", parameters: {
                 "type" => "object",
                 "properties" => { "cmd" => { "type" => "string" } }
               })
      schema = built(t)[:input_schema]
      expect(schema["additionalProperties"]).to eq(false)
    end

    it "preserves an explicitly-set additionalProperties value" do
      t = tool(name: "x", parameters: {
                 "type" => "object",
                 "properties" => {},
                 "additionalProperties" => true
               })
      schema = built(t)[:input_schema]
      expect(schema["additionalProperties"]).to eq(true)
    end

    it "sets additionalProperties: false on nested object nodes" do
      t = tool(name: "x", parameters: {
                 "type" => "object",
                 "properties" => {
                   "inner" => {
                     "type" => "object",
                     "properties" => { "x" => { "type" => "integer" } }
                   }
                 }
               })
      schema = built(t)[:input_schema]
      expect(schema["properties"]["inner"]["additionalProperties"]).to eq(false)
    end
  end

  # ── Unsupported field removal ────────────────────────────────────────────────

  describe "unsupported field removal" do
    it "removes patternProperties from object nodes" do
      t = tool(name: "x", parameters: {
                 "type" => "object",
                 "properties" => {},
                 "patternProperties" => { ".*" => { "type" => "string" } }
               })
      schema = built(t)[:input_schema]
      expect(schema).not_to have_key("patternProperties")
    end

    it "removes maxItems from object nodes" do
      t = tool(name: "x", parameters: {
                 "type" => "object",
                 "properties" => {},
                 "maxItems" => 5
               })
      schema = built(t)[:input_schema]
      expect(schema).not_to have_key("maxItems")
    end

    it "removes minItems from object nodes" do
      t = tool(name: "x", parameters: {
                 "type" => "object",
                 "properties" => {},
                 "minItems" => 1
               })
      schema = built(t)[:input_schema]
      expect(schema).not_to have_key("minItems")
    end

    it "removes minItems from array nodes when value is not 0 or 1" do
      t = tool(name: "x", parameters: {
                 "type" => "array",
                 "items" => { "type" => "string" },
                 "minItems" => 3
               })
      schema = built(t)[:input_schema]
      expect(schema).not_to have_key("minItems")
    end

    it "keeps minItems on array nodes when value is 0" do
      t = tool(name: "x", parameters: {
                 "type" => "array",
                 "items" => { "type" => "string" },
                 "minItems" => 0
               })
      schema = built(t)[:input_schema]
      expect(schema["minItems"]).to eq(0)
    end

    it "keeps minItems on array nodes when value is 1" do
      t = tool(name: "x", parameters: {
                 "type" => "array",
                 "items" => { "type" => "string" },
                 "minItems" => 1
               })
      schema = built(t)[:input_schema]
      expect(schema["minItems"]).to eq(1)
    end

    it "does not strip maxItems from array nodes" do
      t = tool(name: "x", parameters: {
                 "type" => "array",
                 "items" => { "type" => "string" },
                 "maxItems" => 10
               })
      schema = built(t)[:input_schema]
      # maxItems only removed from object nodes, not arrays — check object version
      # In this test, type is "array" so maxItems should remain
      expect(schema["maxItems"]).to eq(10)
    end
  end

  # ── proxy_ prefix ────────────────────────────────────────────────────────────

  describe "OAuth proxy_ prefix" do
    it "prefixes non-builtin tool names with proxy_ when is_oauth: true" do
      wire = built(tool(name: "grep"), is_oauth: true)
      expect(wire[:name]).to eq("proxy_grep")
    end

    it "does not prefix in non-OAuth mode" do
      wire = built(tool(name: "grep"), is_oauth: false)
      expect(wire[:name]).to eq("grep")
    end

    it "does not prefix builtin tools even in OAuth mode" do
      %w[web_search code_execution text_editor computer].each do |builtin|
        wire = built(tool(name: builtin), is_oauth: true)
        expect(wire[:name]).to eq(builtin), "expected #{builtin} to be unprefixed"
      end
    end

    it "does not double-prefix already-prefixed names" do
      wire = built(tool(name: "proxy_grep"), is_oauth: true)
      expect(wire[:name]).to eq("proxy_grep")
    end
  end

  # ── Strict mode — allowlist ───────────────────────────────────────────────────

  describe "strict mode allowlist" do
    let(:simple_strict_schema) do
      {
        "type" => "object",
        "properties" => {
          "command" => { "type" => "string" }
        },
        "required" => ["command"]
      }
    end

    it "sets strict: true for bash when schema fits" do
      wire = built(tool(name: "bash", parameters: simple_strict_schema))
      expect(wire[:strict]).to eq(true)
    end

    it "sets strict: true for python when schema fits" do
      wire = built(tool(name: "python", parameters: simple_strict_schema))
      expect(wire[:strict]).to eq(true)
    end

    it "sets strict: true for edit when schema fits" do
      wire = built(tool(name: "edit", parameters: simple_strict_schema))
      expect(wire[:strict]).to eq(true)
    end

    it "sets strict: true for find when schema fits" do
      wire = built(tool(name: "find", parameters: simple_strict_schema))
      expect(wire[:strict]).to eq(true)
    end

    it "does NOT set strict for non-allowlist tools" do
      wire = built(tool(name: "grep", parameters: simple_strict_schema))
      expect(wire[:strict]).to be_nil
    end

    it "does NOT set strict for custom tools not in allowlist" do
      wire = built(tool(name: "my_custom_tool", parameters: simple_strict_schema))
      expect(wire[:strict]).to be_nil
    end
  end

  # ── disable_strict ───────────────────────────────────────────────────────────

  describe "disable_strict: true" do
    it "suppresses strict: true for allowlist tools" do
      t = tool(name: "bash", parameters: {
                 "type" => "object",
                 "properties" => { "cmd" => { "type" => "string" } },
                 "required" => ["cmd"]
               })
      wire = built(t, disable_strict: true)
      expect(wire[:strict]).to be_nil
    end
  end

  describe "ENV[CLAUDE_NO_STRICT]" do
    around do |example|
      original = ENV.fetch("CLAUDE_NO_STRICT", nil)
      ENV["CLAUDE_NO_STRICT"] = "1"
      example.run
    ensure
      ENV["CLAUDE_NO_STRICT"] = original
    end

    it "suppresses strict: true for allowlist tools" do
      t = tool(name: "bash", parameters: {
                 "type" => "object",
                 "properties" => { "cmd" => { "type" => "string" } },
                 "required" => ["cmd"]
               })
      wire = built(t)
      expect(wire[:strict]).to be_nil
    end
  end

  # ── Strict mode normalisation ─────────────────────────────────────────────────

  describe "strict mode normalisation of optional params" do
    it "moves optional properties into required when strict: true is applied" do
      params = {
        "type" => "object",
        "properties" => {
          "cmd" => { "type" => "string" },
          "timeout" => { "type" => "integer" }
        },
        "required" => ["cmd"]
      }
      wire = built(tool(name: "bash", parameters: params))
      expect(wire[:strict]).to eq(true)
      # After normalisation, all props are required
      expect(wire[:input_schema]["required"]).to contain_exactly("cmd", "timeout")
    end

    it "wraps previously-optional properties as nullable in the schema" do
      params = {
        "type" => "object",
        "properties" => {
          "cmd" => { "type" => "string" },
          "timeout" => { "type" => "integer" }
        },
        "required" => ["cmd"]
      }
      wire = built(tool(name: "bash", parameters: params))
      timeout_schema = wire[:input_schema]["properties"]["timeout"]
      # Should be wrapped in anyOf [..., {type: "null"}]
      expect(timeout_schema).to have_key("anyOf")
      null_branch = timeout_schema["anyOf"].find { |s| s["type"] == "null" }
      expect(null_branch).not_to be_nil
    end
  end

  # ── Budget limits ────────────────────────────────────────────────────────────

  describe "strict mode budget limits" do
    it "does not set strict: true when optional param count exceeds MAX_STRICT_OPTIONAL_PARAMS" do
      props = (1..25).to_h do |i|
        ["opt_param_#{i}", { "type" => "string" }]
      end
      params = {
        "type" => "object",
        "properties" => props,
        "required" => [] # all optional
      }
      wire = built(tool(name: "bash", parameters: params))
      # 25 optional params > MAX_STRICT_OPTIONAL_PARAMS (24)
      expect(wire[:strict]).to be_nil
    end

    it "sets strict: true when optional param count is exactly at the limit" do
      props = (1..24).to_h do |i|
        ["opt_param_#{i}", { "type" => "string" }]
      end
      params = {
        "type" => "object",
        "properties" => props,
        "required" => [] # all optional — 24 is at the limit
      }
      wire = built(tool(name: "bash", parameters: params))
      expect(wire[:strict]).to eq(true)
    end
  end

  # ── Hash tool input ───────────────────────────────────────────────────────────

  describe "plain Hash tool definitions" do
    it "accepts tools as plain Hashes with symbol keys" do
      tool_hash = {
        name: "bash",
        description: "Run a bash command",
        parameters: {
          "type" => "object",
          "properties" => { "cmd" => { "type" => "string" } },
          "required" => ["cmd"]
        }
      }
      result = described_class.build([tool_hash]).first
      expect(result[:name]).to eq("bash")
      expect(result[:input_schema]).to be_a(Hash)
    end

    it "accepts tools as plain Hashes with string keys" do
      tool_hash = {
        "name" => "bash",
        "description" => "Run a bash command",
        "parameters" => {
          "type" => "object",
          "properties" => {},
          "required" => []
        }
      }
      result = described_class.build([tool_hash]).first
      expect(result[:name]).to eq("bash")
    end
  end

  # ── Multiple tools ───────────────────────────────────────────────────────────

  describe "multiple tools" do
    it "converts each tool independently" do
      tools = [
        tool(name: "bash"),
        tool(name: "grep"),
        tool(name: "python")
      ]
      wires = described_class.build(tools)
      expect(wires.map { |w| w[:name] }).to eq(%w[bash grep python])
    end

    it "only marks allowlist tools as strict" do
      tools = [
        tool(name: "bash"),
        tool(name: "grep"),
        tool(name: "find")
      ]
      wires = described_class.build(tools)
      names_with_strict = wires.select { |w| w[:strict] }.map { |w| w[:name] }
      expect(names_with_strict).to contain_exactly("bash", "find")
      expect(wires.find { |w| w[:name] == "grep" }[:strict]).to be_nil
    end
  end

  # ── Deep-clone safety ────────────────────────────────────────────────────────

  describe "deep-clone safety" do
    it "does not mutate the original parameters hash" do
      original_params = {
        "type" => "object",
        "properties" => { "cmd" => { "type" => "string" } }
      }
      t = tool(name: "grep", parameters: original_params)
      described_class.build([t])
      # Original should not have additionalProperties injected
      expect(original_params).not_to have_key("additionalProperties")
    end
  end
end
