defmodule BullX.LLM.Providers.Anthropic do
  @moduledoc """
  Provider implementation for Anthropic Claude models.

  Supports Claude 3 models including:
  - claude-sonnet-4-5-20250929
  - claude-3-5-haiku-20241022
  - claude-3-opus-20240229

  ## Key Differences from OpenAI

  - Uses `/v1/messages` endpoint instead of `/chat/completions`
  - Different authentication: `x-api-key` header instead of `Authorization: Bearer`
  - Different message format with content blocks
  - Different response structure with top-level `role` and `content`
  - System messages are included in the messages array, not separate
  - Tool calls use different format with content blocks

  ## Usage

      iex> ReqLLM.generate_text("anthropic:claude-sonnet-4-5-20250929", "Hello!")
      {:ok, response}

  """

  use ReqLLM.Provider,
    id: :anthropic,
    default_base_url: "https://api.anthropic.com",
    default_env_key: "ANTHROPIC_API_KEY"

  import ReqLLM.Provider.Utils, only: [maybe_put: 3, ensure_parsed_body: 1]

  require Logger

  @provider_schema [
    access_token: [
      type: :string,
      doc: "OAuth access token used as Authorization Bearer credential"
    ],
    auth_mode: [
      type: {:in, [:api_key, :oauth]},
      default: :api_key,
      doc: "Authentication mode: :api_key (default) or :oauth"
    ],
    oauth_file: [
      type: :string,
      doc: "Path to an oauth/auth JSON file with provider credentials"
    ],
    auth_file: [
      type: :string,
      doc: "Alias for :oauth_file"
    ],
    oauth_http_options: [
      type: {:list, :any},
      doc: "Req options for OAuth refresh HTTP requests"
    ],
    with_claude_subscription: [
      type: :boolean,
      default: false,
      doc: "Enable Claude Pro/Max subscription compatibility for Anthropic OAuth requests"
    ],
    anthropic_top_k: [
      type: :pos_integer,
      doc: "Sample from the top K options for each subsequent token (1-40)"
    ],
    anthropic_version: [
      type: :string,
      doc: "Anthropic API version to use",
      default: "2023-06-01"
    ],
    stop_sequences: [
      type: {:list, :string},
      doc: "Custom sequences that will cause the model to stop generating"
    ],
    anthropic_metadata: [
      type: :map,
      doc: "Optional metadata to include with the request"
    ],
    thinking: [
      type: :map,
      doc:
        "Enable thinking/reasoning for supported models (e.g. %{type: \"enabled\", budget_tokens: 4096})"
    ],
    anthropic_prompt_cache: [
      type: :boolean,
      doc: "Enable Anthropic prompt caching"
    ],
    anthropic_prompt_cache_ttl: [
      type: :string,
      doc: "TTL for cache (\"1h\" for one hour; omit for default ~5m)"
    ],
    anthropic_cache_messages: [
      type: {:or, [:boolean, :integer]},
      doc: """
      Add cache breakpoint at a message position (requires anthropic_prompt_cache: true).
      - `true` or `-1` - last message
      - `-2` - second-to-last, `-3` - third-to-last, etc.
      - `0` - first message, `1` - second, etc.
      """
    ],
    anthropic_structured_output_mode: [
      type: {:in, [:auto, :json_schema, :tool_strict]},
      default: :auto,
      doc: """
      Strategy for structured output generation:
      - `:auto` - Use json_schema when supported (default)
      - `:json_schema` - Force output_format with json_schema
      - `:tool_strict` - Force strict: true on function tools
      """
    ],
    output_format: [
      type: :map,
      doc: "Internal use: structured output format configuration"
    ],
    output_config: [
      type: :map,
      doc: "Internal use: Anthropic output configuration"
    ],
    anthropic_beta: [
      type: {:list, :string},
      doc: "Internal use: beta feature flags"
    ],
    web_search: [
      type: :map,
      doc: """
      Enable web search tool with optional configuration:
      - `max_uses` - Limit the number of searches per request (integer)
      - `allowed_domains` - List of domains to include (list of strings)
      - `blocked_domains` - List of domains to exclude (list of strings)
      - `user_location` - Map with keys: type, city, region, country, timezone

      Example: %{max_uses: 5, allowed_domains: ["example.com"]}
      """
    ],
    web_fetch: [
      type: :map,
      doc: """
      Enable web fetch tool (web_fetch_20260209) with optional configuration:
      - `max_uses` - Limit the number of fetches per request (integer)
      - `allowed_domains` - List of domains to include (list of strings)
      - `blocked_domains` - List of domains to exclude (list of strings)
      - `max_content_tokens` - Maximum content length in tokens (integer)
      - `citations` - Map with `:enabled` boolean for source citations

      Example: %{max_uses: 3, allowed_domains: ["example.com"]}
      """
    ]
  ]

  @req_keys ~w(
    context operation text stream model provider_options
  )a

  @body_options ~w(
    temperature top_p stop_sequences thinking output_config
  )a

  @unsupported_parameters ~w(
    presence_penalty frequency_penalty logprobs top_logprobs response_format
  )a

  @default_anthropic_version "2023-06-01"
  @anthropic_beta_tools "tools-2024-05-16"
  @anthropic_beta_prompt_caching "prompt-caching-2024-07-31"
  @anthropic_beta_files_api "files-api-2025-04-14"
  @claude_subscription_betas ["oauth-2025-04-20", "interleaved-thinking-2025-05-14"]
  @claude_subscription_user_agent "claude-cli/2.1.112 (external, cli)"
  @claude_subscription_x_app "claude-code"
  @claude_subscription_identity "You are a Claude agent, built on Anthropic's Claude Agent SDK."
  @claude_subscription_billing_salt "59cf53e54c78"
  @claude_subscription_billing_positions [4, 7, 20]
  @claude_subscription_code_version "2.1.112"
  @claude_subscription_entrypoint "sdk-cli"

  # Canonical reasoning effort token budgets for Anthropic models
  # These values are used across all providers hosting Anthropic models
  @reasoning_budget_minimal 512
  @reasoning_budget_low 1_024
  @reasoning_budget_medium 2_048
  @reasoning_budget_high 4_096
  @reasoning_budget_xhigh 8_192

  @spec list_models(keyword()) :: {:ok, [BullX.LLM.ModelDescriptor.t()]} | {:error, term()}
  def list_models(opts) when is_list(opts) do
    BullX.LLM.ProviderModelDiscovery.list_anthropic(
      Keyword.put(opts, :base_url, Keyword.get(opts, :base_url) || default_base_url())
    )
  end

  @impl ReqLLM.Provider
  def prepare_request(:chat, model_spec, prompt, opts) do
    with {:ok, model} <- ReqLLM.model(model_spec),
         {:ok, context} <- ReqLLM.Context.normalize(prompt, opts),
         opts_with_context = Keyword.put(opts, :context, context),
         {:ok, processed_opts} <-
           ReqLLM.Provider.Options.process(__MODULE__, :chat, model, opts_with_context) do
      http_opts = Keyword.get(processed_opts, :req_http_options, [])

      req_keys = supported_provider_options() ++ @req_keys

      default_timeout =
        if Keyword.has_key?(processed_opts, :thinking) do
          Application.get_env(:req_llm, :thinking_timeout, 300_000)
        else
          Application.get_env(:req_llm, :receive_timeout, 120_000)
        end

      timeout = Keyword.get(processed_opts, :receive_timeout, default_timeout)

      base_url = Keyword.get(processed_opts, :base_url, base_url())

      request =
        Req.new(
          [
            base_url: base_url,
            url: "/v1/messages",
            method: :post,
            receive_timeout: timeout,
            pool_timeout: timeout
          ] ++ http_opts
        )
        |> Req.Request.register_options(req_keys)
        |> Req.Request.merge_options(
          Keyword.take(processed_opts, req_keys) ++ [model: get_api_model_id(model)]
        )
        |> attach(model, processed_opts)

      {:ok, request}
    end
  end

  @impl ReqLLM.Provider
  def prepare_request(:object, model_spec, prompt, opts) do
    compiled_schema = Keyword.fetch!(opts, :compiled_schema)
    {:ok, model} = ReqLLM.model(model_spec)

    with :ok <- validate_object_context(prompt) do
      mode = determine_output_mode(model, opts)

      case mode do
        :json_schema ->
          prepare_json_schema_request(model_spec, prompt, compiled_schema, opts)

        :tool_strict ->
          prepare_strict_tool_request(model_spec, prompt, compiled_schema, opts)
      end
    end
  end

  @impl ReqLLM.Provider
  def prepare_request(operation, _model_spec, _input, _opts) do
    supported_operations = [:chat, :object]

    {:error,
     ReqLLM.Error.Invalid.Parameter.exception(
       parameter:
         "operation: #{inspect(operation)} not supported by #{inspect(__MODULE__)}. Supported operations: #{inspect(supported_operations)}"
     )}
  end

  defp prepare_json_schema_request(model_spec, prompt, compiled_schema, opts) do
    json_schema = ReqLLM.Schema.to_json(compiled_schema.schema)

    json_schema =
      json_schema
      |> strip_constraints_recursive()
      |> enforce_strict_schema_requirements()

    opts_with_format =
      opts
      |> Keyword.update(
        :provider_options,
        [
          anthropic_beta: ["structured-outputs-2025-11-13"],
          output_format: %{
            type: "json_schema",
            schema: json_schema
          }
        ],
        fn provider_opts ->
          provider_opts
          |> Keyword.update(:anthropic_beta, ["structured-outputs-2025-11-13"], fn betas ->
            ["structured-outputs-2025-11-13" | betas]
          end)
          |> Keyword.put(:output_format, %{
            type: "json_schema",
            schema: json_schema
          })
        end
      )
      |> ReqLLM.Provider.Options.put_model_max_tokens_default(model_spec, fallback: 4096)
      |> Keyword.put(:operation, :object)

    prepare_request(:chat, model_spec, prompt, opts_with_format)
  end

  defp validate_object_context(prompt) do
    with {:ok, context} <- ReqLLM.Context.normalize(prompt, []) do
      case last_non_system_message(context) do
        %{role: :assistant} ->
          {:error,
           ReqLLM.Error.Invalid.Parameter.exception(
             parameter:
               "Anthropic generate_object/4 does not support contexts ending with an assistant message. Append a user message requesting the structured output."
           )}

        _message ->
          :ok
      end
    end
  end

  defp last_non_system_message(%ReqLLM.Context{messages: messages}) do
    messages
    |> Enum.reject(&(&1.role == :system))
    |> List.last()
  end

  @spec prepare_strict_tool_request(LLMDB.Model.t() | String.t(), any(), any(), keyword()) ::
          {:ok, Req.Request.t()} | {:error, any()}
  defp prepare_strict_tool_request(model_spec, prompt, compiled_schema, opts) do
    schema =
      compiled_schema.schema
      |> ReqLLM.Schema.to_json()
      |> strip_constraints_recursive()
      |> enforce_strict_schema_requirements()

    case ReqLLM.Tool.new(
           name: "structured_output",
           description: "Generate structured output matching the provided schema",
           parameter_schema: schema,
           strict: true,
           callback: fn _args -> {:ok, "structured output generated"} end
         ) do
      {:ok, structured_output_tool} ->
        opts_with_tool =
          opts
          |> Keyword.update(:tools, [structured_output_tool], &[structured_output_tool | &1])
          |> Keyword.put(:tool_choice, %{type: "tool", name: "structured_output"})
          |> ReqLLM.Provider.Options.put_model_max_tokens_default(model_spec, fallback: 4096)
          |> Keyword.put(:operation, :object)

        prepare_request(:chat, model_spec, prompt, opts_with_tool)

      {:error, _} = error ->
        error
    end
  end

  @impl ReqLLM.Provider
  def attach(request, model, user_opts) do
    # Validate provider compatibility
    if model.provider != :anthropic do
      raise ReqLLM.Error.Invalid.Provider.exception(provider: model.provider)
    end

    credential = ReqLLM.Auth.resolve!(model, user_opts)
    extra_option_keys = ReqLLM.Provider.Defaults.extra_option_keys(__MODULE__)

    request
    |> Req.Request.register_options(extra_option_keys ++ [:anthropic_version, :anthropic_beta])
    |> Req.Request.put_header("content-type", "application/json")
    |> put_auth_headers(credential)
    |> put_subscription_headers(credential, user_opts)
    |> put_subscription_beta_param(credential, user_opts)
    |> Req.Request.put_header("anthropic-version", get_anthropic_version(user_opts))
    |> Req.Request.put_private(:req_llm_model, model)
    |> Req.Request.put_private(
      :req_llm_claude_subscription?,
      claude_subscription?(credential, user_opts)
    )
    |> put_beta_header(user_opts, credential)
    |> Req.Request.merge_options(
      ReqLLM.Provider.Defaults.finch_option(request) ++
        [model: get_api_model_id(model)] ++ user_opts
    )
    |> ReqLLM.Step.Error.attach()
    |> ReqLLM.Step.Retry.attach(user_opts)
    |> Req.Request.prepend_request_steps(llm_encode_body: &encode_body/1)
    |> Req.Request.append_response_steps(llm_decode_response: &decode_response/1)
    |> ReqLLM.Step.Usage.attach(model)
    |> ReqLLM.Step.Telemetry.attach(model, user_opts)
    |> ReqLLM.Step.Fixture.maybe_attach(model, user_opts)
    |> Req.Request.put_private(:req_llm_model, model)
  end

  @impl ReqLLM.Provider
  def encode_body(request) do
    context = request.options[:context]
    model_name = request.options[:model]
    opts = request.options
    operation = request.options[:operation] || :chat

    model =
      Req.Request.get_private(request, :req_llm_model) ||
        %{
          id: model_name,
          model: model_name,
          provider: :anthropic,
          provider_model_id: nil
        }

    context = ReqLLM.ToolCallIdCompat.apply_context(__MODULE__, operation, model, context, opts)

    body =
      context
      |> build_request_body(model_name, opts)
      |> shape_subscription_body(request)

    request
    |> put_in([Access.key!(:options), :json], ReqLLM.Schema.apply_property_ordering(body))
  end

  @impl ReqLLM.Provider
  def decode_response({request, response}) do
    case response.status do
      status when status in 200..299 ->
        decode_success_response(request, response)

      status ->
        decode_error_response(request, response, status)
    end
  end

  @impl ReqLLM.Provider
  def extract_usage(body, _model) when is_map(body) do
    case body do
      %{"usage" => usage} ->
        usage = maybe_add_anthropic_tool_usage(usage)
        {:ok, usage}

      _ ->
        {:error, :no_usage_found}
    end
  end

  def extract_usage(_, _), do: {:error, :invalid_body}

  defp maybe_add_anthropic_tool_usage(usage) when is_map(usage) do
    server_tool_use = Map.get(usage, "server_tool_use", %{})

    web_search = Map.get(server_tool_use, "web_search_requests")

    web_fetch = Map.get(server_tool_use, "web_fetch_requests")

    usage
    |> maybe_put_tool_usage(:web_search, web_search)
    |> maybe_put_tool_usage(:web_fetch, web_fetch)
  end

  defp maybe_add_anthropic_tool_usage(usage), do: usage

  # ========================================================================
  # Shared Request Building Helpers (used by both Req and Finch paths)
  # ========================================================================

  defp build_request_headers(opts, credential) do
    [
      {"content-type", "application/json"}
      | auth_header_list(credential)
    ] ++
      subscription_header_list(credential, opts) ++
      [{"anthropic-version", get_anthropic_version(opts)}]
  end

  defp put_auth_headers(request, credential) do
    Enum.reduce(auth_header_list(credential), request, fn {name, value}, req ->
      Req.Request.put_header(req, name, value)
    end)
  end

  defp put_subscription_headers(request, credential, opts) do
    if claude_subscription?(credential, opts) do
      request
      |> Req.Request.put_header("user-agent", @claude_subscription_user_agent)
      |> Req.Request.put_header("x-app", @claude_subscription_x_app)
    else
      request
    end
  end

  defp put_subscription_beta_param(request, credential, opts) do
    if claude_subscription?(credential, opts) do
      params = put_request_param(request.options[:params], :beta, "true")
      Req.Request.merge_options(request, params: params)
    else
      request
    end
  end

  defp put_request_param(nil, key, value), do: [{key, value}]

  defp put_request_param(params, key, value) when is_list(params) do
    Keyword.put(params, key, value)
  end

  defp put_request_param(params, key, value) when is_map(params) do
    Map.put(params, key, value)
  end

  defp auth_header_list(%{kind: :oauth_access_token, token: token}) do
    [{"authorization", "Bearer #{token}"}]
  end

  defp auth_header_list(%{kind: :api_key, token: token}) do
    [{"x-api-key", token}]
  end

  defp subscription_header_list(credential, opts) do
    if claude_subscription?(credential, opts) do
      [{"user-agent", @claude_subscription_user_agent}, {"x-app", @claude_subscription_x_app}]
    else
      []
    end
  end

  defp build_request_body(context, model_name, opts) do
    # Use Anthropic-specific context encoding
    body_data =
      BullX.LLM.Providers.Anthropic.Context.encode_request(context, %{model: model_name})

    # Ensure max_tokens is always present (required by Anthropic)
    max_tokens =
      case get_option(opts, :max_tokens) do
        nil -> default_max_tokens(model_name)
        v -> v
      end

    body_data
    |> add_basic_options(opts)
    |> maybe_put(:stream, get_option(opts, :stream))
    |> Map.put(:max_tokens, max_tokens)
    |> maybe_add_tools(opts)
    |> maybe_apply_prompt_caching(opts)
    |> maybe_add_output_format(opts)
  end

  defp shape_subscription_body(body, %Req.Request{} = request) do
    case Req.Request.get_private(request, :req_llm_claude_subscription?) do
      true -> do_shape_subscription_body(body)
      _ -> body
    end
  end

  defp shape_subscription_body(body, {credential, opts}) do
    if claude_subscription?(credential, opts) do
      do_shape_subscription_body(body)
    else
      body
    end
  end

  defp do_shape_subscription_body(body) do
    system = map_value(body, :system)
    messages = map_value(body, :messages) || []

    Map.put(body, :system, subscription_system_blocks(system, messages))
  end

  defp subscription_system_blocks(system, messages) do
    blocks = system_blocks(system)
    blocks = prepend_subscription_identity(blocks)

    case subscription_billing_header(messages) do
      nil ->
        blocks

      billing_header ->
        prepend_subscription_billing_header(blocks, billing_header)
    end
  end

  defp system_blocks(nil), do: []
  defp system_blocks(system) when is_list(system), do: Enum.map(system, &text_block/1)
  defp system_blocks(system), do: [text_block(system)]

  defp text_block(block) when is_binary(block), do: %{type: "text", text: block}

  defp text_block(block) when is_map(block) do
    block
    |> Map.put(:type, "text")
    |> Map.put(:text, block_text(block))
  end

  defp text_block(block), do: %{type: "text", text: to_string(block)}

  defp prepend_subscription_identity(blocks) do
    if Enum.any?(blocks, &(block_text(&1) == @claude_subscription_identity)) do
      blocks
    else
      [%{type: "text", text: @claude_subscription_identity} | blocks]
    end
  end

  defp prepend_subscription_billing_header(blocks, billing_header) do
    if Enum.any?(
         blocks,
         &String.contains?(block_text(&1), "x-anthropic-billing-header:")
       ) do
      blocks
    else
      [%{type: "text", text: billing_header} | blocks]
    end
  end

  defp subscription_billing_header(messages) when is_list(messages) do
    case first_subscription_user_text(messages) do
      "" ->
        nil

      text ->
        cch = sha256_prefix(text, 5)

        sampled =
          Enum.map_join(
            @claude_subscription_billing_positions,
            "",
            &(String.at(text, &1) || "0")
          )

        suffix =
          sha256_prefix(
            @claude_subscription_billing_salt <> sampled <> @claude_subscription_code_version,
            3
          )

        "x-anthropic-billing-header: cc_version=#{@claude_subscription_code_version}.#{suffix}; cc_entrypoint=#{@claude_subscription_entrypoint}; cch=#{cch};"
    end
  end

  defp subscription_billing_header(_messages), do: nil

  defp first_subscription_user_text(messages) do
    messages
    |> Enum.find(&(subscription_message_role(&1) == "user"))
    |> subscription_message_text()
  end

  defp subscription_message_role(message) when is_map(message) do
    map_value(message, :role)
  end

  defp subscription_message_role(_message), do: nil

  defp subscription_message_text(nil), do: ""

  defp subscription_message_text(message) when is_map(message) do
    message
    |> map_value(:content)
    |> subscription_content_text()
  end

  defp subscription_message_text(_message), do: ""

  defp subscription_content_text(content) when is_binary(content), do: content

  defp subscription_content_text(content) when is_list(content) do
    content
    |> Enum.find(&(block_type(&1) == "text"))
    |> block_text()
  end

  defp subscription_content_text(_content), do: ""

  defp block_type(block) when is_map(block), do: map_value(block, :type)
  defp block_type(_block), do: nil

  defp block_text(block) when is_map(block), do: map_value(block, :text) || ""
  defp block_text(_block), do: ""

  defp map_value(map, key) when is_map(map) do
    Map.get(map, key) || Map.get(map, Atom.to_string(key))
  end

  defp map_value(_map, _key), do: nil

  defp sha256_prefix(value, length) do
    :crypto.hash(:sha256, value)
    |> Base.encode16(case: :lower)
    |> binary_part(0, length)
  end

  defp build_request_url(opts, credential) do
    base_url = get_option(opts, :base_url, base_url())
    add_subscription_beta_query("#{base_url}/v1/messages", credential)
  end

  defp add_subscription_beta_query(url, {credential, opts}) do
    if claude_subscription?(credential, opts) do
      uri = URI.parse(url)
      query = uri.query || ""
      params = URI.decode_query(query) |> Map.put("beta", "true")
      %{uri | query: URI.encode_query(params)} |> URI.to_string()
    else
      url
    end
  end

  defp build_beta_headers(opts, credential) do
    case beta_features(opts, credential) do
      [] ->
        []

      features ->
        beta_header =
          features
          |> Enum.uniq()
          |> Enum.join(",")

        [{"anthropic-beta", beta_header}]
    end
  end

  defp subscription_beta_features(credential, opts) do
    if claude_subscription?(credential, opts) do
      @claude_subscription_betas
    else
      []
    end
  end

  # ========================================================================

  @impl ReqLLM.Provider
  def attach_stream(model, context, opts, _finch_name) do
    operation = opts[:operation] || :chat

    translated_opts =
      ReqLLM.Provider.Options.process_stream!(
        __MODULE__,
        operation,
        model,
        context,
        opts
      )

    default_timeout =
      if Keyword.has_key?(translated_opts, :thinking) do
        Application.get_env(:req_llm, :thinking_timeout, 300_000)
      else
        Application.get_env(:req_llm, :receive_timeout, 120_000)
      end

    translated_opts = Keyword.put_new(translated_opts, :receive_timeout, default_timeout)

    base_url = ReqLLM.Provider.Options.effective_base_url(__MODULE__, model, translated_opts)
    translated_opts = Keyword.put(translated_opts, :base_url, base_url)

    credential = ReqLLM.Auth.resolve!(model, translated_opts)
    headers = build_request_headers(translated_opts, credential)
    streaming_headers = [{"Accept", "text/event-stream"} | headers]
    beta_headers = build_beta_headers(Keyword.put(translated_opts, :context, context), credential)

    custom_headers =
      ReqLLM.Provider.Utils.extract_custom_headers(translated_opts[:req_http_options])

    all_headers = streaming_headers ++ beta_headers ++ custom_headers

    context =
      ReqLLM.ToolCallIdCompat.apply_context(
        __MODULE__,
        operation,
        model,
        context,
        translated_opts
      )

    body =
      context
      |> build_request_body(get_api_model_id(model), translated_opts ++ [stream: true])
      |> shape_subscription_body({credential, translated_opts})

    url = build_request_url(translated_opts, {credential, translated_opts})

    encoded = body |> ReqLLM.Schema.apply_property_ordering() |> Jason.encode!()
    finch_request = Finch.build(:post, url, all_headers, encoded)
    {:ok, finch_request}
  rescue
    error ->
      {:error,
       ReqLLM.Error.API.Request.exception(
         reason: "Failed to build Anthropic stream request: #{inspect(error)}"
       )}
  end

  @impl ReqLLM.Provider
  def decode_stream_event(event, model) do
    BullX.LLM.Providers.Anthropic.Response.decode_stream_event(event, model)
  end

  @impl ReqLLM.Provider
  def init_stream_state(_model) do
    BullX.LLM.Providers.Anthropic.Response.init_stream_state()
  end

  @impl ReqLLM.Provider
  def decode_stream_event(event, model, state) do
    BullX.LLM.Providers.Anthropic.Response.decode_stream_event(event, model, state)
  end

  @impl ReqLLM.Provider
  def flush_stream_state(model, state) do
    BullX.LLM.Providers.Anthropic.Response.flush_stream_state(model, state)
  end

  @impl ReqLLM.Provider
  def translate_options(operation, model, opts) do
    # Anthropic-specific parameter translation
    translated_opts =
      opts
      |> translate_stop_parameter()
      |> translate_reasoning_effort(model)
      |> normalize_thinking_for_model(model)
      |> disable_thinking_for_forced_tool_choice(operation)
      |> remove_model_unsupported_parameters(model)
      |> remove_conflicting_sampling_params()
      |> translate_unsupported_parameters()

    {translated_opts, []}
  end

  @impl ReqLLM.Provider
  def tool_call_id_policy(_operation, _model, _opts) do
    %{
      mode: :sanitize,
      invalid_chars_regex: ~r/[^A-Za-z0-9_-]/,
      enforce_turn_boundary: true
    }
  end

  # Private implementation functions

  defp get_anthropic_version(user_opts) do
    Keyword.get(user_opts, :anthropic_version, @default_anthropic_version)
  end

  defp claude_subscription?(%{kind: :oauth_access_token}, opts) do
    provider_opts = get_option(opts, :provider_options, []) || []

    get_option(opts, :with_claude_subscription) == true or
      get_option(provider_opts, :with_claude_subscription) == true
  end

  defp claude_subscription?(_credential, _opts), do: false

  defp put_beta_header(request, user_opts, credential) do
    case beta_features(user_opts, credential) do
      [] ->
        request

      features ->
        beta_header =
          features
          |> Enum.uniq()
          |> Enum.join(",")

        Req.Request.put_header(request, "anthropic-beta", beta_header)
    end
  end

  defp beta_features(opts, credential) do
    beta_features = manual_beta_features(opts) ++ subscription_beta_features(credential, opts)

    beta_features =
      if has_tools?(opts) do
        [@anthropic_beta_tools | beta_features]
      else
        beta_features
      end

    beta_features =
      if has_legacy_thinking?(opts) do
        ["interleaved-thinking-2025-05-14" | beta_features]
      else
        beta_features
      end

    beta_features =
      if has_prompt_caching?(opts) do
        [@anthropic_beta_prompt_caching | beta_features]
      else
        beta_features
      end

    beta_features =
      if has_files_api_reference?(opts) do
        [@anthropic_beta_files_api | beta_features]
      else
        beta_features
      end

    Enum.uniq(beta_features)
  end

  defp manual_beta_features(opts) do
    provider_opts = get_option(opts, :provider_options, [])

    [get_option(opts, :anthropic_beta), get_option(provider_opts, :anthropic_beta)]
    |> Enum.flat_map(&List.wrap/1)
    |> Enum.reject(&is_nil/1)
  end

  defp has_tools?(user_opts) do
    provider_opts = Keyword.get(user_opts, :provider_options, [])
    tools = Keyword.get(user_opts, :tools, [])

    server_tools? =
      Enum.any?([:web_search, :web_fetch], fn key ->
        is_map(get_option(user_opts, key) || get_option(provider_opts, key))
      end)

    (is_list(tools) and tools != []) or server_tools?
  end

  defp has_thinking?(user_opts) do
    thinking = Keyword.get(user_opts, :thinking)
    reasoning_effort = Keyword.get(user_opts, :reasoning_effort)
    provider_options = Keyword.get(user_opts, :provider_options, [])
    provider_reasoning_effort = Keyword.get(provider_options, :reasoning_effort)

    not is_nil(thinking) or not is_nil(reasoning_effort) or not is_nil(provider_reasoning_effort)
  end

  defp has_legacy_thinking?(user_opts) do
    provider_options = Keyword.get(user_opts, :provider_options, [])

    case Keyword.get(user_opts, :thinking) || get_option(provider_options, :thinking) do
      %{type: "adaptive"} -> false
      %{"type" => "adaptive"} -> false
      nil -> has_thinking?(user_opts)
      _ -> true
    end
  end

  @doc false
  def has_prompt_caching?(opts) do
    get_option(opts, :anthropic_prompt_cache, false) == true
  end

  defp has_files_api_reference?(opts) do
    opts
    |> get_option(:context)
    |> context_has_files_api_reference?()
  end

  defp context_has_files_api_reference?(%ReqLLM.Context{messages: messages}) do
    Enum.any?(messages, &message_has_files_api_reference?/1)
  end

  defp context_has_files_api_reference?(_context), do: false

  defp message_has_files_api_reference?(%ReqLLM.Message{content: content}) do
    content
    |> List.wrap()
    |> Enum.any?(&content_part_has_file_id?/1)
  end

  defp message_has_files_api_reference?(_message), do: false

  defp content_part_has_file_id?(%ReqLLM.Message.ContentPart{file_id: file_id}) do
    is_binary(file_id) and file_id != ""
  end

  defp content_part_has_file_id?(part) when is_map(part) do
    source = Map.get(part, :source) || Map.get(part, "source")

    file_id =
      Map.get(part, :file_id) ||
        Map.get(part, "file_id") ||
        if(is_map(source), do: Map.get(source, :file_id) || Map.get(source, "file_id"))

    is_binary(file_id) and file_id != ""
  end

  defp content_part_has_file_id?(_part), do: false

  @doc false
  def cache_control_meta(opts) do
    case get_option(opts, :anthropic_prompt_cache_ttl) do
      nil -> %{type: "ephemeral"}
      ttl -> %{type: "ephemeral", ttl: ttl}
    end
  end

  @doc false
  def maybe_apply_prompt_caching(body, opts) do
    if has_prompt_caching?(opts) do
      cache_meta = cache_control_meta(opts)

      body
      |> maybe_cache_tools(cache_meta)
      |> maybe_cache_system(cache_meta)
      |> maybe_cache_message(cache_meta, opts)
    else
      body
    end
  end

  defp maybe_cache_tools(body, cache_meta) do
    case Map.get(body, :tools) do
      tools when is_list(tools) and tools != [] ->
        {init, [last]} = Enum.split(tools, -1)

        updated_last =
          if Map.has_key?(last, :cache_control) or Map.has_key?(last, "cache_control") do
            last
          else
            Map.put(last, :cache_control, cache_meta)
          end

        Map.put(body, :tools, init ++ [updated_last])

      _ ->
        body
    end
  end

  defp maybe_cache_system(body, cache_meta) do
    case Map.get(body, :system) do
      system when is_binary(system) ->
        content_block = %{
          type: "text",
          text: system,
          cache_control: cache_meta
        }

        Map.put(body, :system, [content_block])

      system when is_list(system) and system != [] ->
        updated_system =
          system
          |> Enum.reverse()
          |> case do
            [last | rest] ->
              updated_last =
                if Map.has_key?(last, :cache_control) or Map.has_key?(last, "cache_control") do
                  last
                else
                  Map.put(last, :cache_control, cache_meta)
                end

              Enum.reverse([updated_last | rest])

            [] ->
              []
          end

        Map.put(body, :system, updated_system)

      _ ->
        body
    end
  end

  defp maybe_cache_message(body, cache_meta, opts) do
    case get_option(opts, :anthropic_cache_messages, false) do
      false ->
        body

      true ->
        # true is alias for -1 (last message)
        do_cache_message_at(body, cache_meta, -1)

      offset when is_integer(offset) ->
        do_cache_message_at(body, cache_meta, offset)

      _ ->
        body
    end
  end

  defp do_cache_message_at(body, cache_meta, offset) do
    # Handle nil explicitly (Map.get default only applies when key is absent)
    messages = Map.get(body, :messages, []) || []
    len = length(messages)

    # Standard negative indexing: -1 = last, -2 = second-to-last, etc.
    # Non-negative: 0 = first, 1 = second, etc.
    index = if offset < 0, do: len + offset, else: offset

    # Bounds check - silently return unchanged if out of bounds
    if index < 0 or index >= len do
      body
    else
      {before, [target | after_list]} = Enum.split(messages, index)
      updated = add_cache_to_message_content(target, cache_meta)
      Map.put(body, :messages, before ++ [updated | after_list])
    end
  end

  defp add_cache_to_message_content(msg, cache_meta) do
    content = Map.get(msg, :content)

    updated_content =
      case content do
        # String content - convert to content block with cache_control
        text when is_binary(text) ->
          [%{type: "text", text: text, cache_control: cache_meta}]

        # List content - add cache_control to last block
        blocks when is_list(blocks) and blocks != [] ->
          blocks
          |> Enum.reverse()
          |> case do
            [last | rest] ->
              updated_last =
                if Map.has_key?(last, :cache_control) or Map.has_key?(last, "cache_control") do
                  last
                else
                  Map.put(last, :cache_control, cache_meta)
                end

              Enum.reverse([updated_last | rest])

            [] ->
              []
          end

        # Expected empty cases - return as-is
        nil ->
          nil

        [] ->
          []

        # Unexpected type - log and return as-is
        other ->
          Logger.debug(
            "Unexpected content type for message cache_control injection: #{inspect(other)}"
          )

          other
      end

    # Preserve key type (atom or string)
    if Map.has_key?(msg, :content) do
      Map.put(msg, :content, updated_content)
    else
      Map.put(msg, "content", updated_content)
    end
  end

  defp add_basic_options(body, request_options) do
    body =
      Enum.reduce(@body_options, body, fn key, acc ->
        maybe_put(acc, key, request_options[key])
      end)

    # Handle Anthropic-specific parameters with proper names
    body
    |> maybe_put(:top_k, request_options[:anthropic_top_k])
    |> maybe_put(:metadata, request_options[:anthropic_metadata])
  end

  defp maybe_add_tools(body, options) do
    tools = get_option(options, :tools, [])
    provider_opts = get_option(options, :provider_options, [])

    # Check for server tools in both top-level options and provider_options
    web_search_config =
      get_option(options, :web_search) || get_option(provider_opts, :web_search)

    web_fetch_config =
      get_option(options, :web_fetch) || get_option(provider_opts, :web_fetch)

    # Build the tools list
    formatted_tools =
      if is_list(tools) and tools != [],
        do: Enum.map(tools, &tool_to_anthropic_format/1),
        else: []

    server_tools =
      [
        if(is_map(web_search_config), do: build_web_search_tool(web_search_config)),
        if(is_map(web_fetch_config), do: build_web_fetch_tool(web_fetch_config))
      ]
      |> Enum.reject(&is_nil/1)

    all_tools = formatted_tools ++ server_tools

    case all_tools do
      [] ->
        body

      tools_list ->
        body = Map.put(body, :tools, tools_list)

        case get_option(options, :tool_choice) do
          nil -> body
          choice -> Map.put(body, :tool_choice, normalize_tool_choice(choice))
        end
    end
  end

  @doc false
  def normalize_tool_choice(:auto), do: %{type: "auto"}
  def normalize_tool_choice(:required), do: %{type: "any"}
  def normalize_tool_choice(:none), do: %{type: "none"}
  def normalize_tool_choice("auto"), do: %{type: "auto"}
  def normalize_tool_choice("required"), do: %{type: "any"}
  def normalize_tool_choice("none"), do: %{type: "none"}
  def normalize_tool_choice({:tool, name}) when is_binary(name), do: %{type: "tool", name: name}
  def normalize_tool_choice(%{type: _} = choice), do: choice
  def normalize_tool_choice(%{"type" => _} = choice), do: choice

  defp get_option(options, key, default \\ nil)

  defp get_option(options, key, default) when is_list(options) do
    Keyword.get(options, key, default)
  end

  defp get_option(options, key, default) when is_map(options) do
    Map.get(options, key, default)
  end

  @doc """
  Convert a ReqLLM.Tool to Anthropic's tool format.

  This is made public so that Bedrock and Vertex formatters can reuse it.
  """
  def tool_to_anthropic_format(%ReqLLM.Tool{} = tool) do
    tool
    |> ReqLLM.Tool.to_schema(:anthropic)
    |> Map.new(fn
      {key, value} when is_binary(key) ->
        case existing_atom(key) do
          nil -> {key, value}
          atom_key -> {atom_key, value}
        end

      pair ->
        pair
    end)
  end

  def tool_to_anthropic_format(%{name: _, description: _, input_schema: _} = tool), do: tool

  def tool_to_anthropic_format(%{"name" => _, "description" => _, "input_schema" => _} = tool),
    do: tool

  defp existing_atom(value) when is_binary(value) do
    String.to_existing_atom(value)
  rescue
    ArgumentError -> nil
  end

  # Builds a web search tool definition for Anthropic API.
  #
  # ## Parameters
  #   * `config` - Map with optional keys:
  #     * `:max_uses` - Integer limiting the number of searches per request
  #     * `:allowed_domains` - List of domains to include in results
  #     * `:blocked_domains` - List of domains to exclude from results
  #     * `:user_location` - Map with keys: type, city, region, country, timezone
  defp build_web_search_tool(config) when is_map(config) do
    base_tool = %{
      type: "web_search_20250305",
      name: "web_search"
    }

    # Add optional parameters if present (handle both atom and string keys)
    base_tool
    |> maybe_put_server_tool_opt(:max_uses, get_server_tool_opt(config, :max_uses))
    |> maybe_put_server_tool_opt(
      :allowed_domains,
      get_server_tool_opt(config, :allowed_domains)
    )
    |> maybe_put_server_tool_opt(
      :blocked_domains,
      get_server_tool_opt(config, :blocked_domains)
    )
    |> maybe_put_server_tool_opt(:user_location, get_server_tool_opt(config, :user_location))
  end

  # Builds a web fetch tool definition for Anthropic API.
  #
  # ## Parameters
  #   * `config` - Map with optional keys:
  #     * `:max_uses` - Integer limiting the number of fetches per request
  #     * `:allowed_domains` - List of domains to include
  #     * `:blocked_domains` - List of domains to exclude
  #     * `:max_content_tokens` - Maximum content length in tokens
  #     * `:citations` - Map with `:enabled` boolean
  defp build_web_fetch_tool(config) when is_map(config) do
    base_tool = %{
      type: "web_fetch_20260209",
      name: "web_fetch"
    }

    base_tool
    |> maybe_put_server_tool_opt(:max_uses, get_server_tool_opt(config, :max_uses))
    |> maybe_put_server_tool_opt(
      :allowed_domains,
      get_server_tool_opt(config, :allowed_domains)
    )
    |> maybe_put_server_tool_opt(
      :blocked_domains,
      get_server_tool_opt(config, :blocked_domains)
    )
    |> maybe_put_server_tool_opt(
      :max_content_tokens,
      get_server_tool_opt(config, :max_content_tokens)
    )
    |> maybe_put_server_tool_opt(:citations, get_server_tool_opt(config, :citations))
  end

  defp get_server_tool_opt(config, key) do
    Map.get(config, key) || Map.get(config, Atom.to_string(key))
  end

  defp maybe_put_server_tool_opt(tool, _key, nil), do: tool
  defp maybe_put_server_tool_opt(tool, key, value), do: Map.put(tool, key, value)

  defp maybe_put_tool_usage(usage, tool, count) when is_number(count) and count > 0 do
    tool_usage = Map.get(usage, :tool_usage, %{})
    updated_tool_usage = Map.merge(tool_usage, ReqLLM.Usage.Tool.build(tool, count))
    Map.put(usage, :tool_usage, updated_tool_usage)
  end

  defp maybe_put_tool_usage(usage, _tool, _count), do: usage

  @doc """
  Maps reasoning effort levels to token budgets.

  This is the canonical source of truth for Anthropic reasoning effort mappings,
  used by all providers hosting Anthropic models.

  - `:low` → 1,024 tokens
  - `:medium` → 2,048 tokens
  - `:high` → 4,096 tokens

  ## Examples

      iex> BullX.LLM.Providers.Anthropic.map_reasoning_effort_to_budget(:low)
      1024

      iex> BullX.LLM.Providers.Anthropic.map_reasoning_effort_to_budget("medium")
      2048
  """
  def map_reasoning_effort_to_budget(:none), do: nil
  def map_reasoning_effort_to_budget(:minimal), do: @reasoning_budget_minimal
  def map_reasoning_effort_to_budget(:low), do: @reasoning_budget_low
  def map_reasoning_effort_to_budget(:medium), do: @reasoning_budget_medium
  def map_reasoning_effort_to_budget(:high), do: @reasoning_budget_high
  def map_reasoning_effort_to_budget(:xhigh), do: @reasoning_budget_xhigh
  def map_reasoning_effort_to_budget("none"), do: map_reasoning_effort_to_budget(:none)
  def map_reasoning_effort_to_budget("minimal"), do: map_reasoning_effort_to_budget(:minimal)
  def map_reasoning_effort_to_budget("low"), do: map_reasoning_effort_to_budget(:low)
  def map_reasoning_effort_to_budget("medium"), do: map_reasoning_effort_to_budget(:medium)
  def map_reasoning_effort_to_budget("high"), do: map_reasoning_effort_to_budget(:high)
  def map_reasoning_effort_to_budget("xhigh"), do: map_reasoning_effort_to_budget(:xhigh)
  def map_reasoning_effort_to_budget(_), do: @reasoning_budget_medium

  defp translate_reasoning_effort(opts, model) do
    {reasoning_effort, opts} = Keyword.pop(opts, :reasoning_effort)
    {reasoning_budget, opts} = Keyword.pop(opts, :reasoning_token_budget)

    case reasoning_effort do
      :none ->
        opts

      :minimal ->
        put_reasoning_effort(opts, model, :minimal, reasoning_budget)

      :low ->
        put_reasoning_effort(opts, model, :low, reasoning_budget)

      :medium ->
        put_reasoning_effort(opts, model, :medium, reasoning_budget)

      :high ->
        put_reasoning_effort(opts, model, :high, reasoning_budget)

      :xhigh ->
        put_reasoning_effort(opts, model, :xhigh, reasoning_budget)

      :default ->
        put_default_reasoning_effort(opts, model)

      nil ->
        opts
    end
  end

  defp put_reasoning_effort(opts, model, effort, reasoning_budget) do
    if adaptive_thinking_required?(model) do
      opts
      |> Keyword.put(:thinking, %{type: "adaptive"})
      |> put_output_effort(adaptive_effort(effort, model))
      |> remove_adaptive_thinking_sampling_params()
    else
      budget = reasoning_budget || map_reasoning_effort_to_budget(effort)

      opts
      |> Keyword.put(:thinking, %{type: "enabled", budget_tokens: budget})
      |> adjust_max_tokens_for_thinking(budget)
      |> adjust_top_p_for_thinking()
    end
  end

  defp put_default_reasoning_effort(opts, model) do
    if adaptive_thinking_required?(model) do
      opts
      |> Keyword.put(:thinking, %{type: "adaptive"})
      |> put_output_effort(adaptive_effort(:default, model))
      |> remove_adaptive_thinking_sampling_params()
    else
      opts
      |> Keyword.put(:thinking, %{type: "enabled"})
      |> adjust_top_p_for_thinking()
    end
  end

  defp normalize_thinking_for_model(opts, model) do
    if adaptive_thinking_required?(model) do
      case Keyword.get(opts, :thinking) do
        %{type: "enabled"} = thinking ->
          opts
          |> Keyword.put(:thinking, %{type: "adaptive"})
          |> put_output_effort(effort_from_thinking(thinking, model))
          |> remove_adaptive_thinking_sampling_params()

        %{"type" => "enabled"} = thinking ->
          opts
          |> Keyword.put(:thinking, %{type: "adaptive"})
          |> put_output_effort(effort_from_thinking(thinking, model))
          |> remove_adaptive_thinking_sampling_params()

        %{type: "adaptive"} ->
          remove_adaptive_thinking_sampling_params(opts)

        %{"type" => "adaptive"} ->
          remove_adaptive_thinking_sampling_params(opts)

        _ ->
          opts
      end
    else
      opts
    end
  end

  defp put_output_effort(opts, effort) do
    Keyword.update(opts, :output_config, %{effort: effort}, fn
      config when is_map(config) -> Map.put(config, :effort, effort)
      config when is_list(config) -> Keyword.put(config, :effort, effort)
      _ -> %{effort: effort}
    end)
  end

  defp effort_from_thinking(thinking, model) do
    thinking
    |> get_option(:budget_tokens)
    |> budget_to_effort(model)
  end

  defp budget_to_effort(nil, model), do: adaptive_effort(:default, model)
  defp budget_to_effort(budget, _model) when budget >= @reasoning_budget_xhigh, do: "max"
  defp budget_to_effort(budget, _model) when budget >= @reasoning_budget_high, do: "high"
  defp budget_to_effort(budget, _model) when budget >= @reasoning_budget_medium, do: "medium"
  defp budget_to_effort(_budget, _model), do: "low"

  defp adaptive_effort(:minimal, _model), do: "low"
  defp adaptive_effort(:low, _model), do: "low"
  defp adaptive_effort(:medium, _model), do: "medium"
  defp adaptive_effort(:high, _model), do: "high"
  defp adaptive_effort(:xhigh, model), do: max_effort(model)
  defp adaptive_effort(:default, _model), do: "medium"

  defp max_effort(model) do
    if model_extra(model, [:capabilities, :effort, :max, :supported]) == true do
      "max"
    else
      "high"
    end
  end

  defp adaptive_thinking_required?(model) do
    model_extra(model, [:capabilities, :thinking, :types, :adaptive, :supported]) == true and
      model_extra(model, [:capabilities, :thinking, :types, :enabled, :supported]) == false
  end

  defp remove_model_unsupported_parameters(opts, model) do
    if model_extra(model, [:temperature]) == false do
      Keyword.drop(opts, [:temperature, :top_p, :top_k, :anthropic_top_k])
    else
      opts
    end
  end

  defp model_extra(%LLMDB.Model{extra: extra}, path), do: get_nested(extra, path)
  defp model_extra(_, _path), do: nil

  defp get_nested(value, []), do: value

  defp get_nested(value, [key | rest]) when is_map(value) do
    cond do
      Map.has_key?(value, key) -> get_nested(Map.get(value, key), rest)
      Map.has_key?(value, to_string(key)) -> get_nested(Map.get(value, to_string(key)), rest)
      true -> nil
    end
  end

  defp get_nested(_value, _path), do: nil

  defp adjust_max_tokens_for_thinking(opts, budget_tokens) do
    max_tokens = Keyword.get(opts, :max_tokens)

    cond do
      is_nil(max_tokens) ->
        opts

      max_tokens <= budget_tokens ->
        Keyword.put(opts, :max_tokens, budget_tokens + 201)

      true ->
        opts
    end
  end

  defp disable_thinking_for_forced_tool_choice(opts, operation) do
    thinking = Keyword.get(opts, :thinking)
    tool_choice = Keyword.get(opts, :tool_choice)

    cond do
      is_nil(thinking) ->
        opts

      operation == :object and match?(%{type: "tool"}, tool_choice) ->
        opts
        |> Keyword.delete(:thinking)
        |> Keyword.delete(:output_config)

      match?(%{type: "tool"}, tool_choice) ->
        opts
        |> Keyword.delete(:thinking)
        |> Keyword.delete(:output_config)

      match?(%{type: "any"}, tool_choice) ->
        Keyword.put(opts, :tool_choice, %{type: "auto"})

      true ->
        opts
    end
  end

  defp adjust_top_p_for_thinking(opts) do
    opts
    |> adjust_parameter(:top_p, fn
      nil -> nil
      top_p when top_p < 0.95 -> 0.95
      top_p when top_p > 1.0 -> 1.0
      top_p -> top_p
    end)
    |> Keyword.delete(:temperature)
    |> Keyword.delete(:top_k)
  end

  defp remove_adaptive_thinking_sampling_params(opts) do
    opts
    |> Keyword.delete(:temperature)
    |> Keyword.delete(:top_p)
    |> Keyword.delete(:top_k)
  end

  defp adjust_parameter(opts, key, fun) do
    case Keyword.get(opts, key) do
      nil ->
        opts

      value ->
        case fun.(value) do
          nil -> opts
          new_value -> Keyword.put(opts, key, new_value)
        end
    end
  end

  defp remove_conflicting_sampling_params(opts) do
    has_temperature = Keyword.has_key?(opts, :temperature)
    has_top_p = Keyword.has_key?(opts, :top_p)

    if has_temperature and has_top_p do
      Keyword.delete(opts, :top_p)
    else
      opts
    end
  end

  defp translate_stop_parameter(opts) do
    case Keyword.get(opts, :stop) do
      nil ->
        opts

      stop when is_binary(stop) ->
        opts |> Keyword.delete(:stop) |> Keyword.put(:stop_sequences, [stop])

      stop when is_list(stop) ->
        opts |> Keyword.delete(:stop) |> Keyword.put(:stop_sequences, stop)
    end
  end

  defp translate_unsupported_parameters(opts) do
    Enum.reduce(@unsupported_parameters, opts, fn key, acc -> Keyword.delete(acc, key) end)
  end

  defp decode_success_response(req, resp) do
    operation = req.options[:operation]

    case operation do
      _ ->
        decode_anthropic_response(req, resp, operation)
    end
  end

  defp decode_error_response(req, resp, status) do
    reason =
      try do
        case Jason.decode(resp.body) do
          {:ok, %{"error" => %{"message" => message}}} -> message
          {:ok, %{"error" => %{"type" => error_type}}} -> "#{error_type}"
          _ -> "Anthropic API error"
        end
      rescue
        _ -> "Anthropic API error"
      end

    err =
      ReqLLM.Error.API.Response.exception(
        reason: reason,
        status: status,
        response_body: resp.body
      )

    {req, err}
  end

  defp decode_anthropic_response(req, resp, operation) do
    model_name = req.options[:model]

    # Handle case where model_name might be nil
    model =
      case model_name do
        nil ->
          case req.private[:req_llm_model] do
            %LLMDB.Model{} = stored_model -> stored_model
            _ -> %LLMDB.Model{id: "unknown", provider: :anthropic}
          end

        model_name when is_binary(model_name) ->
          %LLMDB.Model{id: model_name, provider: :anthropic}
      end

    is_streaming = req.options[:stream] == true

    if is_streaming do
      decode_streaming_response(req, resp, model_name)
    else
      decode_non_streaming_response(req, resp, model, operation)
    end
  end

  defp decode_streaming_response(req, resp, model_name) do
    # Similar structure to defaults but use Anthropic-specific stream handling
    {stream, provider_meta} =
      case resp.body do
        %Stream{} = existing_stream ->
          {existing_stream, %{}}

        _ ->
          # Real-time streaming - use the stream created by Stream step
          # The request has already been initiated by the initial Req.request call
          # We just need to return the configured stream, not make another request
          real_time_stream = Req.Request.get_private(req, :real_time_stream, [])

          {real_time_stream, %{}}
      end

    response = %ReqLLM.Response{
      id: "stream-#{System.unique_integer([:positive])}",
      model: model_name,
      context: req.options[:context] || %ReqLLM.Context{messages: []},
      message: nil,
      stream?: true,
      stream: stream,
      usage: %{
        input_tokens: 0,
        output_tokens: 0,
        total_tokens: 0,
        cached_tokens: 0,
        reasoning_tokens: 0
      },
      finish_reason: nil,
      provider_meta: provider_meta
    }

    {req, %{resp | body: response}}
  end

  defp decode_non_streaming_response(req, resp, model, operation) do
    body = ensure_parsed_body(resp.body)
    {:ok, response} = BullX.LLM.Providers.Anthropic.Response.decode_response(body, model)

    final_response =
      case operation do
        :object ->
          extract_and_set_object(response, req.options)

        _ ->
          response
      end

    merged_response = merge_response_with_context(req, final_response)
    {req, %{resp | body: merged_response}}
  end

  defp extract_and_set_object(response, opts) do
    provider_opts = normalize_provider_opts(opts)
    output_format = get_output_format(provider_opts)

    extracted_object =
      if is_map(output_format) and output_format[:type] == "json_schema" do
        # JSON Schema mode: parse text content
        case response.message do
          %ReqLLM.Message{content: content} ->
            text_content =
              Enum.find(content, fn
                %ReqLLM.Message.ContentPart{type: :text} -> true
                _ -> false
              end)

            case text_content do
              %ReqLLM.Message.ContentPart{text: text} ->
                case ReqLLM.JSON.decode(text, opts) do
                  {:ok, json} -> json
                  _ -> find_structured_output(response, opts)
                end

              _ ->
                find_structured_output(response, opts)
            end

          _ ->
            find_structured_output(response, opts)
        end
      else
        find_structured_output(response, opts)
      end

    %{response | object: extracted_object}
  end

  defp normalize_provider_opts(opts) when is_list(opts) do
    Keyword.get(opts, :provider_options, [])
  end

  defp normalize_provider_opts(opts) when is_map(opts) do
    provider_opts = Map.get(opts, :provider_options, [])

    cond do
      Keyword.keyword?(provider_opts) -> provider_opts
      is_map(provider_opts) -> Map.to_list(provider_opts)
      true -> provider_opts
    end
  end

  defp get_output_format(provider_opts) when is_list(provider_opts) do
    Keyword.get(provider_opts, :output_format)
  end

  defp get_output_format(provider_opts) when is_map(provider_opts) do
    provider_opts[:output_format]
  end

  defp find_structured_output(response, opts) do
    response
    |> ReqLLM.Response.tool_calls()
    |> ReqLLM.ToolCall.find_args("structured_output", opts)
  end

  defp merge_response_with_context(req, response) do
    context = req.options[:context] || %ReqLLM.Context{messages: []}
    ReqLLM.Context.merge_response(context, response)
  end

  defp default_max_tokens(_model_name), do: 1024

  defp get_api_model_id(%LLMDB.Model{provider_model_id: api_id}) when not is_nil(api_id),
    do: api_id

  defp get_api_model_id(%LLMDB.Model{id: id}), do: id

  @doc false
  def determine_output_mode(_model, opts) do
    provider_opts = Keyword.get(opts, :provider_options, [])
    explicit_mode = Keyword.get(provider_opts, :anthropic_structured_output_mode, :auto)

    case explicit_mode do
      :auto ->
        if has_other_tools?(opts) do
          :tool_strict
        else
          :json_schema
        end

      mode ->
        mode
    end
  end

  @doc false
  def has_other_tools?(opts) do
    tools = Keyword.get(opts, :tools, [])
    Enum.any?(tools, fn tool -> tool.name != "structured_output" end)
  end

  defp enforce_strict_schema_requirements(
         %{"type" => "object", "properties" => properties} = schema
       ) do
    all_property_names = Map.keys(properties)

    schema
    |> Map.put("required", all_property_names)
    |> Map.put("additionalProperties", false)
  end

  defp enforce_strict_schema_requirements(schema), do: schema

  defp strip_constraints_recursive(schema) when is_map(schema) do
    schema
    |> Map.drop(["minimum", "maximum", "minLength", "maxLength", "minItems", "maxItems"])
    |> Map.new(fn
      {"properties", props} when is_map(props) ->
        {"properties", Map.new(props, fn {k, v} -> {k, strip_constraints_recursive(v)} end)}

      {"items", items} when is_map(items) ->
        {"items", strip_constraints_recursive(items)}

      {k, v} when is_map(v) ->
        {k, strip_constraints_recursive(v)}

      {k, v} ->
        {k, v}
    end)
  end

  defp strip_constraints_recursive(value), do: value

  defp maybe_add_output_format(body, opts) do
    provider_opts = get_option(opts, :provider_options, [])

    output_format =
      get_option(opts, :output_format) || get_option(provider_opts, :output_format)

    case output_format do
      nil -> body
      format -> Map.put(body, :output_format, format)
    end
  end
end
