Skip to main content

lib/jido/chat/attachment.ex

defmodule Jido.Chat.Attachment do
  @moduledoc """
  Normalized outbound attachment used by post payloads and sent-message handles.
  """

  alias Jido.Chat.Content.{Audio, File, Image, Video}
  alias Jido.Chat.{FileUpload, Media}

  @schema Zoi.struct(
            __MODULE__,
            %{
              kind: Zoi.enum([:image, :audio, :video, :file]) |> Zoi.default(:file),
              url: Zoi.string() |> Zoi.nullish(),
              path: Zoi.string() |> Zoi.nullish(),
              data: Zoi.string() |> Zoi.nullish(),
              media_type: Zoi.string() |> Zoi.nullish(),
              filename: Zoi.string() |> Zoi.nullish(),
              size_bytes: Zoi.integer() |> Zoi.nullish(),
              width: Zoi.integer() |> Zoi.nullish(),
              height: Zoi.integer() |> Zoi.nullish(),
              duration: Zoi.integer() |> Zoi.nullish(),
              metadata: Zoi.map() |> Zoi.default(%{})
            },
            coerce: true
          )

  @type t :: unquote(Zoi.type_spec(@schema))

  @type input ::
          t()
          | FileUpload.t()
          | Media.t()
          | Image.t()
          | Audio.t()
          | Video.t()
          | File.t()
          | map()
          | String.t()

  @enforce_keys Zoi.Struct.enforce_keys(@schema)
  defstruct Zoi.Struct.struct_fields(@schema)

  @doc "Returns the Zoi schema for Attachment."
  def schema, do: @schema

  @doc "Creates an attachment struct from normalized map input."
  def new(%__MODULE__{} = attachment), do: attachment

  def new(attrs) when is_map(attrs),
    do: attrs |> normalize_map() |> then(&Jido.Chat.Schema.parse!(__MODULE__, @schema, &1))

  @doc "Normalizes supported outbound attachment inputs into a canonical attachment struct."
  @spec normalize(input()) :: t()
  def normalize(%__MODULE__{} = attachment), do: attachment

  def normalize(%FileUpload{} = file_upload) do
    new(Map.from_struct(file_upload))
  end

  def normalize(%Media{} = media) do
    metadata = media.metadata || %{}
    {path, metadata} = pop_metadata(metadata, :path)
    {data, metadata} = pop_metadata(metadata, :data)

    new(%{
      kind: media.kind,
      url: media.url,
      path: path,
      data: data,
      media_type: media.media_type,
      filename: media.filename,
      size_bytes: media.size_bytes,
      width: media.width,
      height: media.height,
      duration: media.duration,
      metadata: metadata
    })
  end

  def normalize(%Image{} = image) do
    new(%{
      kind: :image,
      url: image.url,
      data: image.data,
      media_type: image.media_type,
      width: image.width,
      height: image.height,
      metadata: compact_metadata(%{alt_text: image.alt_text})
    })
  end

  def normalize(%Audio{} = audio) do
    new(%{
      kind: :audio,
      url: audio.url,
      data: audio.data,
      media_type: audio.media_type,
      duration: audio.duration,
      metadata: compact_metadata(%{transcript: audio.transcript})
    })
  end

  def normalize(%Video{} = video) do
    new(%{
      kind: :video,
      url: video.url,
      data: video.data,
      media_type: video.media_type,
      width: video.width,
      height: video.height,
      duration: video.duration,
      metadata: compact_metadata(%{thumbnail_url: video.thumbnail_url})
    })
  end

  def normalize(%File{} = file) do
    new(%{
      kind: infer_kind(file.media_type, file.filename, file.url),
      url: file.url,
      data: file.data,
      media_type: file.media_type,
      filename: file.filename,
      size_bytes: file.size
    })
  end

  def normalize(attrs) when is_map(attrs), do: new(attrs)

  def normalize(reference) when is_binary(reference) do
    new(%{
      kind: infer_kind(nil, filename_from_reference(reference), reference),
      url: if(remote_reference?(reference), do: reference, else: nil),
      path: if(remote_reference?(reference), do: nil, else: reference),
      filename: filename_from_reference(reference)
    })
  end

  @doc "Normalizes a list of attachment inputs."
  @spec normalize_many([input()]) :: [t()]
  def normalize_many(attachments) when is_list(attachments),
    do: Enum.map(attachments, &normalize/1)

  defp normalize_map(attrs) do
    metadata =
      attrs
      |> metadata_from_attrs()
      |> merge_extra_metadata(attrs)

    media_type =
      attrs[:media_type] || attrs["media_type"] || attrs[:mime_type] || attrs["mime_type"]

    filename = attrs[:filename] || attrs["filename"] || attrs[:name] || attrs["name"]
    url = attrs[:url] || attrs["url"]
    path = attrs[:path] || attrs["path"]

    %{
      kind:
        normalize_kind(attrs[:kind] || attrs["kind"] || attrs[:type] || attrs["type"]) ||
          infer_kind(media_type, filename, url || path),
      url: url,
      path: path,
      data: attrs[:data] || attrs["data"],
      media_type: media_type,
      filename: filename || filename_from_reference(url || path),
      size_bytes: attrs[:size_bytes] || attrs["size_bytes"] || attrs[:size] || attrs["size"],
      width: attrs[:width] || attrs["width"],
      height: attrs[:height] || attrs["height"],
      duration: attrs[:duration] || attrs["duration"],
      metadata: metadata
    }
  end

  defp metadata_from_attrs(attrs) do
    attrs[:metadata] || attrs["metadata"] || %{}
  end

  defp merge_extra_metadata(metadata, attrs) do
    metadata
    |> maybe_put_metadata(:alt_text, attrs[:alt_text] || attrs["alt_text"])
    |> maybe_put_metadata(:transcript, attrs[:transcript] || attrs["transcript"])
    |> maybe_put_metadata(:thumbnail_url, attrs[:thumbnail_url] || attrs["thumbnail_url"])
  end

  defp maybe_put_metadata(metadata, _key, nil), do: metadata
  defp maybe_put_metadata(metadata, key, value), do: Map.put(metadata, key, value)

  defp compact_metadata(entries) do
    entries
    |> Enum.reject(fn {_key, value} -> is_nil(value) end)
    |> Map.new()
  end

  defp pop_metadata(metadata, key) when is_map(metadata) do
    atom_value = Map.get(metadata, key)
    string_key = Atom.to_string(key)
    string_value = Map.get(metadata, string_key)

    value = atom_value || string_value
    next_metadata = metadata |> Map.delete(key) |> Map.delete(string_key)

    {value, next_metadata}
  end

  defp normalize_kind(kind) when kind in [:image, :audio, :video, :file], do: kind

  defp normalize_kind(kind) when is_binary(kind) do
    case kind do
      "image" -> :image
      "audio" -> :audio
      "video" -> :video
      "file" -> :file
      _ -> nil
    end
  end

  defp normalize_kind(_kind), do: nil

  defp infer_kind(media_type, filename, reference) do
    cond do
      is_binary(media_type) and String.starts_with?(media_type, "image/") -> :image
      is_binary(media_type) and String.starts_with?(media_type, "audio/") -> :audio
      is_binary(media_type) and String.starts_with?(media_type, "video/") -> :video
      extension_kind(filename || reference) != :file -> extension_kind(filename || reference)
      true -> :file
    end
  end

  defp extension_kind(nil), do: :file

  defp extension_kind(value) when is_binary(value) do
    case value |> Path.extname() |> String.downcase() do
      ext when ext in [".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".svg"] -> :image
      ext when ext in [".mp3", ".wav", ".ogg", ".m4a", ".flac", ".aac"] -> :audio
      ext when ext in [".mp4", ".mov", ".avi", ".mkv", ".webm", ".m4v"] -> :video
      _ -> :file
    end
  end

  defp filename_from_reference(nil), do: nil

  defp filename_from_reference(reference) when is_binary(reference) do
    case remote_reference?(reference) do
      true ->
        reference
        |> URI.parse()
        |> Map.get(:path)
        |> basename_or_nil()

      false ->
        basename_or_nil(reference)
    end
  end

  defp basename_or_nil(nil), do: nil
  defp basename_or_nil(""), do: nil

  defp basename_or_nil(path) do
    case Path.basename(path) do
      "." -> nil
      "/" -> nil
      value -> value
    end
  end

  defp remote_reference?(reference) when is_binary(reference) do
    case URI.parse(reference) do
      %URI{scheme: scheme} when is_binary(scheme) and scheme != "" -> true
      _ -> false
    end
  end
end