# frozen_string_literal: true

require "webmock/rspec"

CHAT_STREAMING_SSE_DIR = File.expand_path("../../../fixtures/sse", __dir__)

RSpec.describe Dispatch::Adapter::Claude, "#chat (streaming integration)" do
  let(:model_id) { "claude-sonnet-4-5-20250929" }
  let(:base_url) { "https://api.anthropic.com" }

  subject(:adapter) do
    described_class.new(
      model: model_id,
      api_key: "sk-ant-api03-test",
      base_url: base_url
    )
  end

  let(:messages) do
    [Dispatch::Adapter::Message.new(
      role: "user",
      content: [Dispatch::Adapter::TextBlock.new(text: "Hello")]
    )]
  end

  before do
    WebMock.disable_net_connect!
    allow(adapter).to receive(:sleep)
  end

  after { WebMock.reset! }

  def load_sse(filename)
    File.read(File.join(CHAT_STREAMING_SSE_DIR, filename))
  end

  def stub_sse(body, status: 200)
    stub_request(:post, "#{base_url}/v1/messages")
      .to_return(
        status: status,
        body: body,
        headers: { "Content-Type" => "text/event-stream" }
      )
  end

  # ── Scenario 1: text-only stream ─────────────────────────────────────────

  describe "scenario 1: text-only stream (text-only.sse)" do
    before { stub_sse(load_sse("text-only.sse")) }

    it "returns a Response" do
      expect(adapter.chat(messages, stream: true)).to be_a(Dispatch::Adapter::Response)
    end

    it "yields :text_start, :text_delta, :text_delta, :text_end events in order" do
      deltas = []
      adapter.chat(messages, stream: true) { |d| deltas << d }
      expect(deltas.map(&:type)).to eq(%i[text_start text_delta text_delta text_end])
    end

    it "text deltas carry the expected text fragments" do
      deltas = []
      adapter.chat(messages, stream: true) { |d| deltas << d }
      text_delta_texts = deltas.select { |d| d.type == :text_delta }.map(&:text)
      expect(text_delta_texts).to eq(["Hello, ", "world!"])
    end

    it "concatenated text delta content equals the full response text" do
      deltas = []
      adapter.chat(messages, stream: true) { |d| deltas << d }
      full_text = deltas.select { |d| d.type == :text_delta }.map(&:text).join
      expect(full_text).to eq("Hello, world!")
    end

    it "Response has stop_reason :end_turn" do
      expect(adapter.chat(messages, stream: true).stop_reason).to eq(:end_turn)
    end

    it "Response usage.cost is a UsageCost" do
      cost = adapter.chat(messages, stream: true).usage.cost
      expect(cost).to be_a(Dispatch::Adapter::UsageCost)
    end

    it "Response usage.cost.total is positive" do
      cost = adapter.chat(messages, stream: true).usage.cost
      expect(cost.total).to be > 0
    end

    it "Response usage.input_tokens matches the fixture" do
      expect(adapter.chat(messages, stream: true).usage.input_tokens).to eq(15)
    end

    it "Response usage.output_tokens matches the fixture" do
      expect(adapter.chat(messages, stream: true).usage.output_tokens).to eq(8)
    end
  end

  # ── Scenario 2: tool_use stream ───────────────────────────────────────────

  describe "scenario 2: tool_use stream (tool-use.sse)" do
    before { stub_sse(load_sse("tool-use.sse")) }

    it "yields :tool_use_start, :tool_use_delta × 2, :tool_use_end in order" do
      deltas = []
      adapter.chat(messages, stream: true) { |d| deltas << d }
      expect(deltas.map(&:type)).to eq(%i[tool_use_start tool_use_delta tool_use_delta tool_use_end])
    end

    it ":tool_use_start delta carries the correct id and name" do
      deltas = []
      adapter.chat(messages, stream: true) { |d| deltas << d }
      start_delta = deltas.find { |d| d.type == :tool_use_start }
      expect(start_delta.tool_call_id).to eq("toolu_stream01")
      expect(start_delta.tool_name).to eq("bash")
    end

    it ":tool_use_delta events carry the correct argument fragments" do
      deltas = []
      adapter.chat(messages, stream: true) { |d| deltas << d }
      json_deltas = deltas.select { |d| d.type == :tool_use_delta }
      expect(json_deltas.map(&:argument_delta).join).to eq('{"command":"ls -la"}')
    end

    it "Response has stop_reason :tool_use" do
      expect(adapter.chat(messages, stream: true).stop_reason).to eq(:tool_use)
    end

    it "Response.tool_calls has exactly one entry" do
      response = adapter.chat(messages, stream: true)
      expect(response.tool_calls.length).to eq(1)
    end

    it "Response.tool_calls[0] is a ToolUseBlock" do
      tc = adapter.chat(messages, stream: true).tool_calls.first
      expect(tc).to be_a(Dispatch::Adapter::ToolUseBlock)
    end

    it "Response.tool_calls[0].arguments matches the parsed JSON" do
      tc = adapter.chat(messages, stream: true).tool_calls.first
      expect(tc.arguments).to eq({ "command" => "ls -la" })
    end

    it "Response.tool_calls[0].name is 'bash'" do
      tc = adapter.chat(messages, stream: true).tool_calls.first
      expect(tc.name).to eq("bash")
    end

    it "Response usage.cost.total is positive" do
      expect(adapter.chat(messages, stream: true).usage.cost.total).to be > 0
    end
  end

  # ── Scenario 3: thinking-then-text stream ────────────────────────────────

  describe "scenario 3: thinking-then-text stream (thinking-then-text.sse)" do
    before { stub_sse(load_sse("thinking-then-text.sse")) }

    it "yields :thinking_start, :thinking_delta, :thinking_end, :text_start, :text_delta, :text_end" do
      deltas = []
      adapter.chat(messages, stream: true) { |d| deltas << d }
      expect(deltas.map(&:type)).to eq(
        %i[thinking_start thinking_delta thinking_end text_start text_delta text_end]
      )
    end

    it ":thinking_delta carries the expected thinking text" do
      deltas = []
      adapter.chat(messages, stream: true) { |d| deltas << d }
      thinking_delta = deltas.find { |d| d.type == :thinking_delta }
      expect(thinking_delta.text).to eq("Let me think about this...")
    end

    it ":text_delta carries the expected text" do
      deltas = []
      adapter.chat(messages, stream: true) { |d| deltas << d }
      text_delta = deltas.find { |d| d.type == :text_delta }
      expect(text_delta.text).to eq("The answer is 42.")
    end

    it "Response has stop_reason :end_turn" do
      expect(adapter.chat(messages, stream: true).stop_reason).to eq(:end_turn)
    end

    it "Response.content has 2 items (thinking + text)" do
      expect(adapter.chat(messages, stream: true).content.length).to eq(2)
    end

    it "Response.content[0] is a ThinkingBlock" do
      content = adapter.chat(messages, stream: true).content
      expect(content[0]).to be_a(Dispatch::Adapter::ThinkingBlock)
    end

    it "Response.content[0].thinking has the correct text" do
      content = adapter.chat(messages, stream: true).content
      expect(content[0].thinking).to eq("Let me think about this...")
    end

    it "Response.content[1] is a TextBlock" do
      content = adapter.chat(messages, stream: true).content
      expect(content[1]).to be_a(Dispatch::Adapter::TextBlock)
    end

    it "Response.content[1].text has the correct text" do
      content = adapter.chat(messages, stream: true).content
      expect(content[1].text).to eq("The answer is 42.")
    end

    it "Response usage.cost.total is positive" do
      expect(adapter.chat(messages, stream: true).usage.cost.total).to be > 0
    end
  end

  # ── Scenario 4: truncated before message_start ───────────────────────────

  describe "scenario 4: truncated before message_start (truncated-before-message-start.sse)" do
    before do
      truncated_body = load_sse("truncated-before-message-start.sse")
      stub_request(:post, "#{base_url}/v1/messages")
        .to_return(
          status: 200,
          body: truncated_body,
          headers: { "Content-Type" => "text/event-stream" }
        ).times(4) # initial + 3 retries = 4 total, all fail before message_start
    end

    it "returns a Response with stop_reason :error after exhausting retries" do
      response = adapter.chat(messages, stream: true)
      expect(response.stop_reason).to eq(:error)
    end

    it "does not raise" do
      expect { adapter.chat(messages, stream: true) }.not_to raise_error
    end

    it "calls sleep exactly 3 times (one per retry)" do
      adapter.chat(messages, stream: true)
      expect(adapter).to have_received(:sleep).exactly(3).times
    end

    it "uses exponential back-off delays (2s, 4s, 8s)" do
      delays = []
      allow(adapter).to receive(:sleep) { |s| delays << s }
      adapter.chat(messages, stream: true)
      expect(delays).to eq([2.0, 4.0, 8.0])
    end
  end

  # ── Scenario 5: truncated mid-text ───────────────────────────────────────

  describe "scenario 5: truncated mid-text (truncated-mid-text.sse)" do
    before { stub_sse(load_sse("truncated-mid-text.sse")) }

    it "raises a RequestError (stream truncated after partial output)" do
      expect { adapter.chat(messages, stream: true) }
        .to raise_error(Dispatch::Adapter::RequestError, /invalid JSON|incomplete frame/i)
    end

    it "does NOT call sleep (no retry because consumer output was emitted)" do
      begin
        adapter.chat(messages, stream: true)
      rescue Dispatch::Adapter::RequestError
        nil
      end
      expect(adapter).not_to have_received(:sleep)
    end

    it "yields :text_start and :text_delta events before raising" do
      deltas = []
      begin
        adapter.chat(messages, stream: true) { |d| deltas << d }
      rescue Dispatch::Adapter::RequestError
        nil
      end
      types = deltas.map(&:type)
      expect(types).to include(:text_start, :text_delta)
    end
  end
end
