# frozen_string_literal: true

require "webmock/rspec"

RSpec.describe Dispatch::Adapter::Claude::HttpClient do
  let(:base_url)     { "https://api.anthropic.com" }
  let(:test_headers) { { "Content-Type" => "application/json", "Accept" => "application/json" } }
  let(:headers_proc) do
    lambda { |stream: false|
      _ = stream
      test_headers
    }
  end

  subject(:client) { described_class.new(base_url: base_url, headers_proc: headers_proc) }

  before { WebMock.disable_net_connect! }
  after  { WebMock.reset! }

  # ── post_json ─────────────────────────────────────────────────────────────

  describe "#post_json" do
    let(:path)         { "/v1/messages" }
    let(:request_body) { { model: "claude-sonnet-4-6", messages: [] } }
    let(:response_body) do
      { "id" => "msg_01", "type" => "message", "content" => [], "role" => "assistant" }
    end

    context "on a 200 response" do
      before do
        stub_request(:post, "#{base_url}#{path}")
          .to_return(status: 200, body: JSON.generate(response_body),
                     headers: { "Content-Type" => "application/json" })
      end

      it "returns the parsed JSON response body" do
        result = client.post_json(path, request_body)
        expect(result).to eq(response_body)
      end

      it "sends the body serialized as JSON" do
        client.post_json(path, request_body)
        expect(WebMock).to have_requested(:post, "#{base_url}#{path}")
          .with(body: JSON.generate(request_body))
      end

      it "sends the headers from the headers_proc" do
        client.post_json(path, request_body)
        expect(WebMock).to have_requested(:post, "#{base_url}#{path}")
          .with(headers: { "Content-Type" => "application/json" })
      end
    end

    context "on a 401 response" do
      before do
        stub_request(:post, "#{base_url}#{path}")
          .to_return(status: 401,
                     body: JSON.generate({ "error" => { "message" => "Unauthorized" } }),
                     headers: { "Content-Type" => "application/json" })
      end

      it "raises AuthenticationError" do
        expect { client.post_json(path, request_body) }
          .to raise_error(Dispatch::Adapter::AuthenticationError)
      end

      it "includes the error message" do
        expect { client.post_json(path, request_body) }
          .to raise_error(Dispatch::Adapter::AuthenticationError, /Unauthorized/)
      end
    end

    context "on a 429 response" do
      before do
        stub_request(:post, "#{base_url}#{path}")
          .to_return(status: 429,
                     body: JSON.generate({ "error" => { "message" => "Rate limited" } }),
                     headers: { "Content-Type" => "application/json", "Retry-After" => "30" })
      end

      it "raises RateLimitError" do
        expect { client.post_json(path, request_body) }
          .to raise_error(Dispatch::Adapter::RateLimitError)
      end
    end

    context "on a 500 response" do
      before do
        stub_request(:post, "#{base_url}#{path}")
          .to_return(status: 500,
                     body: JSON.generate({ "error" => { "message" => "Internal error" } }),
                     headers: { "Content-Type" => "application/json" })
      end

      it "raises ServerError" do
        expect { client.post_json(path, request_body) }
          .to raise_error(Dispatch::Adapter::ServerError)
      end
    end

    context "with malformed JSON response" do
      before do
        stub_request(:post, "#{base_url}#{path}")
          .to_return(status: 200, body: "not json at all",
                     headers: { "Content-Type" => "text/plain" })
      end

      it "raises RequestError" do
        expect { client.post_json(path, request_body) }
          .to raise_error(Dispatch::Adapter::RequestError, /Failed to parse JSON/)
      end
    end
  end

  # ── get_json ──────────────────────────────────────────────────────────────

  describe "#get_json" do
    let(:path) { "/v1/models" }
    let(:response_body) { { "data" => [{ "id" => "claude-sonnet-4-6" }] } }

    context "on a 200 response" do
      before do
        stub_request(:get, "#{base_url}#{path}")
          .to_return(status: 200, body: JSON.generate(response_body),
                     headers: { "Content-Type" => "application/json" })
      end

      it "returns the parsed JSON response body" do
        result = client.get_json(path)
        expect(result).to eq(response_body)
      end

      it "uses a GET request (not POST)" do
        client.get_json(path)
        expect(WebMock).to have_requested(:get, "#{base_url}#{path}")
        expect(WebMock).not_to have_requested(:post, "#{base_url}#{path}")
      end
    end

    context "on a 401 response" do
      before do
        stub_request(:get, "#{base_url}#{path}")
          .to_return(status: 401,
                     body: JSON.generate({ "error" => { "message" => "Unauthorized" } }),
                     headers: { "Content-Type" => "application/json" })
      end

      it "raises AuthenticationError" do
        expect { client.get_json(path) }
          .to raise_error(Dispatch::Adapter::AuthenticationError)
      end
    end
  end

  # ── stream ────────────────────────────────────────────────────────────────

  describe "#stream" do
    let(:path)         { "/v1/messages" }
    let(:request_body) { { model: "claude-sonnet-4-6", stream: true } }
    let(:sse_body)     { "data: {\"type\":\"message_start\"}\n\ndata: [DONE]\n\n" }

    context "on a 200 SSE response" do
      before do
        stub_request(:post, "#{base_url}#{path}")
          .to_return(status: 200, body: sse_body,
                     headers: { "Content-Type" => "text/event-stream" })
      end

      it "yields a Net::HTTPResponse object" do
        yielded = nil
        client.stream(path, request_body) { |resp| yielded = resp }
        expect(yielded).to be_a(Net::HTTPResponse)
      end

      it "yields a response that responds to read_body" do
        client.stream(path, request_body) do |resp|
          expect(resp).to respond_to(:read_body)
        end
      end

      it "yields the response before it raises" do
        was_yielded = false
        client.stream(path, request_body) { |_resp| was_yielded = true }
        expect(was_yielded).to be true
      end

      it "sends a POST request with Accept: text/event-stream" do
        stream_headers = { "Content-Type" => "application/json", "Accept" => "text/event-stream" }
        stream_headers_proc = ->(stream: false) { stream ? stream_headers : test_headers }
        stream_client = described_class.new(base_url: base_url, headers_proc: stream_headers_proc)
        stream_client.stream(path, request_body) { |_r| nil }
        expect(WebMock).to have_requested(:post, "#{base_url}#{path}")
          .with(headers: { "Accept" => "text/event-stream" })
      end
    end

    context "when called without a block" do
      it "raises ArgumentError" do
        expect { client.stream(path, request_body) }
          .to raise_error(ArgumentError, /block/)
      end
    end

    context "on a 401 SSE response" do
      before do
        stub_request(:post, "#{base_url}#{path}")
          .to_return(status: 401,
                     body: JSON.generate({ "error" => { "message" => "Unauthorized" } }),
                     headers: { "Content-Type" => "application/json" })
      end

      it "raises AuthenticationError after yielding the response" do
        yielded = false
        expect do
          client.stream(path, request_body) { |_r| yielded = true }
        end.to raise_error(Dispatch::Adapter::AuthenticationError)
        expect(yielded).to be true
      end
    end
  end

  # ── Connection failures ───────────────────────────────────────────────────

  describe "connection failures" do
    let(:path)         { "/v1/messages" }
    let(:request_body) { {} }

    {
      "Errno::ECONNREFUSED" => Errno::ECONNREFUSED,
      "Errno::EHOSTUNREACH" => Errno::EHOSTUNREACH,
      "Errno::ETIMEDOUT" => Errno::ETIMEDOUT,
      "SocketError" => SocketError
    }.each do |error_name, error_class|
      context "when #{error_name} is raised" do
        before do
          stub_request(:post, "#{base_url}#{path}").to_raise(error_class)
        end

        it "raises ConnectionError" do
          expect { client.post_json(path, request_body) }
            .to raise_error(Dispatch::Adapter::ConnectionError)
        end

        it "includes the provider name in the error message" do
          expect { client.post_json(path, request_body) }
            .to raise_error(Dispatch::Adapter::ConnectionError,
                            /Anthropic \(Claude\)/)
        end

        it "sets the provider attribute on the error" do
          client.post_json(path, request_body)
        rescue Dispatch::Adapter::ConnectionError => e
          expect(e.provider).to eq("Anthropic (Claude)")
        end
      end
    end

    context "when Net::ReadTimeout is raised" do
      before do
        stub_request(:post, "#{base_url}#{path}").to_raise(Net::ReadTimeout)
      end

      it "raises ConnectionError" do
        expect { client.post_json(path, request_body) }
          .to raise_error(Dispatch::Adapter::ConnectionError)
      end
    end

    context "when SocketError is raised on get_json" do
      before do
        stub_request(:get, "#{base_url}/v1/models").to_raise(SocketError)
      end

      it "raises ConnectionError" do
        expect { client.get_json("/v1/models") }
          .to raise_error(Dispatch::Adapter::ConnectionError)
      end
    end
  end

  # ── Path normalization ────────────────────────────────────────────────────

  describe "path normalization" do
    it "adds a leading slash if the path does not have one" do
      stub_request(:get, "#{base_url}/v1/models")
        .to_return(status: 200, body: "{}", headers: {})
      # Should not raise — path is normalized correctly
      expect { client.get_json("v1/models") }.not_to raise_error
    end

    it "preserves an existing leading slash" do
      stub_request(:get, "#{base_url}/v1/models")
        .to_return(status: 200, body: "{}", headers: {})
      expect { client.get_json("/v1/models") }.not_to raise_error
    end
  end

  # ── base_url trailing slash tolerance ─────────────────────────────────────

  describe "base_url with trailing slash" do
    subject(:client_trailing) do
      described_class.new(base_url: "https://api.anthropic.com/",
                          headers_proc: headers_proc)
    end

    it "still connects to the correct host" do
      stub_request(:get, "https://api.anthropic.com/v1/models")
        .to_return(status: 200, body: "{}", headers: {})
      expect { client_trailing.get_json("/v1/models") }.not_to raise_error
    end
  end

  # ── Timeout constants ─────────────────────────────────────────────────────

  describe "timeout constants" do
    it "has OPEN_TIMEOUT of 30" do
      expect(described_class::OPEN_TIMEOUT).to eq(30)
    end

    it "has READ_TIMEOUT of 120" do
      expect(described_class::READ_TIMEOUT).to eq(120)
    end

    it "has STREAM_TIMEOUT of 300" do
      expect(described_class::STREAM_TIMEOUT).to eq(300)
    end
  end
end
