# frozen_string_literal: true

require "webmock/rspec"

# ── Helpers ────────────────────────────────────────────────────────────────────
module RequestBuilderSpecHelpers
  def msg(role, *blocks)
    Dispatch::Adapter::Message.new(role: role, content: blocks)
  end

  def text_msg(role, text)
    Dispatch::Adapter::Message.new(
      role: role,
      content: [Dispatch::Adapter::TextBlock.new(text: text)]
    )
  end

  def tool(name:, description: "A tool", params: nil)
    Dispatch::Adapter::ToolDefinition.new(
      name: name,
      description: description,
      parameters: params || {
        "type" => "object",
        "properties" => { "cmd" => { "type" => "string" } },
        "required" => ["cmd"]
      }
    )
  end

  def build(model_id:, messages:, system: nil, tools: [], is_oauth: false,
            stream: false, max_tokens: nil, thinking: nil, tool_choice: nil,
            cache_retention: nil, metadata: nil, disable_strict_tools: false,
            base_url: "https://api.anthropic.com")
    Dispatch::Adapter::Claude::RequestBuilder.build(
      model_id: model_id,
      messages: messages,
      system: system,
      tools: tools,
      is_oauth: is_oauth,
      base_url: base_url,
      stream: stream,
      max_tokens: max_tokens,
      thinking: thinking,
      tool_choice: tool_choice,
      cache_retention: cache_retention,
      metadata: metadata,
      disable_strict_tools: disable_strict_tools
    )
  end
end

RSpec.describe Dispatch::Adapter::Claude::RequestBuilder, ".build" do
  include RequestBuilderSpecHelpers

  let(:sonnet_id) { "claude-sonnet-4-5-20250929" }
  let(:opus47_id) { "claude-opus-4-7-20251018" }
  let(:haiku_id)  { "claude-3-5-haiku-20241022" }

  # ── Scenario 1: simple-text ──────────────────────────────────────────────────
  describe "scenario 1: simple-text (single user message, no tools, OAuth)" do
    let(:params) do
      build(
        model_id: sonnet_id,
        messages: [text_msg("user", "Hello!")],
        is_oauth: true
      )
    end

    it "has the correct model" do
      expect(params[:model]).to eq(sonnet_id)
    end

    it "has stream: false" do
      expect(params[:stream]).to be(false)
    end

    it "has a positive max_tokens" do
      expect(params[:max_tokens]).to be > 0
    end

    it "has one message with role user" do
      expect(params[:messages].length).to eq(1)
      expect(params[:messages][0][:role]).to eq("user")
    end

    it "user message content contains a text block" do
      content = params[:messages][0][:content]
      expect(content).to be_an(Array)
      text_block = content.find { |b| b[:type] == "text" }
      expect(text_block).not_to be_nil
      expect(text_block[:text]).to eq("Hello!")
    end

    it "has no tools key" do
      expect(params).not_to have_key(:tools)
    end

    it "has OAuth system blocks (billing + agent + user)" do
      expect(params[:system]).to be_an(Array)
      expect(params[:system].length).to eq(2) # billing + agent (no user system provided)
      expect(params[:system][0]["text"]).to start_with("x-anthropic-billing-header:")
    end

    it "has a metadata user_id in cloaking format" do
      uid = params.dig(:metadata, :user_id)
      expect(uid).not_to be_nil
      expect(Dispatch::Adapter::Claude::Cloaking.cloaking_user_id?(uid)).to be(true)
    end
  end

  # ── Scenario 2: with-tools-strict ───────────────────────────────────────────
  describe "scenario 2: with-tools-strict (2 strict-eligible + 1 non-strict tool)" do
    let(:strict_schema) do
      {
        "type" => "object",
        "properties" => { "cmd" => { "type" => "string" } },
        "required" => ["cmd"]
      }
    end

    let(:tools) do
      [
        Dispatch::Adapter::ToolDefinition.new(name: "bash", description: "Run bash", parameters: strict_schema),
        Dispatch::Adapter::ToolDefinition.new(name: "edit", description: "Edit file", parameters: strict_schema),
        Dispatch::Adapter::ToolDefinition.new(name: "grep", description: "Search text", parameters: strict_schema)
      ]
    end

    let(:params) do
      build(
        model_id: sonnet_id,
        messages: [text_msg("user", "Run it")],
        tools: tools,
        is_oauth: false
      )
    end

    it "includes all 3 tools" do
      expect(params[:tools].length).to eq(3)
    end

    it "sets strict: true for bash" do
      wire = params[:tools].find { |t| t[:name] == "bash" }
      expect(wire[:strict]).to eq(true)
    end

    it "sets strict: true for edit" do
      wire = params[:tools].find { |t| t[:name] == "edit" }
      expect(wire[:strict]).to eq(true)
    end

    it "does NOT set strict for grep" do
      wire = params[:tools].find { |t| t[:name] == "grep" }
      expect(wire[:strict]).to be_nil
    end

    it "sets additionalProperties: false on all tools" do
      params[:tools].each do |t|
        expect(t[:input_schema]["additionalProperties"]).to eq(false)
      end
    end
  end

  # ── Scenario 3: forced-tool-choice strips thinking ───────────────────────────
  describe "scenario 3: forced-tool-choice (tool_choice: {type: :tool, name: 'edit'} strips thinking)" do
    let(:tools) do
      [Dispatch::Adapter::ToolDefinition.new(
        name: "edit",
        description: "Edit",
        parameters: { "type" => "object", "properties" => {}, "required" => [] }
      )]
    end

    let(:params) do
      build(
        model_id: sonnet_id,
        messages: [text_msg("user", "Edit this")],
        tools: tools,
        thinking: { type: :enabled, budget_tokens: 4096 },
        tool_choice: { type: :tool, name: "edit" },
        is_oauth: false,
        max_tokens: 16_000
      )
    end

    it "does NOT include thinking key" do
      expect(params).not_to have_key(:thinking)
    end

    it "does NOT include output_config key" do
      expect(params).not_to have_key(:output_config)
    end

    it "includes tool_choice with type: 'tool'" do
      expect(params[:tool_choice]).to eq({ type: "tool", name: "edit" })
    end
  end

  # ── Scenario 4: assistant thinking roundtrip ─────────────────────────────────
  describe "scenario 4: assistant-thinking-roundtrip" do
    let(:messages) do
      [
        text_msg("user", "Think about this"),
        Dispatch::Adapter::Message.new(
          role: "assistant",
          content: [
            Dispatch::Adapter::ThinkingBlock.new(
              thinking: "I am reasoning...",
              signature: "sig-abc-123"
            ),
            Dispatch::Adapter::TextBlock.new(text: "My answer"),
            Dispatch::Adapter::ToolUseBlock.new(
              id: "toolu_01", name: "bash", arguments: { "cmd" => "ls" }
            )
          ]
        ),
        Dispatch::Adapter::Message.new(
          role: "user",
          content: [
            Dispatch::Adapter::ToolResultBlock.new(
              tool_use_id: "toolu_01",
              content: "file1.txt\nfile2.txt"
            )
          ]
        )
      ]
    end

    let(:params) do
      build(
        model_id: sonnet_id,
        messages: messages,
        is_oauth: false
      )
    end

    it "has 3 wire messages" do
      expect(params[:messages].length).to eq(3)
    end

    it "first message is user" do
      expect(params[:messages][0][:role]).to eq("user")
    end

    it "second message is assistant" do
      expect(params[:messages][1][:role]).to eq("assistant")
    end

    it "assistant message has thinking block" do
      content = params[:messages][1][:content]
      thinking = content.find { |b| b[:type] == "thinking" }
      expect(thinking).not_to be_nil
      expect(thinking[:thinking]).to eq("I am reasoning...")
      expect(thinking[:signature]).to eq("sig-abc-123")
    end

    it "assistant message has text block" do
      content = params[:messages][1][:content]
      text = content.find { |b| b[:type] == "text" }
      expect(text[:text]).to eq("My answer")
    end

    it "assistant message has tool_use block" do
      content = params[:messages][1][:content]
      tu = content.find { |b| b[:type] == "tool_use" }
      expect(tu[:name]).to eq("bash")
      expect(tu[:id]).to eq("toolu_01")
    end

    it "third message is user with tool_result" do
      msg = params[:messages][2]
      expect(msg[:role]).to eq("user")
      tr = msg[:content].find { |b| b[:type] == "tool_result" }
      expect(tr[:tool_use_id]).to eq("toolu_01")
    end
  end

  # ── Scenario 5: tool-results-batched ────────────────────────────────────────
  describe "scenario 5: tool-results-batched (3 consecutive tool-result messages)" do
    let(:messages) do
      [
        text_msg("user", "Do things"),
        Dispatch::Adapter::Message.new(
          role: "assistant",
          content: [
            Dispatch::Adapter::ToolUseBlock.new(id: "t1", name: "bash", arguments: { "cmd" => "ls" }),
            Dispatch::Adapter::ToolUseBlock.new(id: "t2", name: "bash", arguments: { "cmd" => "pwd" }),
            Dispatch::Adapter::ToolUseBlock.new(id: "t3", name: "bash", arguments: { "cmd" => "echo hi" })
          ]
        ),
        Dispatch::Adapter::Message.new(
          role: "user",
          content: [Dispatch::Adapter::ToolResultBlock.new(tool_use_id: "t1", content: "out1")]
        ),
        Dispatch::Adapter::Message.new(
          role: "user",
          content: [Dispatch::Adapter::ToolResultBlock.new(tool_use_id: "t2", content: "out2")]
        ),
        Dispatch::Adapter::Message.new(
          role: "user",
          content: [Dispatch::Adapter::ToolResultBlock.new(tool_use_id: "t3", content: "out3")]
        )
      ]
    end

    let(:params) do
      build(
        model_id: sonnet_id,
        messages: messages,
        is_oauth: false
      )
    end

    it "has exactly 3 wire messages (user, assistant, batched-user)" do
      expect(params[:messages].length).to eq(3)
    end

    it "the last wire message is role: user" do
      expect(params[:messages].last[:role]).to eq("user")
    end

    it "the batched user message contains all 3 tool_result blocks" do
      content = params[:messages].last[:content]
      tr_blocks = content.select { |b| b[:type] == "tool_result" }
      expect(tr_blocks.length).to eq(3)
    end

    it "the batched tool_results have the right tool_use_ids" do
      content = params[:messages].last[:content]
      ids = content.select { |b| b[:type] == "tool_result" }.map { |b| b[:tool_use_id] }
      expect(ids).to contain_exactly("t1", "t2", "t3")
    end
  end

  # ── Scenario 6: cache-retention-long ────────────────────────────────────────
  describe "scenario 6: cache-retention-long (last user message gets ttl: '1h')" do
    let(:tool_with_desc) do
      Dispatch::Adapter::ToolDefinition.new(
        name: "bash",
        description: "Run bash",
        parameters: { "type" => "object", "properties" => { "cmd" => { "type" => "string" } }, "required" => ["cmd"] }
      )
    end

    let(:params) do
      build(
        model_id: sonnet_id,
        messages: [text_msg("user", "Hello")],
        system: "Be helpful.",
        tools: [tool_with_desc],
        is_oauth: false,
        cache_retention: :long,
        base_url: "https://api.anthropic.com"
      )
    end

    it "the last system block has cache_control with type: ephemeral" do
      last_system = params[:system]&.last
      expect(last_system).not_to be_nil
      cc = last_system["cache_control"]
      expect(cc).not_to be_nil
      expect(cc["type"]).to eq("ephemeral")
    end

    it "either system or tool or messages has a cache_control with ttl: '1h'" do
      # find any cache_control with ttl 1h anywhere in the body
      all_ccs = Array(params[:system]).map { |b| b["cache_control"] }
      Array(params[:tools]).each { |t| all_ccs << t[:cache_control] }
      Array(params[:messages]).each do |m|
        Array(m[:content]).each { |b| all_ccs << b[:cache_control] }
      end
      all_ccs.compact!
      expect(all_ccs.any? { |cc| cc && cc["ttl"] == "1h" }).to be(true)
    end
  end

  # ── Scenario 7: image-block stripped when vision not supported ───────────────
  describe "scenario 7: image-block-stripped-when-vision-off" do
    let(:vision_off_model_id) { "claude-text-only-fake" }
    let(:vision_off_model_info) do
      Dispatch::Adapter::ModelInfo.new(
        id: vision_off_model_id,
        name: "Text Only",
        max_context_tokens: 200_000,
        supports_vision: false,
        supports_tool_use: true,
        supports_streaming: true,
        pricing: nil
      )
    end

    let(:messages) do
      [
        Dispatch::Adapter::Message.new(
          role: "user",
          content: [
            Dispatch::Adapter::TextBlock.new(text: "Describe this image"),
            Dispatch::Adapter::ImageBlock.new(
              source: "base64data",
              media_type: "image/png"
            )
          ]
        )
      ]
    end

    before do
      allow(Dispatch::Adapter::Claude::ModelCatalog).to receive(:build)
        .with(vision_off_model_id)
        .and_return(vision_off_model_info)
    end

    let(:params) do
      build(
        model_id: vision_off_model_id,
        messages: messages,
        is_oauth: false
      )
    end

    it "strips the image block from the user message" do
      content = params[:messages][0][:content]
      types = content.map { |b| b[:type] }
      expect(types).not_to include("image")
    end

    it "retains the text block" do
      content = params[:messages][0][:content]
      text_block = content.find { |b| b[:type] == "text" }
      expect(text_block[:text]).to eq("Describe this image")
    end
  end

  # ── Scenario 8: opus-4.7-adaptive thinking ───────────────────────────────────
  describe "scenario 8: opus-4.7-adaptive (thinking: 'high' → adaptive + output_config)" do
    let(:params) do
      build(
        model_id: opus47_id,
        messages: [text_msg("user", "Think hard")],
        thinking: "high",
        is_oauth: false
      )
    end

    it "sets thinking type to adaptive" do
      expect(params[:thinking]).not_to be_nil
      expect(params[:thinking][:type]).to eq("adaptive")
    end

    it "sets thinking display to summarized" do
      expect(params[:thinking][:display]).to eq("summarized")
    end

    it "sets output_config with effort: high" do
      expect(params[:output_config]).not_to be_nil
      expect(params[:output_config][:effort]).to eq("high")
    end
  end

  # ── Scenario 9: haiku-no-agent-instruction ───────────────────────────────────
  describe "scenario 9: haiku-no-agent-instruction (system: billing + user only)" do
    let(:params) do
      build(
        model_id: haiku_id,
        messages: [text_msg("user", "Hello")],
        system: "Be concise.",
        is_oauth: true
      )
    end

    it "has exactly 2 system blocks (billing + user)" do
      expect(params[:system].length).to eq(2)
    end

    it "first block is billing header" do
      expect(params[:system][0]["text"]).to start_with("x-anthropic-billing-header:")
    end

    it "second block is user system text" do
      expect(params[:system][1]["text"]).to eq("Be concise.")
    end

    it "does NOT include an agent instruction block" do
      texts = params[:system].map { |b| b["text"] }
      expect(texts.any? { |t| t.include?("Claude agent") }).to be(false)
    end
  end

  # ── Scenario 10: api-key-mode-no-cloaking ────────────────────────────────────
  describe "scenario 10: api-key-mode-no-cloaking" do
    let(:tools) do
      [
        Dispatch::Adapter::ToolDefinition.new(
          name: "my_custom_tool",
          description: "Custom",
          parameters: { "type" => "object", "properties" => {}, "required" => [] }
        )
      ]
    end

    let(:params) do
      build(
        model_id: sonnet_id,
        messages: [text_msg("user", "Go")],
        system: "System prompt.",
        tools: tools,
        is_oauth: false
      )
    end

    it "has exactly 1 system block" do
      expect(params[:system].length).to eq(1)
    end

    it "system block is just the user system" do
      expect(params[:system][0]["text"]).to eq("System prompt.")
    end

    it "tool name is NOT proxy-prefixed" do
      tool_wire = params[:tools][0]
      expect(tool_wire[:name]).to eq("my_custom_tool")
    end

    it "does NOT include metadata user_id" do
      expect(params.dig(:metadata, :user_id)).to be_nil
    end

    it "system block has no billing header" do
      expect(params[:system][0]["text"]).not_to start_with("x-anthropic-billing-header:")
    end
  end
end
