defmodule ExMCP.ACP.Adapters.ClaudeTest do
  use ExUnit.Case, async: true

  alias ExMCP.ACP.Adapters.Claude

  setup do
    {:ok, state} = Claude.init([])
    %{state: state}
  end

  describe "command/1" do
    test "returns claude with stream-json args" do
      {cmd, args} = Claude.command([])

      assert cmd == "claude"
      assert "--output-format" in args
      assert "stream-json" in args
      assert "--input-format" in args
      assert "--verbose" in args
      # 2026-06-06: default permission_mode is `nil` (no flag); callers
      # that need the historical bypass behavior must opt in explicitly.
      refute "--dangerously-skip-permissions" in args
      refute "--permission-mode" in args
    end

    test "includes model when specified" do
      {_cmd, args} = Claude.command(model: "sonnet")
      assert "--model" in args
      assert "sonnet" in args
    end

    test "includes system prompt when specified" do
      {_cmd, args} = Claude.command(system_prompt: "You are helpful")
      assert "--system-prompt" in args
      assert "You are helpful" in args
    end

    test "includes session resume" do
      {_cmd, args} = Claude.command(session_id: "sess_abc")
      assert "--resume" in args
      assert "sess_abc" in args
    end

    test "uses custom cli_path" do
      {cmd, _args} = Claude.command(cli_path: "/custom/claude")
      assert cmd == "/custom/claude"
    end

    test "includes thinking budget" do
      {_cmd, args} = Claude.command(max_thinking_tokens: 20_000)
      assert "--max-thinking-tokens" in args
      assert "20000" in args
    end

    # Permission control (added 2026-06-06). The pre-2026-06-06 adapter
    # hardcoded `--dangerously-skip-permissions` with no opt-out, which
    # meant programmatic Claude CLI sessions ran with the CLI's
    # permission system fully disabled. These tests guard the new
    # configurable surface (and ensure the default still matches the
    # historical behavior).
    test "permission_mode defaults to nil (safe-by-default 2026-06-06)" do
      {_cmd, args} = Claude.command([])
      refute "--dangerously-skip-permissions" in args
      refute "--permission-mode" in args
    end

    test "permission_mode :bypass passes the dangerously-skip flag explicitly" do
      {_cmd, args} = Claude.command(permission_mode: :bypass)
      assert "--dangerously-skip-permissions" in args
      refute "--permission-mode" in args
    end

    test "permission_mode :default → --permission-mode default" do
      {_cmd, args} = Claude.command(permission_mode: :default)
      assert "--permission-mode" in args
      assert "default" in args
      refute "--dangerously-skip-permissions" in args
    end

    test "permission_mode :accept_edits → --permission-mode acceptEdits (camelCase)" do
      {_cmd, args} = Claude.command(permission_mode: :accept_edits)
      assert "acceptEdits" in args
      refute "accept_edits" in args
    end

    test "permission_mode :plan → --permission-mode plan" do
      {_cmd, args} = Claude.command(permission_mode: :plan)
      assert "plan" in args
    end

    test "permission_mode :auto → --permission-mode auto" do
      {_cmd, args} = Claude.command(permission_mode: :auto)
      assert "auto" in args
    end

    test "permission_mode :dont_ask → --permission-mode dontAsk (camelCase)" do
      {_cmd, args} = Claude.command(permission_mode: :dont_ask)
      assert "dontAsk" in args
      refute "dont_ask" in args
    end

    test "permission_mode :bypass_permissions → --permission-mode bypassPermissions" do
      {_cmd, args} = Claude.command(permission_mode: :bypass_permissions)
      assert "bypassPermissions" in args
      refute "--dangerously-skip-permissions" in args
    end

    test "permission_mode as binary passes through verbatim" do
      {_cmd, args} = Claude.command(permission_mode: "customMode")
      assert "--permission-mode" in args
      assert "customMode" in args
    end

    test "permission_mode nil passes neither flag" do
      {_cmd, args} = Claude.command(permission_mode: nil)
      refute "--dangerously-skip-permissions" in args
      refute "--permission-mode" in args
    end

    test "permission_mode invalid raises" do
      assert_raise ArgumentError, fn ->
        Claude.command(permission_mode: :nope)
      end
    end

    test "allowed_tools list joins on commas" do
      {_cmd, args} = Claude.command(allowed_tools: ["WebSearch", "WebFetch"])
      assert "--allowed-tools" in args
      assert "WebSearch,WebFetch" in args
    end

    test "disallowed_tools list joins on commas" do
      {_cmd, args} = Claude.command(disallowed_tools: ["Write", "Edit", "MultiEdit"])
      assert "--disallowed-tools" in args
      assert "Write,Edit,MultiEdit" in args
    end

    test "allowed_tools empty list omits flag" do
      {_cmd, args} = Claude.command(allowed_tools: [])
      refute "--allowed-tools" in args
    end

    test "allowed_tools as binary passes through verbatim" do
      {_cmd, args} = Claude.command(allowed_tools: "WebSearch")
      assert "--allowed-tools" in args
      assert "WebSearch" in args
    end

    test "combined: dont_ask + allow + disallow" do
      {_cmd, args} =
        Claude.command(
          permission_mode: :dont_ask,
          allowed_tools: ["WebSearch"],
          disallowed_tools: ["Write", "Edit"]
        )

      assert "--permission-mode" in args
      assert "dontAsk" in args
      assert "--allowed-tools" in args
      assert "WebSearch" in args
      assert "--disallowed-tools" in args
      assert "Write,Edit" in args
      refute "--dangerously-skip-permissions" in args
    end
  end

  describe "capabilities/0" do
    test "advertises image prompts because Claude forwards image blocks" do
      caps = Claude.capabilities()

      assert caps["promptCapabilities"]["image"] == true
    end

    test "keeps adapter metadata under _meta" do
      caps = Claude.capabilities()
      assert caps["_meta"]["ex_mcp.claude"]["streaming"] == true
    end
  end

  describe "config_options/0" do
    test "does not advertise non-stable dynamic config options" do
      assert Claude.config_options() == []
    end
  end

  describe "session/set_config_option" do
    test "stores model preference", %{state: state} do
      msg = %{
        "method" => "session/set_config_option",
        "params" => %{"configId" => "model", "value" => "opus"}
      }

      assert {:ok, :skip, new_state} = Claude.translate_outbound(msg, state)
      assert new_state.model == "opus"
    end

    test "skips unknown config options", %{state: state} do
      msg = %{
        "method" => "session/set_config_option",
        "params" => %{"configId" => "unknown", "value" => "x"}
      }

      assert {:ok, :skip, _state} = Claude.translate_outbound(msg, state)
    end
  end

  describe "translate_outbound/2" do
    test "skips initialize", %{state: state} do
      msg = %{"method" => "initialize", "id" => 1, "params" => %{}}
      assert {:ok, :skip, _state} = Claude.translate_outbound(msg, state)
    end

    test "skips session/new", %{state: state} do
      msg = %{"method" => "session/new", "id" => 2, "params" => %{}}
      assert {:ok, :skip, _state} = Claude.translate_outbound(msg, state)
    end

    test "converts session/prompt to stdin NDJSON", %{state: state} do
      msg = %{
        "method" => "session/prompt",
        "id" => 3,
        "params" => %{
          "sessionId" => "sess_001",
          "prompt" => [%{"type" => "text", "text" => "Hello Claude"}]
        }
      }

      assert {:ok, data, new_state} = Claude.translate_outbound(msg, state)

      json_str = IO.iodata_to_binary(data)
      assert String.ends_with?(json_str, "\n")

      parsed = Jason.decode!(String.trim(json_str))
      assert parsed["type"] == "user"
      assert parsed["message"]["role"] == "user"
      assert parsed["message"]["content"] == "Hello Claude"
      assert parsed["session_id"] == "sess_001"
      assert new_state.pending_prompt_id == 3
    end

    # spec regression: ACP spec defines five `ContentBlock` types — text,
    # image, audio, resource_link, embedded resource
    # (https://agentclientprotocol.com/protocol/prompt-turn). The previous
    # `extract_prompt_text/1` implementation kept only `type == "text"`,
    # silently dropping image/audio/resource blocks. Same bug class as
    # the original `permission_mode` regression: caller supplies value,
    # adapter returns `:ok`, wire carries something different.
    #
    # The right behavior for Claude depends on what its CLI supports —
    # Claude Code's stream-json input accepts images, but not audio. So
    # the contract this test pins down: image blocks MUST be forwarded
    # (Claude can handle them); audio/resource blocks MUST return an
    # error (Claude cannot — silent drop is the bug).
    test "spec regression: session/prompt with image block forwards it (Claude supports images)",
         %{state: state} do
      msg = %{
        "method" => "session/prompt",
        "id" => 21,
        "params" => %{
          "sessionId" => "s1",
          "prompt" => [
            %{"type" => "text", "text" => "What do you see?"},
            %{
              "type" => "image",
              "mimeType" => "image/png",
              "data" => "base64encodedimagedata"
            }
          ]
        }
      }

      result = Claude.translate_outbound(msg, state)
      assert {:ok, data, _state} = result

      json_str = IO.iodata_to_binary(data)
      # Claude CLI stream-json input format supports multimodal content.
      # The image data must appear somewhere in the forwarded payload.
      refute is_nil(json_str)

      assert json_str =~ "base64encodedimagedata",
             "Claude CLI supports image content blocks in stream-json input — adapter " <>
               "must forward image blocks, not silently drop them. " <>
               "See https://agentclientprotocol.com/protocol/prompt-turn for ContentBlock types."
    end

    test "spec regression: session/prompt with audio block surfaces an error (Claude can't handle audio)",
         %{state: state} do
      msg = %{
        "method" => "session/prompt",
        "id" => 22,
        "params" => %{
          "sessionId" => "s1",
          "prompt" => [
            %{"type" => "text", "text" => "Transcribe:"},
            %{"type" => "audio", "mimeType" => "audio/wav", "data" => "base64audiodata"}
          ]
        }
      }

      # Claude does NOT advertise promptCapabilities.audio. Per spec,
      # capabilities omitted in initialize MUST be treated as unsupported —
      # so callers SHOULDN'T send audio blocks. But the adapter must still
      # behave correctly when they do: surface an error rather than silently
      # dropping content the caller will assume was sent.
      result = Claude.translate_outbound(msg, state)

      case result do
        {:ok, data, _state} ->
          json_str = IO.iodata_to_binary(data)

          refute String.contains?(json_str, "\"Transcribe:\"") and
                   not String.contains?(json_str, "base64audiodata"),
                 "Audio block silently dropped. Adapter forwarded text but not the audio. " <>
                   "Either forward audio (Claude doesn't support it) or return {:error, _, _}."

        {:error, _reason, _state} ->
          # Acceptable: caller learns the prompt couldn't be sent as-is.
          :ok

        other ->
          flunk("Unexpected translate_outbound result: #{inspect(other)}")
      end
    end

    test "resets accumulators on new prompt", %{state: state} do
      # Simulate accumulated state
      state = %{state | text_acc: ["old text"], thinking_acc: ["old think"]}

      msg = %{
        "method" => "session/prompt",
        "id" => 4,
        "params" => %{
          "sessionId" => "s1",
          "prompt" => [%{"type" => "text", "text" => "new prompt"}]
        }
      }

      {:ok, _data, new_state} = Claude.translate_outbound(msg, state)
      assert new_state.text_acc == []
      assert new_state.thinking_acc == []
    end
  end

  describe "translate_inbound/2 — text deltas" do
    test "text_delta produces session/update", %{state: state} do
      line =
        Jason.encode!(%{
          "type" => "stream_event",
          "event" => %{
            "type" => "content_block_delta",
            "delta" => %{"type" => "text_delta", "text" => "Hello "}
          }
        })

      assert {:messages, [msg], new_state} = Claude.translate_inbound(line, state)

      assert msg["method"] == "session/update"
      assert msg["params"]["update"]["sessionUpdate"] == "agent_message_chunk"
      assert msg["params"]["update"]["content"] == %{"type" => "text", "text" => "Hello "}
      assert new_state.text_acc == ["Hello "]
    end

    test "accumulates text across deltas", %{state: state} do
      lines = [
        %{
          "type" => "stream_event",
          "event" => %{
            "type" => "content_block_delta",
            "delta" => %{"type" => "text_delta", "text" => "Hello "}
          }
        },
        %{
          "type" => "stream_event",
          "event" => %{
            "type" => "content_block_delta",
            "delta" => %{"type" => "text_delta", "text" => "world"}
          }
        }
      ]

      state =
        Enum.reduce(lines, state, fn event, acc ->
          line = Jason.encode!(event)

          case Claude.translate_inbound(line, acc) do
            {:messages, _msgs, new_state} -> new_state
            {:skip, new_state} -> new_state
          end
        end)

      # text_acc is in reverse order (iodata prepend pattern)
      text = IO.iodata_to_binary(Enum.reverse(state.text_acc))
      assert text == "Hello world"
    end
  end

  describe "translate_inbound/2 — thinking deltas" do
    test "thinking_delta produces session/update with kind thinking", %{state: state} do
      line =
        Jason.encode!(%{
          "type" => "stream_event",
          "event" => %{
            "type" => "content_block_delta",
            "delta" => %{"type" => "thinking_delta", "thinking" => "Let me think..."}
          }
        })

      assert {:messages, [msg], new_state} = Claude.translate_inbound(line, state)

      assert msg["params"]["update"]["sessionUpdate"] == "agent_thought_chunk"

      assert msg["params"]["update"]["content"] == %{
               "type" => "text",
               "text" => "Let me think..."
             }

      assert new_state.current_block_type == :thinking
    end
  end

  describe "translate_inbound/2 — content block lifecycle" do
    test "content_block_start sets block type", %{state: state} do
      line =
        Jason.encode!(%{
          "type" => "stream_event",
          "event" => %{
            "type" => "content_block_start",
            "content_block" => %{"type" => "thinking"}
          }
        })

      assert {:skip, new_state} = Claude.translate_inbound(line, state)
      assert new_state.current_block_type == :thinking
    end

    test "content_block_stop finalizes thinking block", %{state: state} do
      # Accumulate some thinking
      state = %{state | thinking_acc: ["some thinking"], current_block_type: :thinking}

      line =
        Jason.encode!(%{
          "type" => "stream_event",
          "event" => %{"type" => "content_block_stop"}
        })

      assert {:skip, new_state} = Claude.translate_inbound(line, state)
      assert new_state.thinking_acc == []
      assert length(new_state.thinking_blocks) == 1
      assert hd(new_state.thinking_blocks).text == "some thinking"
    end
  end

  describe "translate_inbound/2 — assistant message" do
    test "extracts thinking blocks with dedup", %{state: state} do
      # First, accumulate thinking from streaming
      state = %{
        state
        | thinking_blocks: [%{type: :thinking, text: "Already seen", signature: nil}]
      }

      line =
        Jason.encode!(%{
          "type" => "assistant",
          "message" => %{
            "id" => "msg_123",
            "model" => "claude-sonnet-4-20250514",
            "content" => [
              %{"type" => "thinking", "thinking" => "Already seen", "signature" => "sig1"},
              %{"type" => "thinking", "thinking" => "New thinking", "signature" => "sig2"},
              %{"type" => "text", "text" => "Response text"}
            ]
          }
        })

      assert {:messages, notifications, new_state} = Claude.translate_inbound(line, state)

      # Text block emits an agent_message_chunk notification
      assert length(notifications) == 1

      assert %{"method" => "session/update", "params" => %{"update" => update}} =
               hd(notifications)

      assert update["sessionUpdate"] == "agent_message_chunk"

      # "Already seen" should be deduped, "New thinking" should be added
      assert length(new_state.thinking_blocks) == 2
      assert new_state.session_id == "msg_123"
      assert new_state.model == "claude-sonnet-4-20250514"
    end
  end

  describe "translate_inbound/2 — result event" do
    test "produces prompt response with usage", %{state: state} do
      state = %{state | text_acc: ["world", "Hello "], pending_prompt_id: 42, session_id: "s1"}

      line =
        Jason.encode!(%{
          "type" => "result",
          "session_id" => "s1",
          "usage" => %{
            "input_tokens" => 100,
            "output_tokens" => 50,
            "cache_read_input_tokens" => 10
          }
        })

      assert {:messages, messages, new_state} = Claude.translate_inbound(line, state)

      # Should have status update and prompt result
      assert length(messages) >= 1

      # Find the prompt result
      result_msg = Enum.find(messages, &(&1["id"] == 42))
      assert result_msg != nil
      assert result_msg["result"]["stopReason"] == "end_turn"
      assert result_msg["result"]["_meta"]["ex_mcp"]["text"] == "Hello world"
      assert result_msg["result"]["usage"]["inputTokens"] == 100
      assert result_msg["result"]["usage"]["outputTokens"] == 50

      # session_id surfaces in the result so callers can correlate the
      # prompt response with the underlying Claude SDK session.
      assert result_msg["result"]["_meta"]["ex_mcp"]["sessionId"] == "s1"

      # Pending prompt ID cleared
      assert new_state.pending_prompt_id == nil
    end

    test "omits sessionId when state.session_id is nil", %{state: state} do
      state = %{state | text_acc: ["x"], pending_prompt_id: 99, session_id: nil}

      line = Jason.encode!(%{"type" => "result", "usage" => %{}})

      assert {:messages, messages, _state} = Claude.translate_inbound(line, state)

      result_msg = Enum.find(messages, &(&1["id"] == 99))
      refute Map.has_key?(result_msg["result"], "sessionId")
    end

    test "includes thinking in result when available", %{state: state} do
      state = %{
        state
        | text_acc: ["text"],
          thinking_blocks: [%{type: :thinking, text: "I thought about it", signature: "sig"}],
          pending_prompt_id: 10
      }

      line = Jason.encode!(%{"type" => "result", "usage" => %{}})

      assert {:messages, messages, _state} = Claude.translate_inbound(line, state)

      result_msg = Enum.find(messages, &(&1["id"] == 10))
      assert result_msg["result"]["_meta"]["ex_mcp"]["thinking"] != nil
      assert length(result_msg["result"]["_meta"]["ex_mcp"]["thinking"]) == 1
    end

    test "handles error result", %{state: state} do
      state = %{state | text_acc: [], pending_prompt_id: 11}

      line = Jason.encode!(%{"type" => "result", "is_error" => true, "usage" => %{}})

      assert {:messages, messages, _state} = Claude.translate_inbound(line, state)

      result_msg = Enum.find(messages, &(&1["id"] == 11))
      assert result_msg["result"]["stopReason"] == "refusal"
    end

    # spec regression: the ACP spec
    # (https://agentclientprotocol.com/protocol/prompt-turn) defines
    # `sessionUpdate: "usage_update"` with `used`/`size`/`cost?` fields —
    # NOT `"usage"` with `inputTokens`/`outputTokens`. The previous
    # implementation emitted a non-spec discriminator alongside the result
    # message, which other ACP clients (TypeScript reference, Zed, etc.)
    # cannot recognize.
    #
    # The input/output token billing data is already surfaced in the prompt
    # response result's `usage` extension (Pi does this; Claude does too at
    # result_msg["result"]["usage"]). The separate `sessionUpdate: "usage"`
    # notification is duplicate AND non-spec.
    test "spec regression: result event does not emit non-spec usage sessionUpdate", %{
      state: state
    } do
      state = %{state | text_acc: ["hi"], pending_prompt_id: 77, session_id: "s1"}

      line =
        Jason.encode!(%{
          "type" => "result",
          "session_id" => "s1",
          "usage" => %{
            "input_tokens" => 100,
            "output_tokens" => 50,
            "cache_read_input_tokens" => 10
          }
        })

      assert {:messages, messages, _state} = Claude.translate_inbound(line, state)

      # Every session/update notification emitted must use a spec-stable
      # discriminator. "usage" is not in the spec discriminator set;
      # "usage_update" is.
      for msg <- messages, msg["method"] == "session/update" do
        discriminator = msg["params"]["update"]["sessionUpdate"]

        refute discriminator == "usage",
               "Non-spec sessionUpdate discriminator emitted. Spec defines " <>
                 "\"usage_update\" with used/size/cost? — see " <>
                 "https://agentclientprotocol.com/protocol/prompt-turn. " <>
                 "Token billing data belongs in result[\"usage\"], not a separate session/update."

        if discriminator == "usage_update" do
          update = msg["params"]["update"]
          assert Map.has_key?(update, "used"), "usage_update requires `used` per spec"
          assert Map.has_key?(update, "size"), "usage_update requires `size` per spec"
        end
      end
    end
  end

  describe "translate_inbound/2 — multi-turn tool use" do
    test "tool_use content block emits tool_call notification", %{state: state} do
      state = %{state | session_id: "s1"}

      line =
        Jason.encode!(%{
          "type" => "assistant",
          "message" => %{
            "content" => [
              %{
                "type" => "tool_use",
                "id" => "toolu_123",
                "name" => "Grep",
                "input" => %{"pattern" => "defmodule"}
              }
            ]
          }
        })

      assert {:messages, [notification], new_state} = Claude.translate_inbound(line, state)
      assert notification["method"] == "session/update"

      update = notification["params"]["update"]
      assert update["sessionUpdate"] == "tool_call_update"
      assert update["title"] == "Search: defmodule"
      assert update["toolCallId"] == "toolu_123"
      assert update["status"] == "in_progress"
      assert update["kind"] == "search"
      # Pattern is in content, not flat
      assert [%{"type" => "content", "content" => %{"type" => "text", "text" => "defmodule"}}] =
               update["content"]

      assert new_state.in_tool_use == true
    end

    test "user event with tool_result emits tool_result notification", %{state: state} do
      state = %{state | session_id: "s1", in_tool_use: true}

      line =
        Jason.encode!(%{
          "type" => "user",
          "message" => %{
            "content" => [
              %{
                "type" => "tool_result",
                "tool_use_id" => "toolu_123",
                "content" => "Found 3 matches"
              }
            ]
          }
        })

      assert {:messages, [notification], _state} = Claude.translate_inbound(line, state)
      update = notification["params"]["update"]
      assert update["sessionUpdate"] == "tool_call_update"
      assert update["toolCallId"] == "toolu_123"
      assert update["status"] == "completed"
    end

    test "Read tool includes filePath metadata", %{state: state} do
      state = %{state | session_id: "s1"}

      line =
        Jason.encode!(%{
          "type" => "assistant",
          "message" => %{
            "content" => [
              %{
                "type" => "tool_use",
                "id" => "toolu_read",
                "name" => "Read",
                "input" => %{"file_path" => "/src/main.ex"}
              }
            ]
          }
        })

      assert {:messages, [notification], _state} = Claude.translate_inbound(line, state)
      update = notification["params"]["update"]
      assert update["title"] == "Read main.ex"
      assert update["kind"] == "read"
      # File path is in locations
      assert [%{"path" => "/src/main.ex", "line" => 1}] = update["locations"]
    end

    test "Bash tool includes command metadata", %{state: state} do
      state = %{state | session_id: "s1"}

      line =
        Jason.encode!(%{
          "type" => "assistant",
          "message" => %{
            "content" => [
              %{
                "type" => "tool_use",
                "id" => "toolu_bash",
                "name" => "Bash",
                "input" => %{"command" => "mix test --only fast"}
              }
            ]
          }
        })

      assert {:messages, [notification], _state} = Claude.translate_inbound(line, state)
      update = notification["params"]["update"]
      # Bash title is the command itself (matching Zed)
      assert update["title"] == "mix test --only fast"
      assert update["kind"] == "execute"
      # Terminal content with ID for streaming
      assert [%{"type" => "terminal", "terminalId" => "toolu_bash"}] = update["content"]
    end

    test "Write tool includes diff content", %{state: state} do
      state = %{state | session_id: "s1"}

      line =
        Jason.encode!(%{
          "type" => "assistant",
          "message" => %{
            "content" => [
              %{
                "type" => "tool_use",
                "id" => "toolu_write",
                "name" => "Write",
                "input" => %{"file_path" => "/src/new.ex", "content" => "defmodule New do\nend"}
              }
            ]
          }
        })

      assert {:messages, [notification], _state} = Claude.translate_inbound(line, state)
      update = notification["params"]["update"]
      assert update["kind"] == "edit"

      assert [
               %{
                 "type" => "diff",
                 "path" => "/src/new.ex",
                 "oldText" => nil,
                 "newText" => content
               }
             ] = update["content"]

      assert content =~ "defmodule New"
      assert [%{"path" => "/src/new.ex"}] = update["locations"]
    end

    test "Edit tool includes diff with old and new text", %{state: state} do
      state = %{state | session_id: "s1"}

      line =
        Jason.encode!(%{
          "type" => "assistant",
          "message" => %{
            "content" => [
              %{
                "type" => "tool_use",
                "id" => "toolu_edit",
                "name" => "Edit",
                "input" => %{
                  "file_path" => "/src/app.ex",
                  "old_string" => "def old",
                  "new_string" => "def new"
                }
              }
            ]
          }
        })

      assert {:messages, [notification], _state} = Claude.translate_inbound(line, state)
      update = notification["params"]["update"]
      assert update["kind"] == "edit"

      assert [%{"type" => "diff", "oldText" => "def old", "newText" => "def new"}] =
               update["content"]
    end

    test "Read with offset includes line range in title", %{state: state} do
      state = %{state | session_id: "s1"}

      line =
        Jason.encode!(%{
          "type" => "assistant",
          "message" => %{
            "content" => [
              %{
                "type" => "tool_use",
                "id" => "toolu_read2",
                "name" => "Read",
                "input" => %{"file_path" => "/src/app.ex", "offset" => 10, "limit" => 20}
              }
            ]
          }
        })

      assert {:messages, [notification], _state} = Claude.translate_inbound(line, state)
      update = notification["params"]["update"]
      assert update["title"] == "Read app.ex (10-29)"
      assert [%{"path" => "/src/app.ex", "line" => 10}] = update["locations"]
    end

    test "display_path uses relative path when cwd is set", %{state: _state} do
      {:ok, state} = Claude.init(cwd: "/home/user/project")
      state = %{state | session_id: "s1"}

      line =
        Jason.encode!(%{
          "type" => "assistant",
          "message" => %{
            "content" => [
              %{
                "type" => "tool_use",
                "id" => "toolu_rel",
                "name" => "Read",
                "input" => %{"file_path" => "/home/user/project/lib/app.ex"}
              }
            ]
          }
        })

      assert {:messages, [notification], _state} = Claude.translate_inbound(line, state)
      update = notification["params"]["update"]
      assert update["title"] == "Read lib/app.ex"
    end

    test "tool_result with error has failed status", %{state: state} do
      state = %{state | session_id: "s1", in_tool_use: true}

      line =
        Jason.encode!(%{
          "type" => "user",
          "message" => %{
            "content" => [
              %{
                "type" => "tool_result",
                "tool_use_id" => "toolu_err",
                "content" => "File not found",
                "is_error" => true
              }
            ]
          }
        })

      assert {:messages, [notification], _state} = Claude.translate_inbound(line, state)
      update = notification["params"]["update"]
      assert update["status"] == "failed"
      assert update["_meta"]["ex_mcp"]["isError"] == true
    end

    test "assistant after tool use clears text_acc for final answer", %{state: state} do
      # Simulate state after tool_use cycle — text_acc might have stale text
      state = %{state | session_id: "s1", in_tool_use: true, text_acc: ["stale"]}

      line =
        Jason.encode!(%{
          "type" => "assistant",
          "message" => %{
            "content" => [
              %{"type" => "text", "text" => "The final answer"}
            ]
          }
        })

      assert {:messages, _notifications, new_state} = Claude.translate_inbound(line, state)
      assert new_state.text_acc == ["The final answer"]
      assert new_state.in_tool_use == false
    end

    test "full multi-turn tool use sequence produces correct result", %{state: state} do
      state = %{state | session_id: "s1", pending_prompt_id: 42}

      # Step 1: assistant with thinking
      line1 =
        Jason.encode!(%{
          "type" => "assistant",
          "message" => %{
            "content" => [
              %{"type" => "thinking", "thinking" => "I need to search...", "signature" => nil}
            ]
          }
        })

      {:skip, state} = Claude.translate_inbound(line1, state)

      # Step 2: assistant with tool_use
      line2 =
        Jason.encode!(%{
          "type" => "assistant",
          "message" => %{
            "content" => [
              %{
                "type" => "tool_use",
                "id" => "toolu_abc",
                "name" => "Grep",
                "input" => %{"pattern" => "trust_profile"}
              }
            ]
          }
        })

      {:messages, _tool_notifs, state} = Claude.translate_inbound(line2, state)
      assert state.in_tool_use == true

      # Step 3: user with tool_result
      line3 =
        Jason.encode!(%{
          "type" => "user",
          "message" => %{
            "content" => [
              %{
                "type" => "tool_result",
                "tool_use_id" => "toolu_abc",
                "content" => "trust_profile.ex:5: defmodule TrustProfile"
              }
            ]
          }
        })

      {:messages, _result_notifs, state} = Claude.translate_inbound(line3, state)

      # Step 4: assistant with final text answer
      line4 =
        Jason.encode!(%{
          "type" => "assistant",
          "message" => %{
            "content" => [
              %{"type" => "text", "text" => "I found the TrustProfile module."}
            ]
          }
        })

      {:messages, _text_notifs, state} = Claude.translate_inbound(line4, state)
      assert state.in_tool_use == false
      assert state.text_acc == ["I found the TrustProfile module."]

      # Step 5: result event
      line5 =
        Jason.encode!(%{
          "type" => "result",
          "session_id" => "s1",
          "result" => "I found the TrustProfile module.",
          "usage" => %{"input_tokens" => 200, "output_tokens" => 30}
        })

      {:messages, messages, final_state} = Claude.translate_inbound(line5, state)
      assert final_state.pending_prompt_id == nil

      response = Enum.find(messages, &(&1["id"] == 42))
      assert response["result"]["_meta"]["ex_mcp"]["text"] == "I found the TrustProfile module."
      assert response["result"]["stopReason"] == "end_turn"
    end

    test "user event without tool_result content is skipped", %{state: state} do
      line =
        Jason.encode!(%{
          "type" => "user",
          "message" => %{"content" => "plain text"}
        })

      assert {:skip, ^state} = Claude.translate_inbound(line, state)
    end
  end

  describe "translate_inbound/2 — non-JSON" do
    test "skips non-JSON lines", %{state: state} do
      assert {:skip, ^state} = Claude.translate_inbound("not json at all", state)
    end

    test "system events emit status notification", %{state: state} do
      line = Jason.encode!(%{"type" => "system", "message" => "hello"})
      assert {:messages, [notification], _state} = Claude.translate_inbound(line, state)
      assert notification["params"]["update"]["sessionUpdate"] == "session_info_update"
      assert notification["params"]["update"]["_meta"]["ex_mcp"]["message"] == "hello"
    end

    test "skips truly unknown event types", %{state: state} do
      line = Jason.encode!(%{"type" => "unknown_xyz", "data" => "test"})
      assert {:skip, ^state} = Claude.translate_inbound(line, state)
    end
  end
end
