Skip to main content

lib/image/components/url.ex

defmodule Image.Components.URL do
  @moduledoc """
  Per-provider URL builders.

  Projects a canonical `t:Image.Plug.Pipeline.t/0` onto the URL
  grammar of each supported provider. This is the inverse of the URL
  parsers in `image_plug`'s provider modules: those parsers
  consume URLs and produce a `Pipeline`; these builders go the
  other way — `Pipeline` → provider-specific URL string.

  Five providers are supported: four commercial image CDNs
  (Cloudflare Images, Cloudinary, imgix, ImageKit) plus
  [IIIF Image API 3.0](https://iiif.io/api/image/3.0/), the open
  standard implemented by cultural-heritage and academic image
  servers (Cantaloupe, Loris, IIPImage, Wellcome, Library of
  Congress, …). The same IR drives all five grammars, so an
  option set produces five URLs with comparable semantics — modulo
  the per-provider feature gaps.

  ## Coverage

  Implements the round-trip subset shared by the five providers:
  resize (width/height/fit/gravity/dpr), format/quality,
  blur/sharpen, brightness/contrast/saturation/gamma, rotate,
  trim, background, plus IIIF-specific `region` and named-quality
  (`gray`/`bitonal`) tokens. Operations not natively expressible
  in a given provider's URL grammar are dropped silently and
  documented in the corresponding `image_plug` provider's
  conformance guide.

  ## Provider semantic differences

  The five providers do not all express adjust effects the same
  way:

    * **Cloudflare** takes brightness/contrast/saturation/gamma
      as raw multipliers (the same units as the IR; `1.0` = no
      change).

    * **Cloudinary** and **imgix** take centred percentages in
      `-100..100`, where `0` = no change. The builders below
      convert: an IR value of `1.4` becomes `e_contrast:40` for
      Cloudinary and `con=40` for imgix.

    * **ImageKit** has only an unparameterised `e-contrast`
      toggle (auto-contrast). Brightness/contrast/saturation/
      gamma multipliers cannot be faithfully expressed in
      ImageKit URL form, so they are silently dropped — no
      approximation, by design. See
      `guides/imagekit_conformance.md` in `image_plug`.

    * **IIIF** has no parameterised adjust effects at all. Only
      `Adjust{saturation: 0.0}` round-trips, via the `gray`
      quality token in the URL's quality segment; everything else
      (brightness, contrast, gamma, non-zero saturation) is
      silently dropped. See `guides/iiif_conformance.md` in
      `image_plug`.

  ## Examples

      iex> alias Image.Plug.Pipeline
      iex> alias Image.Plug.Pipeline.Ops
      iex> p = %Pipeline{ops: [%Ops.Resize{width: 600}], output: %Ops.Format{type: :webp, quality: 80}}
      iex> Image.Components.URL.cloudflare(p, source_path: "/sample.jpg")
      "/cdn-cgi/image/width=600,format=webp,quality=80/sample.jpg"

  """

  alias Image.Plug.Pipeline
  alias Image.Plug.Pipeline.Ops

  @typedoc """
  Per-builder options, shared across the four projectors.

    * `:source_path` — the URL-relative source path. Cloudflare,
      Cloudinary, and ImageKit append this after the options
      segment; imgix prepends it before the query string.
      Defaults to `\"/sample.jpg\"`.

    * `:host` — when supplied, prepended verbatim (e.g.
      `\"https://playground.example.com\"` or just
      `\"/img\"` to scope under a path). Default `\"\"` — relative
      URL.

    * `:cloudinary_account` — Cloudinary's `<cloud-name>`
      segment. Default `\"demo\"`.

    * `:imagekit_endpoint` — ImageKit's per-account endpoint
      segment. Default `\"demo\"`.

  """
  @type options :: keyword()

  @doc """
  Projects the pipeline onto the Cloudflare Images URL grammar: `<host>/cdn-cgi/image/<options>/<source>`.

  ### Arguments

  * `pipeline` is an `Image.Plug.Pipeline.t()`.

  * `options` is a keyword list — see the Options section.

  ### Options

  * `:source_path` is the URL-relative source path. Defaults to `\"/sample.jpg\"`.

  * `:host` is prepended verbatim — e.g. `\"https://playground.example.com\"` or `\"/img\"` to scope under a path. Defaults to `\"\"` (relative URL).

  ### Returns

  * The projected URL as a string. Cloudflare requires at least one option; when the pipeline projects to nothing, `format=auto` (a no-op) is emitted.

  ### Examples

      iex> alias Image.Plug.Pipeline
      iex> alias Image.Plug.Pipeline.Ops
      iex> p = %Pipeline{ops: [%Ops.Resize{width: 200}], output: nil}
      iex> Image.Components.URL.cloudflare(p, source_path: "/abc.jpg")
      "/cdn-cgi/image/width=200/abc.jpg"

  """
  @spec cloudflare(Pipeline.t(), options()) :: String.t()
  def cloudflare(%Pipeline{} = pipeline, options \\ []) do
    options_segment = pipeline |> cloudflare_options() |> Enum.join(",")
    source = source_path(options)
    host = Keyword.get(options, :host, "")

    # Cloudflare requires a non-empty options segment; emit
    # `format=auto` (the default and a no-op) when we'd otherwise
    # produce nothing.
    url =
      case options_segment do
        "" -> "#{host}/cdn-cgi/image/format=auto/#{trim_leading_slash(source)}"
        seg -> "#{host}/cdn-cgi/image/#{seg}/#{trim_leading_slash(source)}"
      end

    maybe_sign(url, options, :cloudflare)
  end

  @doc """
  Projects the pipeline onto the Cloudinary URL grammar: `<host>/<account>/image/upload/<options>/<source>`.

  ### Arguments

  * `pipeline` is an `Image.Plug.Pipeline.t()`.

  * `options` is a keyword list — see the Options section.

  ### Options

  * `:source_path` is the URL-relative source path. Defaults to `\"/sample.jpg\"`.

  * `:host` is prepended verbatim. Defaults to `\"\"` (relative URL).

  * `:cloudinary_account` is the `<cloud-name>` segment. Defaults to `\"demo\"`.

  ### Returns

  * The projected URL as a string. When the pipeline projects to no options, the `tr:` segment is omitted and the URL collapses to `<host>/<account>/image/upload/<source>`.

  ### Examples

      iex> alias Image.Plug.Pipeline
      iex> alias Image.Plug.Pipeline.Ops
      iex> p = %Pipeline{ops: [%Ops.Resize{width: 200}], output: nil}
      iex> Image.Components.URL.cloudinary(p, source_path: "/abc.jpg")
      "/demo/image/upload/w_200/abc.jpg"

  """
  @spec cloudinary(Pipeline.t(), options()) :: String.t()
  def cloudinary(%Pipeline{} = pipeline, options \\ []) do
    options_segment = pipeline |> cloudinary_options() |> Enum.join(",")
    source = source_path(options) |> trim_leading_slash()
    account = Keyword.get(options, :cloudinary_account, "demo")
    host = Keyword.get(options, :host, "")

    url =
      case options_segment do
        "" -> "#{host}/#{account}/image/upload/#{source}"
        seg -> "#{host}/#{account}/image/upload/#{seg}/#{source}"
      end

    maybe_sign(url, options, :cloudinary)
  end

  @doc """
  Projects the pipeline onto the imgix URL grammar: `<host>/<source>?<options>`.

  ### Arguments

  * `pipeline` is an `Image.Plug.Pipeline.t()`.

  * `options` is a keyword list — see the Options section.

  ### Options

  * `:source_path` is the URL-relative source path. Defaults to `\"/sample.jpg\"`.

  * `:host` is prepended verbatim. Defaults to `\"\"` (relative URL).

  ### Returns

  * The projected URL as a string. When the pipeline projects to no options, the `?…` query string is omitted and only `<host><source>` remains.

  ### Examples

      iex> alias Image.Plug.Pipeline
      iex> alias Image.Plug.Pipeline.Ops
      iex> p = %Pipeline{ops: [%Ops.Resize{width: 200}], output: nil}
      iex> Image.Components.URL.imgix(p, source_path: "/abc.jpg")
      "/abc.jpg?w=200"

  """
  @spec imgix(Pipeline.t(), options()) :: String.t()
  def imgix(%Pipeline{} = pipeline, options \\ []) do
    query = pipeline |> imgix_options() |> URI.encode_query()
    source = source_path(options)
    host = Keyword.get(options, :host, "")

    url =
      case query do
        "" -> "#{host}#{source}"
        q -> "#{host}#{source}?#{q}"
      end

    maybe_sign(url, options, :imgix)
  end

  @doc """
  Projects the pipeline onto the ImageKit URL grammar: `<host>/<endpoint>/tr:<options>/<source>`.

  ### Arguments

  * `pipeline` is an `Image.Plug.Pipeline.t()`.

  * `options` is a keyword list — see the Options section.

  ### Options

  * `:source_path` is the URL-relative source path. Defaults to `\"/sample.jpg\"`.

  * `:host` is prepended verbatim. Defaults to `\"\"` (relative URL).

  * `:imagekit_endpoint` is the per-account endpoint segment. Defaults to `\"demo\"`.

  ### Returns

  * The projected URL as a string. When the pipeline projects to no options, the `tr:` segment is omitted and the URL collapses to `<host>/<endpoint>/<source>`.

  ### Examples

      iex> alias Image.Plug.Pipeline
      iex> alias Image.Plug.Pipeline.Ops
      iex> p = %Pipeline{ops: [%Ops.Resize{width: 200}], output: nil}
      iex> Image.Components.URL.imagekit(p, source_path: "/abc.jpg")
      "/demo/tr:w-200/abc.jpg"

  """
  @spec imagekit(Pipeline.t(), options()) :: String.t()
  def imagekit(%Pipeline{} = pipeline, options \\ []) do
    options_segment = pipeline |> imagekit_options() |> Enum.join(",")
    source = source_path(options) |> trim_leading_slash()
    endpoint = Keyword.get(options, :imagekit_endpoint, "demo")
    host = Keyword.get(options, :host, "")

    url =
      case options_segment do
        "" -> "#{host}/#{endpoint}/#{source}"
        seg -> "#{host}/#{endpoint}/tr:#{seg}/#{source}"
      end

    maybe_sign(url, options, :imagekit)
  end

  @doc """
  Projects the pipeline onto the [IIIF Image API 3.0](https://iiif.io/api/image/3.0/) URL grammar: `<host><prefix>/<identifier>/<region>/<size>/<rotation>/<quality>.<format>`.

  ### Arguments

  * `pipeline` is an `Image.Plug.Pipeline.t()`.

  * `options` is a keyword list — see the Options section.

  ### Options

  * `:source_path` is the URL-relative source path used as the IIIF identifier. Leading `/` is stripped; embedded `/` characters are percent-encoded as `%2F` per the spec. Defaults to `\"/sample.jpg\"`.

  * `:host` is prepended verbatim — e.g. `\"https://iiif.example.org\"` or `\"\"` for a relative URL.

  * `:iiif_prefix` is the server's IIIF version prefix (the `/{prefix}` segment in the spec). Typical values: `\"/iiif/3\"` for an Image API 3.0 server, `\"/cantaloupe/iiif/3\"` for Cantaloupe deployments. Defaults to `\"/iiif/3\"`.

  * `:iiif_format` is the format extension used when the pipeline's output `Format.type` is `:auto` (IIIF requires an explicit format in the URL). Defaults to `:jpeg`.

  ### Returns

  * The projected URL as a string. The five positional segments are always emitted: `region`, `size`, `rotation`, `quality.format`. A pipeline with no transforms produces `<host>/iiif/3/<id>/full/max/0/default.jpg`.

  ### Conformance gaps

  IIIF's URL grammar is narrower than the IR. The following ops project to `default` / `max` / `full` rather than the requested transform — see `guides/iiif.md` (in `image_plug`) for the per-op detail:

    * `Resize{fit: :cover}` — IIIF cannot express "scale-to-fill plus centred crop" in one URL. Drop and use `:contain` or `:squeeze` instead, or supply an explicit `Crop` op for the region you want.
    * `Blur`, `Sharpen`, `Vignette`, `Tint`, `Background`, non-grayscale `Adjust`, `face_zoom`, `gravity` — silently dropped. The IIIF spec scopes to a small set of geometric and quality transforms; effects are out of scope.

  ### Examples

      iex> alias Image.Plug.Pipeline
      iex> alias Image.Plug.Pipeline.Ops
      iex> p = %Pipeline{ops: [%Ops.Resize{width: 600}], output: %Ops.Format{type: :jpeg, quality: 80}}
      iex> Image.Components.URL.iiif(p, source_path: "/cat.jpg", host: "https://iiif.example.org")
      "https://iiif.example.org/iiif/3/cat.jpg/full/^600,/0/default.jpg"

      iex> alias Image.Plug.Pipeline
      iex> Image.Components.URL.iiif(%Pipeline{ops: [], output: nil}, source_path: "/cat.jpg")
      "/iiif/3/cat.jpg/full/max/0/default.jpg"

  """
  @spec iiif(Pipeline.t(), options()) :: String.t()
  def iiif(%Pipeline{} = pipeline, options \\ []) do
    identifier = source_path(options) |> trim_leading_slash() |> iiif_encode_identifier()
    host = Keyword.get(options, :host, "")
    prefix = Keyword.get(options, :iiif_prefix, "/iiif/3")
    fmt_default = Keyword.get(options, :iiif_format, :jpeg)

    region = iiif_region(find_op(pipeline, Ops.Crop))
    size = iiif_size(find_op(pipeline, Ops.Resize))
    rotation = iiif_rotation(find_op(pipeline, Ops.Rotate))
    quality = iiif_quality(pipeline)
    format = iiif_format_extension(pipeline.output, fmt_default)

    url = "#{host}#{prefix}/#{identifier}/#{region}/#{size}/#{rotation}/#{quality}.#{format}"
    maybe_sign(url, options, :iiif)
  end

  @doc """
  Builds the URL of an IIIF identifier's [`info.json` discovery
  document](https://iiif.io/api/image/3.0/#5-image-information).

  This is the entry point a deep-zoom viewer (OpenSeadragon, Mirador,
  Leaflet-IIIF) reads to discover the source dimensions, available
  tile sizes, supported features, and rendering profile. It is also
  the canonical IIIF identity URL — the same URL appears as `id` in
  the `info.json` body.

  ### Arguments

  * `options` is a keyword list — see the Options section.

  ### Options

  * `:source_path` is the URL-relative source path used as the IIIF
    identifier. Default `\"/sample.jpg\"`.

  * `:host` is prepended verbatim. Default `\"\"`.

  * `:iiif_prefix` is the server's IIIF version prefix. Default
    `\"/iiif/3\"`.

  ### Returns

  * The URL of the `info.json` document as a string.

  ### Examples

      iex> Image.Components.URL.iiif_info_url(source_path: "/cat.jpg", host: "https://iiif.example.org")
      "https://iiif.example.org/iiif/3/cat.jpg/info.json"

      iex> Image.Components.URL.iiif_info_url(source_path: "/sub/cat.jpg")
      "/iiif/3/sub%2Fcat.jpg/info.json"

  """
  @spec iiif_info_url(options()) :: String.t()
  def iiif_info_url(options \\ []) do
    identifier = source_path(options) |> trim_leading_slash() |> iiif_encode_identifier()
    host = Keyword.get(options, :host, "")
    prefix = Keyword.get(options, :iiif_prefix, "/iiif/3")
    "#{host}#{prefix}/#{identifier}/info.json"
  end

  # ─── shared helpers ──────────────────────────────────────────────

  defp source_path(options), do: Keyword.get(options, :source_path, "/sample.jpg")
  defp trim_leading_slash("/" <> rest), do: rest
  defp trim_leading_slash(s), do: s

  defp find_op(pipeline, module) do
    Enum.find(pipeline.ops, fn op -> op.__struct__ == module end)
  end

  # Dispatch a built URL to the right per-provider signer when
  # `:sign` is supplied in the options. The signer takes the
  # request path-and-query (origin stripped, since back-end
  # verifiers ignore the origin) and returns it with the
  # vendor-specific signature appended. Each vendor uses a
  # different algorithm and parameter name — see the per-provider
  # `Image.Components.Signing.*` modules.
  defp maybe_sign(url, options, provider) do
    case Keyword.get(options, :sign) do
      nil ->
        url

      [_ | _] = keys ->
        sign_options =
          case Keyword.get(options, :sign_expires_at) do
            nil -> []
            value -> [expires_at: value]
          end

        {origin, path} = split_origin(url)
        signer = signer_for(provider)
        origin <> signer.sign(path, keys, sign_options)
    end
  end

  defp signer_for(:cloudflare), do: Image.Components.Signing
  defp signer_for(:cloudinary), do: Image.Components.Signing.Cloudinary
  defp signer_for(:imgix), do: Image.Components.Signing.Imgix
  defp signer_for(:imagekit), do: Image.Components.Signing.ImageKit
  # IIIF doesn't have a standard URL-signing scheme — IIIF Auth API
  # 2.0 uses cookie/token-based access control at the protocol
  # level, not URL signatures. Fall back to the Cloudflare-style
  # generic HMAC for callers that want SOMETHING; document this
  # as not standardised.
  defp signer_for(:iiif), do: Image.Components.Signing

  defp split_origin("http://" <> _ = url), do: split_origin_at_path(url)
  defp split_origin("https://" <> _ = url), do: split_origin_at_path(url)
  defp split_origin(path), do: {"", path}

  defp split_origin_at_path(url) do
    case String.split(url, "/", parts: 4) do
      [scheme, "", host, rest] -> {scheme <> "//" <> host, "/" <> rest}
      _ -> {"", url}
    end
  end

  # ─── Cloudflare projection ────────────────────────────────────────

  defp cloudflare_options(pipeline) do
    [
      cloudflare_resize(find_op(pipeline, Ops.Resize)),
      cloudflare_format(pipeline.output),
      cloudflare_adjust(find_op(pipeline, Ops.Adjust)),
      cloudflare_blur_sharpen(pipeline),
      cloudflare_rotate(find_op(pipeline, Ops.Rotate)),
      cloudflare_trim(find_op(pipeline, Ops.Trim)),
      cloudflare_background(find_op(pipeline, Ops.Background))
    ]
    |> Enum.concat()
  end

  defp cloudflare_resize(nil), do: []

  defp cloudflare_resize(%Ops.Resize{} = r) do
    []
    |> opt("width", r.width)
    |> opt("height", r.height)
    |> opt_unless_default("fit", r.fit && cloudflare_fit(r.fit), "scale-down")
    |> opt_unless_default("gravity", r.gravity && cloudflare_gravity(r.gravity), "center")
    |> opt_unless_default("dpr", r.dpr, 1)
    |> opt_unless_default("face-zoom", r.face_zoom, 0.0)
  end

  defp cloudflare_fit(:contain), do: "scale-down"
  defp cloudflare_fit(:cover), do: "cover"
  defp cloudflare_fit(:crop), do: "crop"
  defp cloudflare_fit(:pad), do: "pad"
  defp cloudflare_fit(:scale_down), do: "scale-down"
  defp cloudflare_fit(:squeeze), do: "contain"
  defp cloudflare_fit(other), do: to_string(other)

  defp cloudflare_gravity(:center), do: "center"
  defp cloudflare_gravity(:auto), do: "auto"
  defp cloudflare_gravity(:face), do: "face"
  defp cloudflare_gravity(:north), do: "top"
  defp cloudflare_gravity(:south), do: "bottom"
  defp cloudflare_gravity(:east), do: "right"
  defp cloudflare_gravity(:west), do: "left"
  defp cloudflare_gravity({:xy, x, y}), do: "#{Float.round(x, 2)}x#{Float.round(y, 2)}"
  defp cloudflare_gravity(other), do: to_string(other)

  defp cloudflare_format(nil), do: []

  defp cloudflare_format(%Ops.Format{} = f) do
    []
    |> opt_unless_default("format", cloudflare_format_atom(f.type), "auto")
    |> opt_unless_default("quality", f.quality, 85)
    |> opt_unless_default("metadata", f.metadata, :copyright)
  end

  defp cloudflare_format_atom(:auto), do: "auto"
  defp cloudflare_format_atom(:jpeg), do: "jpeg"
  defp cloudflare_format_atom(:baseline_jpeg), do: "baseline-jpeg"
  defp cloudflare_format_atom(:png), do: "png"
  defp cloudflare_format_atom(:webp), do: "webp"
  defp cloudflare_format_atom(:avif), do: "avif"
  defp cloudflare_format_atom(other), do: to_string(other)

  defp cloudflare_adjust(nil), do: []

  defp cloudflare_adjust(%Ops.Adjust{} = a) do
    # Cloudflare takes brightness/contrast/saturation/gamma as raw
    # multipliers where 1.0 means "no change" (the same units as the
    # IR). Cloudinary and imgix want centred percentages — handled
    # separately in the per-provider sections below.
    []
    |> opt_unless_default("brightness", format_multiplier(a.brightness), "1")
    |> opt_unless_default("contrast", format_multiplier(a.contrast), "1")
    |> opt_unless_default("saturation", format_multiplier(a.saturation), "1")
    |> opt_unless_default("gamma", format_multiplier(a.gamma), "1")
  end

  defp cloudflare_blur_sharpen(pipeline) do
    blur =
      case find_op(pipeline, Ops.Blur) do
        %Ops.Blur{sigma: s} when s > 0 -> opt([], "blur", round(s * 2))
        _ -> []
      end

    sharpen =
      case find_op(pipeline, Ops.Sharpen) do
        %Ops.Sharpen{sigma: s} when s > 0 -> opt([], "sharpen", round(s * 10))
        _ -> []
      end

    blur ++ sharpen
  end

  defp cloudflare_rotate(nil), do: []
  defp cloudflare_rotate(%Ops.Rotate{angle: 0}), do: []
  defp cloudflare_rotate(%Ops.Rotate{angle: a}), do: opt([], "rotate", a)

  defp cloudflare_trim(nil), do: []
  defp cloudflare_trim(%Ops.Trim{mode: :border}), do: opt([], "trim", "border")
  defp cloudflare_trim(_), do: []

  defp cloudflare_background(nil), do: []

  defp cloudflare_background(%Ops.Background{color: color}),
    do: opt([], "background", to_string(color))

  # ─── generic opt helpers ──────────────────────────────────────────

  defp opt(acc, _key, nil), do: acc
  defp opt(acc, _key, false), do: acc
  defp opt(acc, key, value), do: acc ++ ["#{key}=#{value}"]

  defp opt_unless_default(acc, _key, value, default) when value == default, do: acc
  defp opt_unless_default(acc, key, value, _default), do: opt(acc, key, value)

  # Cloudflare expects raw multipliers as compact numeric strings
  # (e.g. `1.4` not `1.4000`). Render integers without a decimal.
  defp format_multiplier(v) when is_number(v) do
    cond do
      v == trunc(v) -> Integer.to_string(trunc(v))
      true -> :erlang.float_to_binary(v * 1.0, [:compact, decimals: 4])
    end
  end

  # ─── Cloudinary projection ────────────────────────────────────────

  defp cloudinary_options(pipeline) do
    [
      cloudinary_resize(find_op(pipeline, Ops.Resize)),
      cloudinary_format(pipeline.output),
      cloudinary_adjust_effects(find_op(pipeline, Ops.Adjust)),
      cloudinary_blur_sharpen(pipeline),
      cloudinary_vignette(find_op(pipeline, Ops.Vignette))
    ]
    |> Enum.concat()
  end

  defp cloudinary_vignette(nil), do: []

  defp cloudinary_vignette(%Ops.Vignette{strength: s}) when s > 0,
    do: ["e_vignette:#{round(s * 100)}"]

  defp cloudinary_vignette(_), do: []

  defp cloudinary_resize(nil), do: []

  defp cloudinary_resize(%Ops.Resize{} = r) do
    []
    |> cl_opt("w", r.width)
    |> cl_opt("h", r.height)
    |> cl_opt_unless_default("dpr", r.dpr, 1)
    |> cl_opt_unless_default("c", cloudinary_fit(r.fit), "fit")
    |> cl_opt_unless_default("g", cloudinary_gravity(r.gravity), "center")
    # `z_<float>` is only valid in Cloudinary's URL grammar when
    # paired with `g_face` — emitting `z_` alone causes the parser
    # to treat the rest of the path as malformed and return
    # `source_not_found`. Gate on gravity to avoid that.
    |> cl_opt_unless_default("z", cloudinary_face_zoom(r.gravity, r.face_zoom), nil)
  end

  defp cloudinary_face_zoom(:face, z) when is_number(z) and z > 0.0 do
    :erlang.float_to_binary(z * 1.0, [:compact, decimals: 4])
  end

  defp cloudinary_face_zoom(_gravity, _z), do: nil

  defp cloudinary_fit(:contain), do: "fit"
  defp cloudinary_fit(:cover), do: "fill"
  defp cloudinary_fit(:crop), do: "crop"
  defp cloudinary_fit(:pad), do: "pad"
  defp cloudinary_fit(:scale_down), do: "limit"
  defp cloudinary_fit(:squeeze), do: "scale"
  defp cloudinary_fit(_), do: nil

  defp cloudinary_gravity(:center), do: "center"
  defp cloudinary_gravity(:auto), do: "auto"
  defp cloudinary_gravity(:face), do: "face"
  defp cloudinary_gravity(:north), do: "north"
  defp cloudinary_gravity(:north_east), do: "north_east"
  defp cloudinary_gravity(:north_west), do: "north_west"
  defp cloudinary_gravity(:south), do: "south"
  defp cloudinary_gravity(:south_east), do: "south_east"
  defp cloudinary_gravity(:south_west), do: "south_west"
  defp cloudinary_gravity(:east), do: "east"
  defp cloudinary_gravity(:west), do: "west"
  defp cloudinary_gravity({:xy, _, _}), do: "xy_center"
  defp cloudinary_gravity(_), do: nil

  defp cloudinary_format(nil), do: []

  defp cloudinary_format(%Ops.Format{} = f) do
    []
    |> cl_opt_unless_default("f", cloudinary_format_atom(f.type), "auto")
    |> cl_opt_unless_default("q", f.quality, 85)
  end

  defp cloudinary_format_atom(:auto), do: "auto"
  defp cloudinary_format_atom(:jpeg), do: "jpg"
  defp cloudinary_format_atom(:png), do: "png"
  defp cloudinary_format_atom(:webp), do: "webp"
  defp cloudinary_format_atom(:avif), do: "avif"
  defp cloudinary_format_atom(_), do: nil

  defp cloudinary_adjust_effects(nil), do: []

  defp cloudinary_adjust_effects(%Ops.Adjust{} = a) do
    []
    |> cloudinary_centered_effect("brightness", a.brightness)
    |> cloudinary_centered_effect("contrast", a.contrast)
    |> cloudinary_centered_effect("saturation", a.saturation)
    |> cloudinary_centered_effect("gamma", a.gamma)
  end

  defp cloudinary_centered_effect(acc, _name, value) when value == 1.0, do: acc

  defp cloudinary_centered_effect(acc, name, value) do
    pct = round((value - 1.0) * 100)
    acc ++ ["e_#{name}:#{pct}"]
  end

  defp cloudinary_blur_sharpen(pipeline) do
    blur =
      case find_op(pipeline, Ops.Blur) do
        %Ops.Blur{sigma: s} when s > 0 -> ["e_blur:#{round(s * 100)}"]
        _ -> []
      end

    sharpen =
      case find_op(pipeline, Ops.Sharpen) do
        %Ops.Sharpen{sigma: s} when s > 0 -> ["e_sharpen:#{round(s * 10)}"]
        _ -> []
      end

    blur ++ sharpen
  end

  defp cl_opt(acc, _key, nil), do: acc
  defp cl_opt(acc, key, value), do: acc ++ ["#{key}_#{value}"]

  defp cl_opt_unless_default(acc, _key, value, default) when value == default, do: acc
  defp cl_opt_unless_default(acc, key, value, _default), do: cl_opt(acc, key, value)

  # ─── imgix projection ─────────────────────────────────────────────

  defp imgix_options(pipeline) do
    [
      imgix_resize(find_op(pipeline, Ops.Resize)),
      imgix_format(pipeline.output),
      imgix_adjust(find_op(pipeline, Ops.Adjust)),
      imgix_blur_sharpen(pipeline),
      imgix_rotate(find_op(pipeline, Ops.Rotate)),
      imgix_trim(find_op(pipeline, Ops.Trim)),
      imgix_background(find_op(pipeline, Ops.Background)),
      imgix_tint(find_op(pipeline, Ops.Tint))
    ]
    |> Enum.concat()
  end

  # imgix's `monochrome=<hex>` is the closest analog to a tint
  # op — it produces a luminance-tinted monochrome. `Ops.Tint`'s
  # type is `[non_neg_integer()]`; the `Image.Components.image/1`
  # component normalises hex strings into the list form before
  # building the pipeline, so we only need to handle that here.
  defp imgix_tint(nil), do: []
  defp imgix_tint(%Ops.Tint{color: [r, g, b]}), do: qs([], "monochrome", rgb_to_hex(r, g, b))
  defp imgix_tint(_), do: []

  defp rgb_to_hex(r, g, b) do
    [r, g, b]
    |> Enum.map_join("", fn n ->
      n |> Integer.to_string(16) |> String.pad_leading(2, "0") |> String.downcase()
    end)
  end

  defp imgix_resize(nil), do: []

  defp imgix_resize(%Ops.Resize{} = r) do
    []
    |> qs("w", r.width)
    |> qs("h", r.height)
    |> qs_unless_default("dpr", r.dpr, 1)
    |> qs_unless_default("fit", imgix_fit(r.fit), "clip")
    |> qs("crop", imgix_crop(r.gravity))
  end

  defp imgix_fit(:contain), do: "clip"
  defp imgix_fit(:cover), do: "crop"
  defp imgix_fit(:crop), do: "crop"
  defp imgix_fit(:pad), do: "fill"
  defp imgix_fit(:scale_down), do: "max"
  defp imgix_fit(:squeeze), do: "scale"
  defp imgix_fit(_), do: nil

  defp imgix_crop(:center), do: nil
  defp imgix_crop(:north), do: "top"
  defp imgix_crop(:south), do: "bottom"
  defp imgix_crop(:east), do: "right"
  defp imgix_crop(:west), do: "left"
  defp imgix_crop(:north_east), do: "top,right"
  defp imgix_crop(:north_west), do: "top,left"
  defp imgix_crop(:south_east), do: "bottom,right"
  defp imgix_crop(:south_west), do: "bottom,left"
  defp imgix_crop(:face), do: "faces"
  defp imgix_crop(:auto), do: "entropy"
  defp imgix_crop({:xy, _, _}), do: "focalpoint"
  defp imgix_crop(_), do: nil

  defp imgix_format(nil), do: []

  defp imgix_format(%Ops.Format{type: :auto, quality: q}) do
    [{"auto", "format,compress"}] |> qs_unless_default("q", q, 75)
  end

  defp imgix_format(%Ops.Format{} = f) do
    []
    |> qs("fm", imgix_format_atom(f.type))
    |> qs_unless_default("q", f.quality, 75)
  end

  defp imgix_format_atom(:jpeg), do: "jpg"
  defp imgix_format_atom(:baseline_jpeg), do: "pjpg"
  defp imgix_format_atom(:png), do: "png"
  defp imgix_format_atom(:webp), do: "webp"
  defp imgix_format_atom(:avif), do: "avif"
  defp imgix_format_atom(_), do: nil

  defp imgix_adjust(nil), do: []

  defp imgix_adjust(%Ops.Adjust{} = a) do
    []
    |> qs_centered("bri", a.brightness)
    |> qs_centered("con", a.contrast)
    |> qs_centered("sat", a.saturation)
    |> qs_centered("gam", a.gamma)
  end

  defp qs_centered(acc, _key, value) when value == 1.0, do: acc
  defp qs_centered(acc, key, value), do: qs(acc, key, round((value - 1.0) * 100))

  defp imgix_blur_sharpen(pipeline) do
    blur =
      case find_op(pipeline, Ops.Blur) do
        %Ops.Blur{sigma: s} when s > 0 -> qs([], "blur", round(s * 100))
        _ -> []
      end

    sharpen =
      case find_op(pipeline, Ops.Sharpen) do
        %Ops.Sharpen{sigma: s} when s > 0 -> qs([], "sharp", round(s * 10))
        _ -> []
      end

    blur ++ sharpen
  end

  defp imgix_rotate(nil), do: []
  defp imgix_rotate(%Ops.Rotate{angle: 0}), do: []
  defp imgix_rotate(%Ops.Rotate{angle: a}), do: qs([], "rot", a)

  defp imgix_trim(nil), do: []
  defp imgix_trim(%Ops.Trim{mode: :border}), do: qs([], "trim", "auto")
  defp imgix_trim(_), do: []

  defp imgix_background(nil), do: []
  defp imgix_background(%Ops.Background{color: c}), do: qs([], "bg", to_string(c))

  defp qs(acc, _key, nil), do: acc
  defp qs(acc, key, value), do: acc ++ [{key, to_string(value)}]

  defp qs_unless_default(acc, _key, value, default) when value == default, do: acc
  defp qs_unless_default(acc, key, value, _default), do: qs(acc, key, value)

  # ─── ImageKit projection ──────────────────────────────────────────

  defp imagekit_options(pipeline) do
    [
      imagekit_resize(find_op(pipeline, Ops.Resize)),
      imagekit_format(pipeline.output),
      imagekit_blur_sharpen(pipeline),
      imagekit_rotate(find_op(pipeline, Ops.Rotate)),
      imagekit_background(find_op(pipeline, Ops.Background))
    ]
    |> Enum.concat()
  end

  defp imagekit_resize(nil), do: []

  defp imagekit_resize(%Ops.Resize{} = r) do
    []
    |> ik_opt("w", r.width)
    |> ik_opt("h", r.height)
    |> ik_opt_unless_default("dpr", r.dpr, 1)
    |> ik_opt_unless_default("c", imagekit_fit(r.fit), "maintain_ratio")
    |> ik_opt_unless_default("fo", imagekit_focus(r.gravity), "center")
    # ImageKit's `z-<float>` is face-zoom in `[0, 1]`. Only
    # meaningful with `fo-face`; emitted regardless to match the
    # IR.
    |> ik_opt_unless_default("z", imagekit_face_zoom(r.face_zoom), nil)
  end

  defp imagekit_face_zoom(z) when is_number(z) and z > 0.0 do
    :erlang.float_to_binary(z * 1.0, [:compact, decimals: 4])
  end

  defp imagekit_face_zoom(_), do: nil

  defp imagekit_fit(:contain), do: "maintain_ratio"
  defp imagekit_fit(:cover), do: "extract"
  defp imagekit_fit(:crop), do: "extract"
  defp imagekit_fit(:pad), do: "pad_resize"
  defp imagekit_fit(:scale_down), do: "at_max"
  defp imagekit_fit(:squeeze), do: "force"
  defp imagekit_fit(_), do: nil

  defp imagekit_focus(:center), do: "center"
  defp imagekit_focus(:auto), do: "auto"
  defp imagekit_focus(:face), do: "face"
  defp imagekit_focus(:north), do: "top"
  defp imagekit_focus(:south), do: "bottom"
  defp imagekit_focus(:east), do: "right"
  defp imagekit_focus(:west), do: "left"
  defp imagekit_focus(:north_east), do: "top_right"
  defp imagekit_focus(:north_west), do: "top_left"
  defp imagekit_focus(:south_east), do: "bottom_right"
  defp imagekit_focus(:south_west), do: "bottom_left"
  defp imagekit_focus({:xy, _, _}), do: "custom"
  defp imagekit_focus(_), do: nil

  defp imagekit_format(nil), do: []

  defp imagekit_format(%Ops.Format{} = f) do
    []
    |> ik_opt_unless_default("f", imagekit_format_atom(f.type), "auto")
    |> ik_opt_unless_default("q", f.quality, 80)
  end

  defp imagekit_format_atom(:auto), do: "auto"
  defp imagekit_format_atom(:jpeg), do: "jpg"
  defp imagekit_format_atom(:png), do: "png"
  defp imagekit_format_atom(:webp), do: "webp"
  defp imagekit_format_atom(:avif), do: "avif"
  defp imagekit_format_atom(_), do: nil

  defp imagekit_blur_sharpen(pipeline) do
    blur =
      case find_op(pipeline, Ops.Blur) do
        %Ops.Blur{sigma: s} when s > 0 -> ["e-blur-#{round(s * 100)}"]
        _ -> []
      end

    sharpen =
      case find_op(pipeline, Ops.Sharpen) do
        %Ops.Sharpen{sigma: s} when s > 0 -> ["e-sharpen-#{round(s * 10)}"]
        _ -> []
      end

    blur ++ sharpen
  end

  defp imagekit_rotate(nil), do: []
  defp imagekit_rotate(%Ops.Rotate{angle: 0}), do: []
  defp imagekit_rotate(%Ops.Rotate{angle: a}), do: ["rt-#{a}"]

  defp imagekit_background(nil), do: []
  defp imagekit_background(%Ops.Background{color: c}), do: ["bg-#{c}"]

  defp ik_opt(acc, _key, nil), do: acc
  defp ik_opt(acc, key, value), do: acc ++ ["#{key}-#{value}"]

  defp ik_opt_unless_default(acc, _key, value, default) when value == default, do: acc
  defp ik_opt_unless_default(acc, key, value, _default), do: ik_opt(acc, key, value)

  # ─── IIIF Image API 3.0 projection ───────────────────────────────
  #
  # IIIF URL grammar is positional, not key=value:
  #
  #   {host}{prefix}/{identifier}/{region}/{size}/{rotation}/{quality}.{format}
  #
  # The five segments are always emitted (the spec requires it).
  # Where the IR has no equivalent for a IIIF concept, we emit the
  # spec's "no-op" sentinel: `full` for region, `max` for size,
  # `0` for rotation, `default` for quality.

  # Identifier — strip leading `/`, percent-encode any embedded
  # `/` (so `subdir/cat.jpg` becomes `subdir%2Fcat.jpg`). Other
  # reserved chars left to URI.encode.
  defp iiif_encode_identifier(path) do
    path
    |> URI.encode(&URI.char_unreserved?/1)
  end

  # Region — `Ops.Crop` projects to the IIIF region segment.
  # When absent, defaults to `full`. The :cover-fit collision
  # documented in the moduledoc is NOT auto-mapped to `square`
  # (decision: drop).
  defp iiif_region(nil), do: "full"

  defp iiif_region(%{__struct__: mod, x: x, y: y, width: w, height: h, units: units})
       when mod == Ops.Crop do
    case units do
      :percent ->
        "pct:#{format_iiif_num(x)},#{format_iiif_num(y)},#{format_iiif_num(w)},#{format_iiif_num(h)}"

      _ ->
        "#{x},#{y},#{w},#{h}"
    end
  end

  # Defensive — absorb a Crop op even if `Ops.Crop` is not yet
  # defined in image_plug. Lets Phase 1 ship before Phase 2 lands
  # the IR addition; it just always produces `full`.
  defp iiif_region(_), do: "full"

  # Size — Resize maps onto the size segment with the spec's
  # comma syntax. Upscale prefix `^` (3.0 only) is emitted when
  # `Resize.upscale?` is true.
  defp iiif_size(nil), do: "max"

  defp iiif_size(%Ops.Resize{} = r) do
    upscale_prefix = if Map.get(r, :upscale?, true), do: "^", else: ""
    body = iiif_size_body(r)
    "#{upscale_prefix}#{body}"
  end

  defp iiif_size_body(%Ops.Resize{width: nil, height: nil} = r) do
    case Map.get(r, :size_pct) do
      nil -> "max"
      0 -> "max"
      pct when is_number(pct) and pct > 0 -> "pct:#{format_iiif_num(pct)}"
      _ -> "max"
    end
  end

  defp iiif_size_body(%Ops.Resize{width: w, height: nil}), do: "#{w},"
  defp iiif_size_body(%Ops.Resize{width: nil, height: h}), do: ",#{h}"

  defp iiif_size_body(%Ops.Resize{width: w, height: h, fit: :contain}), do: "!#{w},#{h}"
  defp iiif_size_body(%Ops.Resize{width: w, height: h, fit: :squeeze}), do: "#{w},#{h}"
  # `:cover` and `:crop` cannot be expressed in IIIF without an explicit
  # Crop op alongside. Document the gap by emitting the closest exact
  # form (`w,h` — distorts) but the conformance guide warns against it.
  defp iiif_size_body(%Ops.Resize{width: w, height: h}), do: "#{w},#{h}"

  # Rotation — IIIF 3.0 accepts any 0..360 angle plus an optional
  # leading `!` for mirror-then-rotate. We don't currently emit
  # the mirror form (no IR for it).
  defp iiif_rotation(nil), do: "0"
  defp iiif_rotation(%Ops.Rotate{angle: a}) when is_number(a), do: format_iiif_num(a)
  defp iiif_rotation(_), do: "0"

  # Quality — IIIF defines `default | color | gray | bitonal`.
  # We map saturation=0 to `gray` and Posterize{levels: 2} to
  # `bitonal`; everything else is `default`.
  defp iiif_quality(pipeline) do
    cond do
      iiif_grayscale?(pipeline) -> "gray"
      iiif_bitonal?(pipeline) -> "bitonal"
      true -> "default"
    end
  end

  defp iiif_grayscale?(pipeline) do
    case find_op(pipeline, Ops.Adjust) do
      %Ops.Adjust{saturation: s} when s == +0.0 -> true
      _ -> false
    end
  end

  defp iiif_bitonal?(pipeline) do
    case find_op(pipeline, Ops.Posterize) do
      %{levels: 2} -> true
      _ -> false
    end
  end

  # Format — IIIF requires a literal extension. `:auto` falls
  # back to the configured `:iiif_format` default (jpg).
  defp iiif_format_extension(nil, default), do: iiif_format_atom(default)
  defp iiif_format_extension(%Ops.Format{type: :auto}, default), do: iiif_format_atom(default)
  defp iiif_format_extension(%Ops.Format{type: type}, _default), do: iiif_format_atom(type)

  defp iiif_format_atom(:jpeg), do: "jpg"
  defp iiif_format_atom(:png), do: "png"
  defp iiif_format_atom(:gif), do: "gif"
  defp iiif_format_atom(:webp), do: "webp"
  defp iiif_format_atom(:tiff), do: "tif"
  defp iiif_format_atom(:jp2), do: "jp2"
  defp iiif_format_atom(:pdf), do: "pdf"
  defp iiif_format_atom(_), do: "jpg"

  # IIIF numbers — integers as integers, floats compactly. The
  # spec doesn't mandate trailing-zero stripping but cleaner URLs.
  defp format_iiif_num(n) when is_integer(n), do: Integer.to_string(n)

  defp format_iiif_num(n) when is_float(n) do
    if n == trunc(n),
      do: Integer.to_string(trunc(n)),
      else: :erlang.float_to_binary(n, [:compact, decimals: 4])
  end
end