lib/allm/providers/support/image_mime.ex

defmodule ALLM.Providers.Support.ImageMime do
  @moduledoc """
  Per-provider MIME and size validation for `ALLM.ImagePart` content.

  Layer B helper used by the OpenAI and (future) Anthropic vision pre-flight
  to gate `%ALLM.ImagePart{}` content against per-provider accept-sets and a
  shared 20-MB byte-size ceiling. See spec §35.6 and Phase 17 design §3.1.

  ## Per-image validation (`validate/2`)

  Resolves the part's `%ALLM.Image{}` to bytes via
  `ALLM.Image.to_binary/1` and applies two checks:

    * MIME membership against the supplied accept-set.
    * `byte_size/1` of the resolved bytes against `@max_bytes` (20 MB).

  URL sources (`{:url, _}`) skip the size check — the adapter does NOT
  fetch the URL during pre-flight (Decision #1); size validation is
  deferred to the provider. MIME enforcement still applies (the URL's
  `:mime_type` is always `nil` per `ALLM.Image.from_url/1`, so a URL-source
  `%ImagePart{}` is accepted regardless of accept-set today; future
  divergence is captured by the per-provider arg).

  ## Request-level pre-flight (`validate_request/2`)

  Walks every `%ALLM.ImagePart{}` in `request.messages`, calls `validate/2`
  for each, accumulates `{[:content, msg_idx, part_idx], reason}` field
  errors. Returns `:ok` when no part fails, otherwise an
  `%ALLM.Error.ValidationError{reason: :invalid_message}` with all
  per-image errors in `:errors`. Adapters call this once in pre-flight;
  per-provider divergence is isolated to `accept_mimes/1`.

  ## Provider accept-sets

      iex> ALLM.Providers.Support.ImageMime.accept_mimes(:openai)
      ["image/png", "image/jpeg", "image/webp", "image/gif"]

      iex> ALLM.Providers.Support.ImageMime.accept_mimes(:anthropic)
      ["image/png", "image/jpeg", "image/webp", "image/gif"]

  Both providers' published 2026-04 accept-sets match; the per-provider
  arg keeps the seam open for future divergence.
  """

  alias ALLM.Error.ValidationError
  alias ALLM.{Image, ImagePart, Message, Request, TextPart}

  @typedoc "Per-image validation result."
  @type validate_result ::
          :ok
          | {:error,
             {:unsupported_image_format, mime :: String.t() | nil}
             | {:image_too_large, byte_size :: non_neg_integer()}
             | :missing_mime_type}

  # 20 MB — both OpenAI and Anthropic published limit per the 2026-04
  # vision API docs.
  @max_bytes 20 * 1024 * 1024

  @doc """
  Provider accept-set for `image/*` MIME types.

  ## Examples

      iex> ALLM.Providers.Support.ImageMime.accept_mimes(:openai)
      ["image/png", "image/jpeg", "image/webp", "image/gif"]
  """
  @spec accept_mimes(:openai | :anthropic) :: [String.t()]
  def accept_mimes(:openai), do: ~w(image/png image/jpeg image/webp image/gif)
  def accept_mimes(:anthropic), do: ~w(image/png image/jpeg image/webp image/gif)

  @doc """
  Validate a single `%ImagePart{}` against an accept-set and the 20-MB
  size ceiling.

  ## Examples

      iex> img = ALLM.Image.from_binary("hi", "image/png")
      iex> part = ALLM.ImagePart.new(img)
      iex> ALLM.Providers.Support.ImageMime.validate(part, ["image/png"])
      :ok

      iex> img = ALLM.Image.from_binary("hi", "image/svg+xml")
      iex> part = ALLM.ImagePart.new(img)
      iex> ALLM.Providers.Support.ImageMime.validate(part, ["image/png"])
      {:error, {:unsupported_image_format, "image/svg+xml"}}

      iex> img = ALLM.Image.from_url("https://example.com/x.png")
      iex> part = ALLM.ImagePart.new(img)
      iex> ALLM.Providers.Support.ImageMime.validate(part, ["image/png"])
      :ok
  """
  @spec validate(ImagePart.t(), [String.t()]) :: validate_result()
  def validate(%ImagePart{image: %Image{source: {:url, _}, mime_type: mime}}, accept_mimes)
      when is_list(accept_mimes) do
    # URL sources: defer size validation (no fetch). MIME may be nil
    # (`from_url/1` doesn't infer); accept either nil or in-set.
    cond do
      is_nil(mime) -> :ok
      mime in accept_mimes -> :ok
      true -> {:error, {:unsupported_image_format, mime}}
    end
  end

  def validate(%ImagePart{image: %Image{mime_type: nil}}, _accept_mimes) do
    {:error, :missing_mime_type}
  end

  def validate(%ImagePart{image: %Image{mime_type: mime} = image}, accept_mimes)
      when is_list(accept_mimes) do
    if mime in accept_mimes do
      check_byte_size(image)
    else
      {:error, {:unsupported_image_format, mime}}
    end
  end

  defp check_byte_size(%Image{} = image) do
    case Image.to_binary(image) do
      {:ok, bytes} ->
        if byte_size(bytes) > @max_bytes do
          {:error, {:image_too_large, byte_size(bytes)}}
        else
          :ok
        end

      {:error, _reason} ->
        # Resolution failure (file missing, base64 invalid). Treat as
        # missing-mime equivalent — the adapter cannot prove the image is
        # OK without bytes.
        :ok
    end
  end

  @doc """
  Walk every `%ImagePart{}` in `request.messages`, validate, and return
  either `:ok` or a `%ValidationError{reason: :invalid_message}` carrying
  per-image field errors.

  Field paths: `[:content, msg_idx, part_idx]` per spec §35.6 / Phase 17
  design §7.

  ## Examples

      iex> img = ALLM.Image.from_binary("hi", "image/png")
      iex> req = ALLM.Request.new([%ALLM.Message{role: :user, content: [%ALLM.ImagePart{image: img}]}])
      iex> ALLM.Providers.Support.ImageMime.validate_request(req, :openai)
      :ok
  """
  @spec validate_request(Request.t(), :openai | :anthropic) ::
          :ok | {:error, ValidationError.t()}
  def validate_request(%Request{messages: messages}, provider)
      when provider in [:openai, :anthropic] do
    accept_set = accept_mimes(provider)

    errors =
      messages
      |> Enum.with_index()
      |> Enum.flat_map(fn {%Message{content: content}, msg_idx} ->
        collect_message_errors(content, msg_idx, accept_set)
      end)

    case errors do
      [] ->
        :ok

      list ->
        {:error,
         ValidationError.new(:invalid_message, list,
           message: "image content failed provider validation"
         )}
    end
  end

  defp collect_message_errors(content, msg_idx, accept_set) when is_list(content) do
    content
    |> Enum.with_index()
    |> Enum.flat_map(fn {part, part_idx} ->
      case validate_part(part, accept_set) do
        :ok -> []
        {:error, reason} -> [{[:content, msg_idx, part_idx], image_reason(reason)}]
      end
    end)
  end

  defp collect_message_errors(_content, _msg_idx, _accept_set), do: []

  defp validate_part(%ImagePart{} = part, accept_set), do: validate(part, accept_set)
  defp validate_part(%TextPart{}, _accept_set), do: :ok
  defp validate_part(_other, _accept_set), do: :ok

  # Map the `validate/2` per-image error term into the field-error reason
  # atom carried in `ValidationError.errors`.
  defp image_reason({:unsupported_image_format, _mime}), do: :unsupported_image_format
  defp image_reason({:image_too_large, _bytes}), do: :image_too_large
  defp image_reason(:missing_mime_type), do: :missing_mime_type
end