defmodule Nx.Shape do
  # Conveniences for manipulating shapes internal to Nx.
  @moduledoc false

  @doc """
  Validates a given shape with `kind`.

  ## Examples

      iex> Nx.Shape.validate!({1, 2, 3}, :window_dimensions)
      {1, 2, 3}

      iex> Nx.Shape.validate!({0, 2, 3}, :window_dimensions)
      ** (ArgumentError) invalid dimension in axis 0 found in window_dimensions. Each dimension must be a positive integer, got 0 in shape {0, 2, 3}

  """
  def validate!(shape, kind) when is_tuple(shape) do
    validate!(shape, tuple_size(shape), kind)
  end

  def validate!(other, kind) do
    raise ArgumentError,
          "invalid #{kind}. #{kind} is a n-element tuple with the size of each dimension. " <>
            "Got: #{inspect(other)}"
  end

  defp validate!(shape, 0, _kind), do: shape

  defp validate!(shape, pos, kind) do
    dim = :erlang.element(pos, shape)

    cond do
      is_integer(dim) and dim > 0 ->
        validate!(shape, pos - 1, kind)

      is_struct(dim, Nx.Tensor) ->
        raise ArgumentError, """
        invalid dimension in axis #{pos - 1} found in #{kind}. Each dimension must be a \
        positive integer, but instead got a tensor as dimension: #{inspect(dim)}

        This may happen if you are trying to pass a dimension or a shape as an argument \
        to a defn function, for example:

            defn my_defn(dim) do
              Nx.iota({dim})
            end

        However, defn treats all arguments as inputs. To address this, you can pass \
        the dimension or the shape as an option instead:

            defn my_defn(opts \\\\ []) do
              opts = keyword!(opts, dim: 1)
              Nx.iota({opts[:dim]})
            end

        Invalid shape: #{inspect(shape)}
        """

      true ->
        raise ArgumentError,
              "invalid dimension in axis #{pos - 1} found in #{kind}. Each dimension must be " <>
                "a positive integer, got #{inspect(dim)} in shape #{inspect(shape)}"
    end
  end

  @doc """
  Converts a shape to an algebra document for inspection.
  """
  def to_algebra(shape, names, open, close) do
    shape
    |> Tuple.to_list()
    |> Enum.zip_with(names, fn
      number, nil ->
        Inspect.Algebra.concat([open, Integer.to_string(number), close])

      number, name ->
        Inspect.Algebra.concat([
          open,
          Atom.to_string(name),
          ": ",
          Integer.to_string(number),
          close
        ])
    end)
    |> Inspect.Algebra.concat()
  end

  @doc """
  Converts shape and name to a string.

  ## Examples

      iex> Nx.Shape.to_string({1, 2, 3}, [:foo, nil, :bat])
      "[foo: 1][2][bat: 3]"

  """
  def to_string(shape, names) do
    to_algebra(shape, names, "[", "]")
    |> Inspect.Algebra.format(:infinity)
    |> IO.iodata_to_binary()
  end

  @doc """
  Validates the names of axes.
  """
  def named_axes!(names, shape) do
    n_dims = tuple_size(shape)

    if names do
      n_names = length(names)

      if n_names != n_dims do
        raise ArgumentError,
              "invalid names for tensor of rank #{n_dims}," <>
                " when specifying names every dimension must" <>
                " have a name or be nil"
      else
        names
      end
    else
      List.duplicate(nil, n_dims)
    end
  end

  @doc """
  Finds the axis for the given name.
  """
  def find_name!(names, name) do
    Enum.find_index(names, &(&1 == name)) ||
      raise(
        ArgumentError,
        "tensor does not have name #{inspect(name)}. The tensor names are: #{inspect(names)}"
      )
  end

  @doc """
  Reshapes `old_shape` to `new_shape`.

  The product of all dimensions in `old_shape` must match the
  product of all dimensions in `new_shape`. You may optionally
  specify an `:auto` dimension to infer the shape of that dimension.

  ## Examples

      iex> Nx.Shape.reshape({}, {})
      {}

      iex> Nx.Shape.reshape({2, 3}, {1, 6})
      {1, 6}

      iex> Nx.Shape.reshape({2, 2, 2}, {:auto, 4})
      {2, 4}

  ## Error cases

      iex> Nx.Shape.reshape({2, 2, 2}, {2, 3})
      ** (ArgumentError) cannot reshape, current shape {2, 2, 2} is not compatible with new shape {2, 3}

      iex> Nx.Shape.reshape({1, 3}, {:auto, 4})
      ** (ArgumentError) cannot reshape, current shape {1, 3} is not compatible with new shape {4}
  """
  def reshape(old_shape, new_shape) do
    old_size = Tuple.product(old_shape)

    new_shape =
      case Enum.find_index(Tuple.to_list(new_shape), &(&1 == :auto)) do
        nil ->
          new_shape

        idx ->
          shape_without_auto = Tuple.delete_at(new_shape, idx)
          inferred_dim = div(old_size, Tuple.product(shape_without_auto))

          if inferred_dim == 0 do
            raise ArgumentError,
                  "cannot reshape, current shape #{inspect(old_shape)} is not compatible with " <>
                    "new shape #{inspect(shape_without_auto)}"
          end

          put_elem(new_shape, idx, inferred_dim)
      end

    if old_size != Tuple.product(new_shape) do
      raise ArgumentError,
            "cannot reshape, current shape #{inspect(old_shape)} is not compatible with " <>
              "new shape #{inspect(new_shape)}"
    end

    new_shape
  end

  @doc """
  Broadcasts a shape to a new shape.

  The dimensions of `shape` is expanded to match the
  dimensions of `new_shape` according to the axes
  mapping.

  ## Examples

  ### Scalars

      iex> Nx.Shape.broadcast!({}, {4, 2, 1, 5}, [])
      :ok

      iex> Nx.Shape.broadcast!({}, {}, [])
      :ok

  ### n-D shapes

      iex> Nx.Shape.broadcast!({1}, {2, 3, 4}, [2])
      :ok

      iex> Nx.Shape.broadcast!({4, 2, 3}, {4, 3, 4, 2, 3}, [2, 3, 4])
      :ok

  ### Custom axes

      iex> Nx.Shape.broadcast!({2}, {2, 3}, [0])
      :ok

  ## Error cases

      iex> Nx.Shape.broadcast!({4, 2, 2}, {1, 1}, [0, 1, 2])
      ** (ArgumentError) cannot broadcast tensor of dimensions {4, 2, 2} to {1, 1} with axes [0, 1, 2]

      iex> Nx.Shape.broadcast!({2, 2}, {2, 2, 2}, [1, 0])
      ** (ArgumentError) broadcast axes must be ordered, got 0 after 1
  """
  def broadcast!(old_shape, new_shape, axes, offset \\ 0)

  def broadcast!(old_shape, new_shape, axes, offset)
      when is_tuple(old_shape) and is_tuple(new_shape) and is_list(axes) do
    old_rank = tuple_size(old_shape)
    new_rank = tuple_size(new_shape)

    if length(axes) != old_rank do
      raise ArgumentError,
            "expected length of axes (#{length(axes)}) to match rank of shape (#{old_rank})"
    end

    if old_rank > new_rank or not valid_broadcast?(axes, 0, -1, old_shape, new_shape) do
      drop_axes = fn t -> t |> Tuple.to_list() |> Enum.drop(offset) |> List.to_tuple() end

      {old_shape, new_shape, axes} =
        if offset > 0 do
          {drop_axes.(old_shape), drop_axes.(new_shape),
           axes |> Enum.drop(offset) |> Enum.map(&(&1 - offset))}
        else
          {old_shape, new_shape, axes}
        end

      raise ArgumentError,
            "cannot broadcast tensor of dimensions #{inspect(old_shape)} " <>
              "to #{inspect(new_shape)} with axes #{inspect(axes)}"
    end

    :ok
  end

  defp valid_broadcast?([head | tail], axis, last, old_shape, new_shape) do
    if head < last do
      raise ArgumentError, "broadcast axes must be ordered, got #{head} after #{last}"
    end

    old_dim = elem(old_shape, axis)
    new_dim = elem(new_shape, head)

    (old_dim == 1 or old_dim == new_dim) and
      valid_broadcast?(tail, axis + 1, head, old_shape, new_shape)
  end

  defp valid_broadcast?([], _axis, _head, _old_shape, _new_shape), do: true

  @doc """
  Broadcasts two shapes to a common shape.

  The dimensions of either shape can be expanded to match
  the dimension of the other. This differs from a normal
  broadcast, where one shapes dimensions remain fixed,
  while the other's are expanded to match.

  ## Examples

  ### Scalar Shapes

      iex> Nx.Shape.binary_broadcast({}, [], {}, [])
      {{}, []}
      iex> Nx.Shape.binary_broadcast({}, [], {4, 2, 1, 5}, [:batch, nil, :data, nil])
      {{4, 2, 1, 5}, [:batch, nil, :data, nil]}

  ### n-D Shapes

      iex> Nx.Shape.binary_broadcast({8, 1, 6, 1}, [:batch, nil, :data, nil], {7, 1, 5}, [:time, :data, nil])
      {{8, 7, 6, 5}, [:batch, :time, :data, nil]}
      iex> Nx.Shape.binary_broadcast({7, 1, 5}, [:time, :data, nil], {8, 1, 6, 1},  [:batch, nil, :data, nil])
      {{8, 7, 6, 5}, [:batch, :time, :data, nil]}
      iex> Nx.Shape.binary_broadcast({5, 4}, [nil, nil], {1}, [:data])
      {{5, 4}, [nil, :data]}
      iex> Nx.Shape.binary_broadcast({3, 1}, [:x, :y], {15, 3, 5}, [:batch, :x, nil])
      {{15, 3, 5}, [:batch, :x, :y]}

  ## Error cases

      iex> Nx.Shape.binary_broadcast({4, 2, 5}, [nil, nil, nil], {3, 2, 5}, [:batch, :x, :y])
      ** (ArgumentError) cannot broadcast tensor of dimensions {4, 2, 5} to {3, 2, 5}

      iex> Nx.Shape.binary_broadcast({1, 2, 5}, [:batch, :x, :y], {3, 2, 5}, [:time, :x, :y])
      ** (ArgumentError) cannot merge name :batch on axis 0 with name :time on axis 0
  """
  def binary_broadcast(left_shape, left_names, right_shape, right_names)

  def binary_broadcast(shape, names, shape, names), do: {shape, names}

  def binary_broadcast(left_shape, left_names, right_shape, right_names)
      when is_tuple(left_shape) and is_tuple(right_shape) do
    left_rank = tuple_size(left_shape)
    right_rank = tuple_size(right_shape)
    rank = max(left_rank, right_rank)

    left_lower_and_names =
      shape_and_names_to_lower_ranked_list(
        left_shape,
        Enum.reverse(left_names),
        left_rank,
        rank
      )

    right_lower_and_names =
      shape_and_names_to_lower_ranked_list(
        right_shape,
        Enum.reverse(right_names),
        right_rank,
        rank
      )

    {left_lower, left_names} = Enum.unzip(left_lower_and_names)
    {right_lower, right_names} = Enum.unzip(right_lower_and_names)

    case binary_broadcast(left_lower, left_names, right_lower, right_names, [], [], rank) do
      {new_shape, new_names} ->
        {new_shape, new_names}

      :error ->
        raise ArgumentError,
              "cannot broadcast tensor of dimensions #{inspect(left_shape)} " <>
                "to #{inspect(right_shape)}"
    end
  end

  defp binary_broadcast(
         [ldim | ldims],
         [lname | lnames],
         [rdim | rdims],
         [rname | rnames],
         shape_acc,
         names_acc,
         axis
       )
       when rdim == 1 or ldim == 1 or rdim == ldim do
    axis = axis - 1
    shape_acc = [max(rdim, ldim) | shape_acc]
    names_acc = [merge_names!(lname, rname, axis, axis) | names_acc]
    binary_broadcast(ldims, lnames, rdims, rnames, shape_acc, names_acc, axis)
  end

  defp binary_broadcast([], [], [], [], shape_acc, names_acc, 0),
    do: {List.to_tuple(shape_acc), names_acc}

  defp binary_broadcast(_, _, _, _, _, _, _),
    do: :error

  defp shape_and_names_to_lower_ranked_list(_tuple, _names, 0, 0),
    do: []

  defp shape_and_names_to_lower_ranked_list(tuple, [], 0, rank),
    do: [{1, nil} | shape_and_names_to_lower_ranked_list(tuple, [], 0, rank - 1)]

  defp shape_and_names_to_lower_ranked_list(tuple, [n | names], size, rank),
    do: [
      {:erlang.element(size, tuple), n}
      | shape_and_names_to_lower_ranked_list(tuple, names, size - 1, rank - 1)
    ]

  @doc """
  Contracts a shape along the given axes.

  It expects the axes to have been normalized.

  ## Examples

      iex> Nx.Shape.contract({4, 1, 2}, [1], [:batch, :x, :y], false)
      {{4, 2}, [:batch, :y]}

      iex> Nx.Shape.contract({2, 4, 6, 5}, [1, 3], [:batch, :x, :y, :z], false)
      {{2, 6}, [:batch, :y]}

      iex> Nx.Shape.contract({1, 2, 3}, [], [:batch, :x, :y], false)
      {{1, 2, 3}, [:batch, :x, :y]}

      iex> Nx.Shape.contract({4, 2, 8}, [2], [:x, :y, :z], false)
      {{4, 2}, [:x, :y]}

      iex> Nx.Shape.contract({4, 2, 8}, [2], [:x, :y, :z], true)
      {{4, 2, 1}, [:x, :y, :z]}

  """
  def contract(shape, axes, names, keep_axes) do
    {new_shape, new_names} =
      Enum.unzip(contract(shape, axes, names, 0, tuple_size(shape), keep_axes))

    {List.to_tuple(new_shape), new_names}
  end

  defp contract(_shape, _axes, _names, n, n, _keep_axes) do
    []
  end

  defp contract(shape, axes, [name | names], i, n, keep_axes) do
    cond do
      i not in axes ->
        [{elem(shape, i), name} | contract(shape, axes, names, i + 1, n, keep_axes)]

      keep_axes ->
        [{1, name} | contract(shape, axes, names, i + 1, n, keep_axes)]

      true ->
        contract(shape, axes, names, i + 1, n, keep_axes)
    end
  end

  @doc """
  Transposes a shape according to the given permutation.

  ## Examples

    iex> Nx.Shape.transpose({4, 8, 2, 1}, [1, 0, 3, 2], [:batch, :channels, :height, :width])
    {{8, 4, 1, 2}, [:channels, :batch, :width, :height]}

  ## Error cases

    iex> Nx.Shape.transpose({4, 8, 2, 1}, [0, 1, 2], [:batch, nil, nil, nil])
    ** (ArgumentError) expected length of permutation (3) to match rank of shape (4)

  """
  def transpose(shape, permutation, names)

  def transpose(shape, permutation, names) when tuple_size(shape) == length(permutation) do
    {new_shape, new_names} =
      Enum.unzip(Enum.map(permutation, &{elem(shape, &1), Enum.at(names, &1)}))

    {List.to_tuple(new_shape), new_names}
  end

  def transpose(shape, permutation, _names) do
    raise ArgumentError,
          "expected length of permutation (#{length(permutation)})" <>
            " to match rank of shape (#{tuple_size(shape)})"
  end

  @doc """
  Computes the shape for zip_reduce.

  In order for the dimensions to be correct, the value of each shape
  at the given axes must match. It expects axes to have already been
  normalized.

  ## Examples

      iex> Nx.Shape.zip_reduce({1, 2, 3}, [0, 1], [:batch, :x, :y], {3, 1, 2}, [1, 2], [:batch, :x, :y])
      {{3, 3}, [:y, :batch]}

      iex> Nx.Shape.zip_reduce({1, 2, 3}, [0, 1], [nil, nil, nil], {1, 2, 3}, [1, 2], [nil, nil, nil])
      ** (ArgumentError) dot/zip expects shapes to be compatible, dimension 0 of left-side (1) does not equal dimension 1 of right-side (2)

      iex> Nx.Shape.zip_reduce({2, 2}, [1], [:x, :y], {2, 2}, [0], [:y, :x])
      ** (ArgumentError) operation would result in duplicate names [:x, :x], please rename your tensors to avoid duplicates
  """
  def zip_reduce(s1, axes1, names1, s2, axes2, names2) do
    validate_zip_reduce_axes!(s1, axes1, s2, axes2)
    {l1, n1} = Enum.unzip(contract(s1, axes1, names1, 0, tuple_size(s1), false))
    {l2, n2} = Enum.unzip(contract(s2, axes2, names2, 0, tuple_size(s2), false))
    new_names = n1 ++ n2

    non_nil_names = Enum.filter(new_names, &(&1 != nil))

    if length(non_nil_names) != length(Enum.uniq(non_nil_names)),
      do:
        raise(
          ArgumentError,
          "operation would result in duplicate names #{inspect(new_names)}," <>
            " please rename your tensors to avoid duplicates"
        )

    {List.to_tuple(l1 ++ l2), n1 ++ n2}
  end

  def validate_zip_reduce_axes!(s1, [a1 | axes1], s2, [a2 | axes2]) do
    d1 = elem(s1, a1)
    d2 = elem(s2, a2)

    if d1 == d2 do
      validate_zip_reduce_axes!(s1, axes1, s2, axes2)
    else
      raise ArgumentError,
            "dot/zip expects shapes to be compatible," <>
              " dimension #{a1} of left-side (#{d1}) does not equal" <>
              " dimension #{a2} of right-side (#{d2})"
    end
  end

  def validate_zip_reduce_axes!(_, [], _, []) do
    :ok
  end

  @doc """
  Returns a padding configuration based on the given pad mode
  for the given input shape, kernel size and stride.

  By default, interior padding is not considered in the padding
  configuration.

  ## Examples

      iex> Nx.Shape.to_padding_config({2, 3, 2}, {2, 3, 2}, :valid)
      [{0, 0}, {0, 0}, {0, 0}]

      iex> Nx.Shape.to_padding_config({12, 12}, {2, 2}, :same)
      [{0, 1}, {0, 1}]

  ## Error cases

      iex> Nx.Shape.to_padding_config({2, 3, 2}, {2, 3, 2}, :foo)
      ** (ArgumentError) invalid padding mode specified, padding must be one of :valid, :same, or a padding configuration, got: :foo

  """
  def to_padding_config(shape, kernel_size, mode) do
    case mode do
      :valid ->
        List.duplicate({0, 0}, tuple_size(shape))

      :same ->
        Enum.map(Tuple.to_list(kernel_size), fn k ->
          padding_size = max(k - 1, 0)

          {floor(padding_size / 2), ceil(padding_size / 2)}
        end)

      config when is_list(config) ->
        Enum.each(config, fn
          {x, y} when is_integer(x) and is_integer(y) ->
            :ok

          _other ->
            raise ArgumentError,
                  "padding must be a list of {high, low} tuples, where each element is an integer. " <>
                    "Got: #{inspect(config)}"
        end)

        config

      mode ->
        raise ArgumentError,
              "invalid padding mode specified, padding must be one" <>
                " of :valid, :same, or a padding configuration, got:" <>
                " #{inspect(mode)}"
    end
  end

  @doc """
  Flattens the given axes of the given input shape into a
  single axis.

  ## Examples

      iex> Nx.Shape.flatten({1, 2, 3}, [nil, nil, nil], nil)
      {{6}, [nil]}

      iex> Nx.Shape.flatten({1, 2, 3}, [:batch, nil, nil], [1, 2])
      {{1, 6}, [:batch, nil]}

      iex> Nx.Shape.flatten({1, 2, 3}, [nil, nil, nil], [])
      {{1, 2, 3}, [nil, nil, nil]}

  ## Error cases

      iex> Nx.Shape.flatten({1, 2, 3}, [:batch, nil, nil], [0, 2])
      ** (ArgumentError) flatten axes must be consecutive
  """
  def flatten(shape, names, axes)

  def flatten(shape, _names, nil), do: {{Tuple.product(shape)}, [nil]}

  def flatten(shape, names, []), do: {shape, names}

  def flatten(shape, names, axes) do
    axes = axes || Enum.to_list(0..(tuple_size(shape) - 1))
    [insert_axis | _] = axes = normalize_axes(shape, axes, names)

    unless consecutive?(axes) do
      raise ArgumentError, "flatten axes must be consecutive"
    end

    shape = Tuple.to_list(shape)
    shape_and_names = Enum.zip(shape, names)

    {rev_shape, rev_names, flatten_size, _} =
      for {size, name} <- shape_and_names, reduce: {[], [], 1, 0} do
        {cur_shape, cur_names, cur_size, i} ->
          if i in axes do
            {cur_shape, cur_names, size * cur_size, i + 1}
          else
            {[size | cur_shape], [name | cur_names], cur_size, i + 1}
          end
      end

    shape =
      rev_shape
      |> Enum.reverse()
      |> List.to_tuple()
      |> Tuple.insert_at(insert_axis, flatten_size)

    names =
      rev_names
      |> Enum.reverse()
      |> List.insert_at(insert_axis, nil)

    {shape, names}
  end

  defp consecutive?([cur, next | rest]) when next == cur + 1, do: consecutive?([next | rest])
  defp consecutive?([_last]), do: true
  defp consecutive?(_), do: false

  @doc """
  Dilates the given input shape according to dilation.

  ## Examples

      iex> Nx.Shape.dilate({3, 3, 3}, [1, 2, 1])
      {3, 5, 3}

      iex> Nx.Shape.dilate({2, 4, 2}, [3, 1, 3])
      {4, 4, 4}
  """
  def dilate(shape, dilation) when is_tuple(shape) and is_list(dilation) do
    unless Enum.all?(dilation, &(&1 >= 1)) do
      raise ArgumentError,
            "dilation rates must be greater than or equal to 1" <>
              " got #{inspect(dilation)}"
    end

    dilated_padding_config = Enum.map(dilation, fn x -> {0, 0, x - 1} end)
    pad(shape, dilated_padding_config)
  end

  @doc """
  Early validation of conv! before remaining values are computed.
  """
  def validate_conv!(input_shape, kernel_shape) do
    cond do
      tuple_size(input_shape) < 3 ->
        raise ArgumentError,
              "input shape in conv requires at least rank 3," <>
                " shape #{inspect(input_shape)} has rank #{tuple_size(input_shape)}"

      tuple_size(kernel_shape) < 3 ->
        raise ArgumentError,
              "kernel shape in conv requires at least rank 3," <>
                " shape #{inspect(kernel_shape)} has rank #{tuple_size(kernel_shape)}"

      true ->
        :ok
    end
  end

  @doc """
  Output shape after a convolution.
  """
  def conv(
        input_shape,
        input_names,
        kernel_shape,
        kernel_names,
        strides,
        padding,
        feature_group_count,
        batch_group_count,
        input_dilation,
        kernel_dilation,
        input_permutation,
        kernel_permutation,
        output_permutation
      ) do
    validate_conv_strides!(input_shape, strides)
    validate_conv_dilations!(input_shape, kernel_shape, input_dilation, kernel_dilation)

    {input_shape, permuted_input_names} = transpose(input_shape, input_permutation, input_names)
    input_shape = dilate(input_shape, [1, 1 | input_dilation])

    {kernel_shape, _} = transpose(kernel_shape, kernel_permutation, kernel_names)
    kernel_shape = dilate(kernel_shape, [1, 1 | kernel_dilation])

    validate_conv_groups!(input_shape, kernel_shape, feature_group_count, batch_group_count)

    num_filters = elem(kernel_shape, 0)
    batch_size = elem(input_shape, 0)

    filter_shape =
      kernel_shape
      |> Tuple.delete_at(0)
      |> Tuple.delete_at(0)

    spatial_dims =
      input_shape
      |> Tuple.delete_at(0)
      |> Tuple.delete_at(0)

    padding_config = to_padding_config(spatial_dims, filter_shape, padding)

    old_spatial_dims =
      spatial_dims
      |> pad(Enum.map(padding_config, fn {x, y} -> {x, y, 0} end))
      |> Tuple.to_list()

    spatial_dims = do_conv_spatial_dims(old_spatial_dims, Tuple.to_list(filter_shape), strides)
    shape = List.to_tuple([div(batch_size, batch_group_count), num_filters | spatial_dims])

    inv_output_permutation =
      output_permutation
      |> Enum.with_index()
      |> Enum.sort()
      |> Enum.map(&elem(&1, 1))

    {shape, names} = transpose(shape, inv_output_permutation, permuted_input_names)

    {shape, names, padding_config}
  end

  defp validate_conv_strides!(input_shape, strides) do
    if length(strides) != tuple_size(input_shape) - 2 do
      raise ArgumentError,
            "rank of strides much match rank of spatial dimensions" <>
              " got strides #{inspect(strides)} with rank #{length(strides)}" <>
              " and got input shape #{inspect(input_shape)} of rank" <>
              " #{tuple_size(input_shape) - 2}"
    end
  end

  # Validates the input and kernel dilations given to Nx.conv
  defp validate_conv_dilations!(input_shape, kernel_shape, input_dilation, kernel_dilation) do
    cond do
      is_list(input_dilation) and length(input_dilation) != tuple_size(input_shape) - 2 ->
        raise ArgumentError,
              "must specify dilation for each spatial dimension of the input" <>
                " or specify an integer dilation factor"

      is_list(input_dilation) and Enum.any?(input_dilation, &(&1 < 1 || !is_integer(&1))) ->
        raise ArgumentError,
              "input dilation of each dimension must be a positive integer, got " <>
                inspect(input_dilation)

      is_list(kernel_dilation) and length(kernel_dilation) != tuple_size(kernel_shape) - 2 ->
        raise ArgumentError,
              "must specify dilation for each spatial dimension of the kernel" <>
                " or specify an integer dilation factor"

      is_list(kernel_dilation) and Enum.any?(kernel_dilation, &(&1 < 1 || !is_integer(&1))) ->
        raise ArgumentError,
              "kernel dilation of each dimension must be a positive integer, got " <>
                inspect(kernel_dilation)

      true ->
        :ok
    end
  end

  defp validate_conv_groups!(input_shape, kernel_shape, feature_groups, batch_groups) do
    tensor_input_batch_size = elem(input_shape, 0)
    tensor_input_channels = elem(input_shape, 1)
    kernel_input_channels = elem(kernel_shape, 1)
    kernel_output_channels = elem(kernel_shape, 0)

    cond do
      batch_groups != 1 and feature_groups != 1 ->
        raise ArgumentError,
              "either batch groups or feature groups must be 1," <>
                " got batch_groups = #{batch_groups} and feature_groups = #{feature_groups}"

      rem(tensor_input_batch_size, batch_groups) != 0 ->
        raise ArgumentError,
              "batch groups must evenly divide input batch size" <>
                " got rem(#{batch_groups}, #{tensor_input_batch_size}) != 0"

      rem(kernel_output_channels, feature_groups) != 0 ->
        raise ArgumentError,
              "size of kernel output channels must be evenly divisible by feature groups" <>
                " got rem(#{kernel_output_channels}, #{feature_groups}) != 0 for kernel" <>
                " with shape #{inspect(kernel_shape)}"

      rem(kernel_output_channels, batch_groups) != 0 ->
        raise ArgumentError,
              "size of kernel output channels must be evenly divisible by batch groups" <>
                " got rem(#{kernel_output_channels}, #{batch_groups}) != 0 for kernel" <>
                " with shape #{inspect(kernel_shape)}"

      tensor_input_channels != kernel_input_channels * feature_groups ->
        raise ArgumentError,
              "size of input channels divided by feature groups must match size of kernel channels," <>
                " got #{tensor_input_channels} / #{feature_groups} != #{kernel_input_channels}" <>
                " for shapes #{inspect(input_shape)} and #{inspect(kernel_shape)}"

      true ->
        :ok
    end
  end

  defp do_conv_spatial_dims([], [], []), do: []

  defp do_conv_spatial_dims([cur | spatial], [f | filters], [s | strides]) do
    dim = floor((cur - f) / s) + 1

    if dim <= 0 do
      raise ArgumentError,
            "conv would result in empty tensor which is" <>
              " not currently supported in Nx, please open an" <>
              " issue if you'd like this behavior to change"
    else
      [dim | do_conv_spatial_dims(spatial, filters, strides)]
    end
  end

  @doc """
  Output shape after a pooling or reduce window operation.

  ## Examples

    iex> Nx.Shape.pool({3, 3}, {1, 2}, [1, 1], :valid, [1, 1])
    {{3, 2}, [{0, 0}, {0, 0}]}

    iex> Nx.Shape.pool({3, 2, 3}, {2, 1, 1}, [1, 2, 1], :same, [1, 1, 1])
    {{3, 1, 3}, [{0, 1}, {0, 0}, {0, 0}]}

  ## Error cases

    iex> Nx.Shape.pool({1, 2, 3}, {2, 1, 1}, [1, 1, 1], :valid, [1, 1, 1])
    ** (ArgumentError) window dimensions would result in empty tensor which is not currently supported in Nx, please open an issue if you'd like this behavior to change

    iex> Nx.Shape.pool({1, 2, 3}, {2, 1}, [1, 1, 1], :valid, [1, 1, 1])
    ** (ArgumentError) invalid window dimensions, rank of shape (3) does not match rank of window (2)

    iex> Nx.Shape.pool({1, 2, 3}, {2, 1, 1}, [1, 1], :valid, [1, 1, 1])
    ** (ArgumentError) invalid stride dimensions, rank of shape (3) does not match rank of stride (2)
  """
  def pool(shape, kernel_size, strides, padding, kernel_dilation) do
    validate_window!(shape, kernel_size)
    validate_strides!(shape, strides)

    kernel_size = dilate(kernel_size, kernel_dilation)

    padding_config = to_padding_config(shape, kernel_size, padding)
    shape = pad(shape, Enum.map(padding_config, fn {x, y} -> {x, y, 0} end))
    {List.to_tuple(do_pool(strides, shape, kernel_size, 0)), padding_config}
  end

  defp do_pool([], _shape, _window, _pos), do: []

  defp do_pool([s | strides], shape, window, pos) do
    dim = elem(shape, pos)
    w = elem(window, pos)
    new_dim = div(dim - w, s) + 1

    if new_dim <= 0 do
      raise ArgumentError,
            "window dimensions would result in empty tensor" <>
              " which is not currently supported in Nx, please" <>
              " open an issue if you'd like this behavior to change"
    end

    [new_dim | do_pool(strides, shape, window, pos + 1)]
  end

  # Ensures the window is valid given the shape.
  # A window is valid as long as it's rank matches
  # the rank of the given shape.
  defp validate_window!(shape, window)

  defp validate_window!(shape, window) when tuple_size(shape) != tuple_size(window),
    do:
      raise(
        ArgumentError,
        "invalid window dimensions, rank of shape (#{tuple_size(shape)})" <>
          " does not match rank of window (#{tuple_size(window)})"
      )

  defp validate_window!(_, _), do: :ok

  # Ensures the strides are valid given the shape.
  # A stride is valid as long as it's rank matches
  # the rank of the given shape.
  defp validate_strides!(shape, strides)

  defp validate_strides!(shape, strides) when tuple_size(shape) != length(strides),
    do:
      raise(
        ArgumentError,
        "invalid stride dimensions, rank of shape (#{tuple_size(shape)})" <>
          " does not match rank of stride (#{length(strides)})"
      )

  defp validate_strides!(_, _), do: :ok

  @doc "Validates the input shapes for `Nx.indexed_*/3`"
  def indexed_scalar(target_shape, indices_shape, updates_shape) do
    r = tuple_size(target_shape)

    case {indices_shape, updates_shape} do
      {{n}, _} when n != r ->
        raise ArgumentError,
              "expected indices to have shape {#{r}}, got: #{inspect({n})}"

      {{^r}, updates_shape} when updates_shape != {} ->
        raise ArgumentError,
              "updates must be a scalar tensor when indices has rank 1, got: #{inspect(updates_shape)}"

      {_, _} ->
        :ok
    end
  end

  @doc "Validates the input shapes for `Nx.indexed_*/3`"
  def indexed(target_shape, indices_shape, updates_shape, axes) do
    r = tuple_size(target_shape)
    i = tuple_size(indices_shape)
    u = tuple_size(updates_shape)

    case indices_shape do
      _ when i != 2 ->
        raise ArgumentError, "indices must be a rank 1 or 2 tensor, got: #{i}"

      {n, _} when n != elem(updates_shape, 0) ->
        raise ArgumentError,
              "expected the leading axis of indices (#{inspect(indices_shape)}) " <>
                "and leading axis of updates (#{inspect(updates_shape)}) to match"

      {_, n} when u - 1 + n != r ->
        raise ArgumentError,
              "expected the rank of the input (#{r}) to be equal to " <>
                "the rank of the updates (#{u}) plus " <>
                "the trailing axis of indices (#{inspect(indices_shape)}) minus one"

      _ when u == 1 ->
        :ok

      _ ->
        Enum.reduce(count_up(r, 0) -- axes, 1, fn target_axis, update_axis ->
          if elem(updates_shape, update_axis) > elem(target_shape, target_axis) do
            raise ArgumentError,
                  "axis (#{update_axis}) of updates (#{inspect(updates_shape)}) must be less than " <>
                    "or equal to the axis (#{target_axis}) of #{inspect(target_shape)})"
          end

          update_axis + 1
        end)

        :ok
    end
  end

  @doc """
  Output shape after a squeeze operation.

  ## Examples

      iex> Nx.Shape.squeeze({2, 1, 1}, [1, 2], [:batch, :x, :y])
      {{2}, [:batch]}

      iex> Nx.Shape.squeeze({1, 2}, [0], [:batch, :x])
      {{2}, [:x]}

  ## Error cases

      iex> Nx.Shape.squeeze({2, 2, 1}, [1], [:batch, :x, :y])
      ** (ArgumentError) cannot squeeze dimensions whose sizes are not 1, got 2 for dimension 1

  """
  def squeeze(shape, axes, names) do
    squeeze(Enum.with_index(Tuple.to_list(shape)), axes, names, [], [])
  end

  defp squeeze([], _, _, sacc, nacc) do
    {List.to_tuple(Enum.reverse(sacc)), Enum.reverse(nacc)}
  end

  defp squeeze([{s, i} | shape], axes, [n | names], sacc, nacc) do
    if i in axes do
      if s == 1 do
        squeeze(shape, axes, names, sacc, nacc)
      else
        raise ArgumentError,
              "cannot squeeze dimensions whose sizes are not 1, got #{s} for dimension #{i}"
      end
    else
      squeeze(shape, axes, names, [s | sacc], [n | nacc])
    end
  end

  @doc """
  Output shape after a padding operation.

  ## Examples

      iex> Nx.Shape.pad({3, 2, 4}, [{0, 1, 0}, {1, 2, 0}, {1, 1, 0}])
      {4, 5, 6}

      iex> Nx.Shape.pad({}, [])
      {}

      iex> Nx.Shape.pad({2, 2}, [{1, 1, 0}, {0, 0, 0}])
      {4, 2}

      iex> Nx.Shape.pad({2, 3}, [{0, 0, 1}, {0, 0, 1}])
      {3, 5}

  ## Error cases

      iex> Nx.Shape.pad({2, 2, 3}, [{0, 1, 0}, {1, 2, 0}])
      ** (ArgumentError) invalid padding configuration, rank of padding configuration and shape must match

      iex> Nx.Shape.pad({2, 2, 3}, [{0, 1, -1}, {0, 0, 0}, {0, 0, 0}])
      ** (ArgumentError) invalid padding configuration, interior padding must be non-negative
  """
  def pad(shape, padding_config) do
    shape
    |> Tuple.to_list()
    |> padded_dims(padding_config, [])
    |> Enum.reverse()
    |> List.to_tuple()
  end

  defp padded_dims([], [], acc), do: acc

  defp padded_dims([_ | _], [], _acc),
    do:
      raise(
        ArgumentError,
        "invalid padding configuration, rank of padding configuration" <>
          " and shape must match"
      )

  defp padded_dims([], [_ | _], _acc),
    do:
      raise(
        ArgumentError,
        "invalid padding configuration, rank of padding configuration" <>
          " and shape must match"
      )

  defp padded_dims([s | shape], [{edge_low, edge_high, interior} | config], acc) do
    if interior < 0 do
      raise ArgumentError, "invalid padding configuration, interior padding must be non-negative"
    end

    interior_padding_factor = (s - 1) * interior
    padded_dims(shape, config, [s + interior_padding_factor + edge_low + edge_high | acc])
  end

  ## Axes helpers

  @doc """
  Normalize the axis to the given shape.

  ## Examples

      iex> Nx.Shape.normalize_axis({4, 2, 3}, -1, [:batch, :x, :y])
      2

      iex> Nx.Shape.normalize_axis({4, 2, 1, 4}, -2, [:batch, :x, :y, :z])
      2

      iex> Nx.Shape.normalize_axis({4, 2, 1, 4}, 1, [:batch, :x, :y, :z])
      1

      iex> Nx.Shape.normalize_axis({4, 2, 1, 4}, :z, [:batch, :x, :y, :z])
      3

      iex> Nx.Shape.normalize_axis({4, 2, 1, 4}, 2, [nil, nil, nil, nil], 1)
      3

  ## Error cases

      iex> Nx.Shape.normalize_axis({4, 2, 5}, -4, [:batch, :x, :y])
      ** (ArgumentError) given axis (-4) invalid for shape with rank 3

      iex> Nx.Shape.normalize_axis({4, 2, 5}, 3, [:batch, :x, :y])
      ** (ArgumentError) given axis (3) invalid for shape with rank 3

      iex> Nx.Shape.normalize_axis({4, 2, 5}, :z, [:batch, :x, :y])
      ** (ArgumentError) name :z not found in tensor with names [:batch, :x, :y]

      iex> Nx.Shape.normalize_axis({4, 2, 5}, nil, [:batch, nil, nil])
      ** (ArgumentError) axis name cannot be nil

  """
  def normalize_axis(shape, axis, names, vectorized_offset \\ 0)

  def normalize_axis(shape, axis, _names, _vectorized_offset)
      when axis < 0 and abs(axis) <= tuple_size(shape),
      do: tuple_size(shape) + axis

  def normalize_axis(shape, axis, _names, vectorized_offset)
      when axis >= 0 and axis < tuple_size(shape),
      do: axis + vectorized_offset

  def normalize_axis(_shape, nil, _names, _vectorized_offset),
    do: raise(ArgumentError, "axis name cannot be nil")

  def normalize_axis(_shape, axis, names, _vectorized_offset) when is_atom(axis) do
    if axis in names do
      Enum.with_index(names)[axis]
    else
      raise ArgumentError,
            "name #{inspect(axis)} not found in tensor with names #{inspect(names)}"
    end
  end

  def normalize_axis(shape, axis, _names, _vectorized_offset) do
    raise ArgumentError,
          "given axis (#{inspect(axis)}) invalid for shape with rank #{tuple_size(shape)}"
  end

  @doc """
  Normalize a list of unique axis.

  See `normalize_axis/1`.

  ## Examples

      iex> Nx.Shape.normalize_axes({2, 3, 4}, [-1, 0], [:batch, nil])
      [2, 0]

      iex> Nx.Shape.normalize_axes({2, 3, 4}, [:batch, 1], [:batch, :x])
      [0, 1]

  ## Error cases

      iex> Nx.Shape.normalize_axes({2, 3, 4}, [1, 1], [nil, nil, nil])
      ** (ArgumentError) axes [1, 1] must be unique integers between 0 and 2
  """
  def normalize_axes(shape, axes, names, offset \\ 0) when is_list(axes) do
    normalized = Enum.map(axes, &normalize_axis(shape, &1, names, offset))

    if length(Enum.uniq(normalized)) != length(axes) do
      raise ArgumentError,
            "axes #{inspect(axes)} must be unique integers between 0 and #{tuple_size(shape) - 1 - offset}"
    end

    normalized
  end

  @doc """
  Returns the axes for transposition.

  ## Examples

      iex> Nx.Shape.transpose_axes({})
      []
      iex> Nx.Shape.transpose_axes({3, 2, 1})
      [2, 1, 0]

  """
  def transpose_axes(shape, offset \\ 0) do
    rank = tuple_size(shape)
    count_down(rank, rank + offset - 1)
  end

  @doc """
  Compute the broadcast axes based on the shape rank.

  It doesn't validate if the remaining dimensions are
  actually valid.

  ## Examples

      iex> Nx.Shape.broadcast_axes({2, 2, 2}, {2, 2, 2, 2})
      [1, 2, 3]

      iex> Nx.Shape.broadcast_axes({2, 2, 2}, {2, 2, 2, 2, 2})
      [2, 3, 4]

  """
  def broadcast_axes(shape, new_shape) when tuple_size(shape) > tuple_size(new_shape) do
    raise ArgumentError,
          "cannot broadcast tensor of dimensions #{inspect(shape)} " <>
            "to #{inspect(new_shape)}"
  end

  def broadcast_axes(shape, new_shape) do
    min_size = tuple_size(shape)
    max_size = tuple_size(new_shape)
    count_up(min_size, max_size - min_size)
  end

  @doc """
  Returns the axes for squeezing.

  ## Examples

      iex> Nx.Shape.squeeze_axes({2, 1, 1})
      [1, 2]

      iex> Nx.Shape.squeeze_axes({1, 2, 1, 3, 2, 1})
      [0, 2, 5]
  """
  def squeeze_axes(shape, offset \\ 0) do
    axes =
      shape
      |> Tuple.to_list()
      |> Enum.drop(offset)
      |> Enum.with_index()

    for {1, i} <- axes, do: i
  end

  @doc """
  Returns the shape after a slice.

  ## Examples

      iex> Nx.Shape.slice({2, 15, 30}, [1, 4, 10], [1, 1, 10], [1, 1, 3])
      {[1, 4, 10], {1, 1, 4}}

      iex> Nx.Shape.slice({2, 15, 30}, [1, 4, 25], [1, 1, 10], [1, 1, 1])
      {[1, 4, 20], {1, 1, 10}}

  ## Error cases

      iex> Nx.Shape.slice({2, 15, 30}, [1, 4, 10], [3, 1, 1], [1, 1, 1])
      ** (ArgumentError) length at axis 0 must be less than axis size of 2, got: 3

  """
  def slice(shape, start_indices, lengths, strides) do
    rank = tuple_size(shape)

    if length(strides) != rank do
      raise ArgumentError, "invalid strides rank for shape of rank #{rank}"
    end

    if length(start_indices) != rank do
      raise ArgumentError, "invalid start indices rank for shape of rank #{rank}"
    end

    if length(lengths) != rank do
      raise ArgumentError, "invalid limit indices rank for shape of rank #{rank}"
    end

    do_slice(shape, 0, start_indices, lengths, strides, [], [])
  end

  defp do_slice(shape, pos, [i | indices], [len | lengths], [s | strides], acc_indices, acc_shape) do
    dim = elem(shape, pos)

    if not is_integer(len) or len < 1 do
      raise ArgumentError,
            "length at axis #{pos} must be greater than or equal to 1, got: #{inspect(len)}"
    end

    if not is_integer(s) or s < 1 do
      raise ArgumentError,
            "stride at axis #{pos} must be greater than or equal to 1, got: #{inspect(s)}"
    end

    if len > dim do
      raise ArgumentError,
            "length at axis #{pos} must be less than axis size of #{dim}, got: #{len}"
    end

    out = Kernel.ceil(len / s)
    i = if is_integer(i), do: min(i, dim - len), else: i
    do_slice(shape, pos + 1, indices, lengths, strides, [i | acc_indices], [out | acc_shape])
  end

  defp do_slice(_shape, _pos, [], [], [], acc_indices, acc_shape) do
    {Enum.reverse(acc_indices), acc_shape |> Enum.reverse() |> List.to_tuple()}
  end

  @doc """
  Returns the shape and names after a put_slice.

  ## Examples

      iex> Nx.Shape.put_slice({2, 3}, [nil, :data], {1, 2}, [:batch, nil], [1, 1])
      {{2, 3}, [:batch, :data]}

      iex> Nx.Shape.put_slice({2, 3}, [nil, nil], {2, 3}, [nil, nil], [0, 1])
      {{2, 3}, [nil, nil]}

  """
  def put_slice(shape, names, slice_shape, slice_names, start_indices) do
    rank = tuple_size(shape)

    if length(start_indices) != rank do
      raise ArgumentError, "invalid start indices rank for shape of rank #{rank}"
    end

    if tuple_size(slice_shape) != rank do
      raise ArgumentError,
            "invalid slice for put_slice, rank of slice must match #{rank}, " <>
              "got: #{tuple_size(slice_shape)}"
    end

    shape
    |> Tuple.to_list()
    |> do_put_slice(names, Tuple.to_list(slice_shape), slice_names, [], 0)
    |> case do
      :error ->
        raise ArgumentError,
              "slice shape #{inspect(slice_shape)} must be less than or equal to " <>
                "tensor shape #{inspect(shape)}"

      names ->
        {shape, names}
    end
  end

  defp do_put_slice([s | _], _, [slice | _], _, _, _) when slice > s do
    :error
  end

  defp do_put_slice([_ | shape], [n | names], [_ | s_shape], [s_name | s_names], acc, axis) do
    acc = [merge_names!(n, s_name, axis, axis) | acc]
    do_put_slice(shape, names, s_shape, s_names, acc, axis + 1)
  end

  defp do_put_slice([], [], [], [], acc, _axis), do: Enum.reverse(acc)

  @doc """
  Returns the shape and names after a take.

  In practice, `axis` in `shape` gets replaced by `indices_shape`.

  ## Examples

      iex> Nx.Shape.take({2, 3}, [nil, :data], {10}, [nil], 0)
      {{10, 3}, [nil, :data]}

      iex> Nx.Shape.take({2, 3}, [nil, :data], {10}, [nil], 1)
      {{2, 10}, [nil, :data]}

      iex> Nx.Shape.take({2, 3}, [nil, :data], {10}, [:reordered], 0)
      {{10, 3}, [:reordered, :data]}

      iex> Nx.Shape.take({2, 3, 4}, [:x, :y, :z], {10, 20}, [:a, :b], 1)
      {{2, 10, 20, 4}, [:x, :a, :b, :z]}

  ## Error cases

      iex> Nx.Shape.take({2, 3}, [nil, :data], {10}, [:reordered], 1)
      ** (ArgumentError) cannot merge name :data on axis 1 with name :reordered on axis 0
  """
  def take(shape, names, indices_shape, indices_names, axis) do
    shape = Tuple.to_list(shape)
    indices_shape = Tuple.to_list(indices_shape)

    {leading_lengths, [_ | trailing_lengths]} = Enum.split(shape, axis)
    {leading_names, [axis_name | trailing_names]} = Enum.split(names, axis)

    indices_names =
      case indices_names do
        [name] -> [merge_names!(axis_name, name, axis, 0)]
        names -> names
      end

    result_shape = leading_lengths ++ indices_shape ++ trailing_lengths
    result_names = leading_names ++ indices_names ++ trailing_names
    {List.to_tuple(result_shape), result_names}
  end

  @doc """
  Returns {batch_shape, matrix_shape} if valid and raises error if not.
  """
  def take_diagonal(shape)

  def take_diagonal(shape) when tuple_size(shape) > 1 do
    {batch_shape, matrix_shape} = shape |> Tuple.to_list() |> Enum.split(-2)
    {List.to_tuple(batch_shape), List.to_tuple(matrix_shape)}
  end

  def take_diagonal(invalid_shape) do
    raise ArgumentError,
          "take_diagonal/2 expects tensor of rank 2 or higher, got tensor of rank: #{tuple_size(invalid_shape)}"
  end

  @doc """
  Returns shape if valid and raises error if not.
  """
  def make_diagonal(shape)

  def make_diagonal({len}) do
    {len}
  end

  def make_diagonal(invalid_shape) do
    raise ArgumentError,
          "make_diagonal/2 expects tensor of rank 1, got tensor of rank: #{tuple_size(invalid_shape)}"
  end

  @doc """
  Validates the tensor, diagonal, and offset given to `Nx.put_diagonal/3`.

  ## Examples

    Given a 2D tensor and a 1D diagonal:

      iex> Nx.Shape.put_diagonal({4, 4}, {4}, 0)
      :ok

      iex> Nx.Shape.put_diagonal({4, 3}, {3}, 0)
      :ok

    Given a 2D tensor and a 1D diagonal with a positive offset:

      iex> Nx.Shape.put_diagonal({4, 4}, {3}, 1)
      :ok

      iex> Nx.Shape.put_diagonal({4, 3}, {2}, 1)
      :ok

    Given a 2D tensor and a 1D diagonal with a negative offset:

      iex> Nx.Shape.put_diagonal({4, 4}, {3}, -1)
      :ok

      iex> Nx.Shape.put_diagonal({4, 3}, {3}, -1)
      :ok

  ## Error cases

    Given and invalid tensor:

      iex> Nx.Shape.put_diagonal({3, 3, 3}, {3}, 0)
      ** (ArgumentError) put_diagonal/3 expects tensor of rank 2, got tensor of rank: 3

    Given invalid diagonals:

      iex> Nx.Shape.put_diagonal({3, 3}, {3, 3}, 0)
      ** (ArgumentError) put_diagonal/3 expects diagonal of rank 1, got tensor of rank: 2

      iex> Nx.Shape.put_diagonal({3, 3}, {2}, 0)
      ** (ArgumentError) expected diagonal tensor of length: 3, got diagonal tensor of length: 2

      iex> Nx.Shape.put_diagonal({3, 3}, {3}, 1)
      ** (ArgumentError) expected diagonal tensor of length: 2, got diagonal tensor of length: 3

   Given invalid offsets:

      iex> Nx.Shape.put_diagonal({3, 3}, {3}, 4)
      ** (ArgumentError) offset must be less than length of axis 1 when positive, got: 4

      iex> Nx.Shape.put_diagonal({3, 3}, {3}, -3)
      ** (ArgumentError) absolute value of offset must be less than length of axis 0 when negative, got: -3
  """
  def put_diagonal(tensor, diagonal, offset)

  def put_diagonal({len, breadth} = tensor, {given_len}, offset) do
    validate_diag_offset!(tensor, offset)

    {len, breadth} =
      if offset > 0 do
        {len, breadth - offset}
      else
        {len + offset, breadth}
      end

    expected_len = min(len, breadth)

    if expected_len == given_len do
      :ok
    else
      raise ArgumentError,
            "expected diagonal tensor of length: #{expected_len}, got diagonal tensor of length: #{given_len}"
    end
  end

  def put_diagonal(tensor, {_len}, _offset) do
    raise ArgumentError,
          "put_diagonal/3 expects tensor of rank 2, got tensor of rank: #{tuple_size(tensor)}"
  end

  def put_diagonal({_len, _breadth}, diagonal, _offset) do
    raise ArgumentError,
          "put_diagonal/3 expects diagonal of rank 1, got tensor of rank: #{tuple_size(diagonal)}"
  end

  @doc """
  Validates an offset to extract or create a diagonal (tensor) for given shape

  ## Examples

      iex> Nx.Shape.validate_diag_offset!({3, 4}, 1)
      :ok

      iex> Nx.Shape.validate_diag_offset!({3, 4}, -1)
      :ok

  ## Error cases

    iex> Nx.Shape.validate_diag_offset!({3, 4}, 4)
    ** (ArgumentError) offset must be less than length of axis 1 when positive, got: 4

    iex> Nx.Shape.validate_diag_offset!({3, 4}, -3)
    ** (ArgumentError) absolute value of offset must be less than length of axis 0 when negative, got: -3

    iex> Nx.Shape.validate_diag_offset!({3, 3, 3}, 0)
    ** (ArgumentError) expected shape of rank 2 to be given, got shape of rank: 3
  """
  def validate_diag_offset!(shape, offset)

  def validate_diag_offset!({len, breadth}, offset) do
    cond do
      offset >= 0 and offset < breadth ->
        :ok

      offset >= 0 ->
        raise ArgumentError,
              "offset must be less than length of axis 1 when positive, got: #{inspect(offset)}"

      offset < 0 and -offset < len ->
        :ok

      offset < 0 ->
        raise ArgumentError,
              "absolute value of offset must be less than length of axis 0 when negative, got: #{inspect(offset)}"
    end
  end

  def validate_diag_offset!(shape, _offset) when is_tuple(shape) do
    raise ArgumentError,
          "expected shape of rank 2 to be given, got shape of rank: #{tuple_size(shape)}"
  end

  @doc """
  Returns the shape and names after a `take_along_axis` operation is performed.

  In practice, `axis` in `shape` gets replaced by `indices_shape`.

  ## Examples

      iex> Nx.Shape.take_along_axis({2, 3}, {10, 3}, 0)
      {10, 3}

      iex> Nx.Shape.take_along_axis({2, 3}, {2, 10}, 1)
      {2, 10}

      iex> Nx.Shape.take_along_axis({2, 3, 4}, {10, 3, 4}, 0)
      {10, 3, 4}

  ## Error cases

      iex> Nx.Shape.take_along_axis({2, 3, 4}, {3, 10, 4}, 1)
      ** (ArgumentError) non-indexing dimensions must match. Expected {2, *, 4}, got: {3, 10, 4}

      iex> Nx.Shape.take_along_axis({2, 3}, {1, 2, 3}, 0)
      ** (ArgumentError) shapes must have the same number of dimensions. Expected {*, 3}, got: {1, 2, 3}
  """
  def take_along_axis(shape, indices_shape, axis) do
    if axis >= tuple_size(shape) or axis < 0 do
      raise ArgumentError, "axis must be a non-negative less than #{tuple_size(shape)}"
    end

    if tuple_size(shape) != tuple_size(indices_shape) do
      raise ArgumentError,
            "shapes must have the same number of dimensions. Expected #{shape_template_on_axis(shape, axis)}, got: #{inspect(indices_shape)}"
    end

    shape_list = Tuple.to_list(shape)
    indices_shape_list = Tuple.to_list(indices_shape)

    [shape_list, indices_shape_list]
    |> Enum.zip()
    |> Enum.with_index(fn
      {_input_length, _output_length}, ^axis ->
        :ok

      {input_length, output_length}, _axis ->
        unless input_length == output_length do
          raise ArgumentError,
                "non-indexing dimensions must match. Expected #{shape_template_on_axis(shape, axis)}, got: #{inspect(indices_shape)}"
        end
    end)

    indices_shape
  end

  defp shape_template_on_axis(shape, axis) do
    shape_list = Tuple.to_list(shape)

    shape_template =
      shape_list
      |> Enum.with_index(fn
        _, ^axis -> "*"
        l, _ -> "#{l}"
      end)
      |> Enum.join(", ")

    "{#{shape_template}}"
  end

  @doc """
  Returns the shape after a gather.

  ## Examples

      iex> Nx.Shape.gather({2, 3}, {10, 2}, [0, 1])
      {{10}, [nil]}

      iex> Nx.Shape.gather({2, 3}, {4, 5, 2}, [0, 1])
      {{4, 5}, [nil, nil]}

      iex> Nx.Shape.gather({2}, {4, 5, 1}, [0])
      {{4, 5}, [nil, nil]}

      iex> Nx.Shape.gather({2, 2, 2, 2, 2}, {3, 3, 5}, [0, 1, 2, 3, 4])
      {{3, 3}, [nil, nil]}

      iex> Nx.Shape.gather({2, 2, 2}, {3}, [0, 1, 2])
      {{}, []}

      iex> Nx.Shape.gather({2, 2, 2, 2, 2}, {3, 3, 3}, [0, 1, 2])
      {{3, 3, 2, 2}, [nil, nil, nil, nil]}

  ## Error cases

      iex> Nx.Shape.gather({2, 3}, {}, [])
      ** (ArgumentError) expected indices rank to be at least 1, got: 0

  """
  def gather(shape, indices_shape, axes) do
    rank = tuple_size(shape)

    if indices_shape == {} do
      raise ArgumentError, "expected indices rank to be at least 1, got: 0"
    end

    {outer_shape, [last_size]} = indices_shape |> Tuple.to_list() |> Enum.split(-1)

    if last_size > rank do
      raise ArgumentError,
            "expected the last indices dimension size (#{last_size}) to be less than or equal to the tensor rank (#{rank})"
    end

    inner_shape = for i <- Nx.axes(shape), i not in axes, do: elem(shape, i)
    shape = List.to_tuple(outer_shape ++ inner_shape)
    names = List.duplicate(nil, tuple_size(shape))
    {shape, names}
  end

  @doc """
  Returns the shape and name of new axis.
  """
  def new_axis(shape, names, axis, name, size, offset) do
    rank = tuple_size(shape)
    norm = if axis < 0, do: axis + rank + 1, else: axis + offset

    if norm not in offset..tuple_size(shape) do
      raise ArgumentError,
            "new axis position for shape #{inspect(shape)} must be " <>
              "a number between #{-rank - 1 + offset} and #{rank - offset}, got: #{axis}"
    end

    new_shape = Tuple.insert_at(shape, norm, size)
    new_names = List.insert_at(names, norm, name)
    {new_shape, new_names, norm}
  end

  @doc """
  Returns the shape and names after a stack.

  ## Examples

      iex> Nx.Shape.stack([{3, 2}, {3, 2}, {3, 2}], [[nil, nil], [nil, :z], [:y, nil]], 0, :x, 0)
      {{3, 3, 2}, [:x, :y, :z], 0}
  """
  def stack(shapes, names, axis, name, offset) do
    names =
      Enum.zip_with(names, fn zipped ->
        Enum.reduce(zipped, &merge_names!(&1, &2, axis, axis))
      end)

    case Enum.uniq(shapes) do
      [shape] ->
        new_axis(shape, names, axis, name, length(shapes), offset)

      shapes ->
        raise ArgumentError,
              "can only stack tensors of the same shape, got distinct shapes: #{inspect(shapes)}"
    end
  end

  @doc """
  Returns the shape and names after a concat.

  ## Examples

      iex> Nx.Shape.concatenate([{2, 3, 2}, {1, 3, 2}, {4, 3, 2}], [[:x, :y, :z], [:x, :y, :z], [:x, :y, :z]], 0, 0)
      {{7, 3, 2}, [:x, :y, :z], 0}
  """
  def concatenate([s1 | _] = shapes, [n1 | _] = names, axis, offset) do
    axis = normalize_axis(s1, axis, n1, offset)

    names =
      Enum.zip_with(names, fn zipped ->
        Enum.reduce(zipped, &merge_names!(&1, &2, axis, axis))
      end)

    {concat_dims(shapes, axis), names, axis}
  end

  defp concat_dims([s1 | shapes] = all_shapes, axis) do
    assert_non_concat_dims_equal(all_shapes, axis)

    s1 = Tuple.to_list(s1)

    shapes
    |> Enum.reduce(s1, &concat_shapes(Tuple.to_list(&1), &2, axis))
    |> List.to_tuple()
  end

  defp concat_shapes(shape1, shape2, axis) do
    shape1
    |> Enum.zip(shape2)
    |> Enum.with_index(fn {s1, s2}, i ->
      cond do
        i == axis ->
          s1 + s2

        s1 == s2 ->
          s1

        true ->
          raise ArgumentError,
                "non-concat dims must be equal got" <>
                  " #{inspect(s1)} and #{inspect(s2)}" <>
                  " while concatenating on axis #{axis}"
      end
    end)
  end

  defp assert_non_concat_dims_equal([s1 | shapes], axis) do
    s1_size = tuple_size(s1)
    template = Tuple.delete_at(s1, axis)

    for shape <- shapes,
        tuple_size(shape) != s1_size or Tuple.delete_at(shape, axis) != template do
      raise ArgumentError,
            "expected all shapes to match #{shape_template_on_axis(s1, axis)}, got unmatching shape: #{inspect(shape)}"
    end
  end

  @doc """
  Calculates the intermediate and final shapes used by the
  `Nx.tile` function.
  """
  def tile(%Nx.Tensor{shape: old_shape}, repetitions) do
    num_dims = tuple_size(old_shape)
    length_reps = length(repetitions)

    # grow the dimensionality of the tensor by new_dims_count
    shape_grow_count = Kernel.max(length_reps - num_dims, 0)
    resized_shape_list = List.duplicate(1, shape_grow_count) ++ Tuple.to_list(old_shape)

    repetitions_grow_count = Kernel.max(num_dims - length_reps, 0)
    resized_repetitions = List.duplicate(1, repetitions_grow_count) ++ repetitions

    broadcast_shape = resized_repetitions |> alternate(resized_shape_list) |> List.to_tuple()

    tensor_reshape =
      [1 | Enum.intersperse(resized_shape_list, 1)]
      |> List.to_tuple()

    result_shape =
      resized_repetitions
      |> Enum.zip(resized_shape_list)
      |> Enum.map(fn {x, y} -> x * y end)
      |> List.to_tuple()

    {tensor_reshape, broadcast_shape, result_shape}
  end

  defp alternate([], []), do: []
  defp alternate([h1 | tl1], [h2 | tl2]), do: [h1, h2 | alternate(tl1, tl2)]

  @doc """
  Calculates the output shape of a dot product.
  """
  def dot(s1, c1, names1, b1, s2, c2, names2, b2) do
    validate_dot_axes!(s1, c1, b1, s2, c2, b2)

    {batch_dims, batch_names, s1, c1, names1, s2, c2, names2} =
      prep_dot_batch_output(s1, c1, names1, b1, s2, c2, names2, b2)

    # zip reduce without the batched dimensions
    {output_shape, output_names} = zip_reduce(s1, c1, names1, s2, c2, names2)

    # re-add the batched dimensions.
    if is_nil(batch_dims) do
      {output_shape, output_names}
    else
      output_shape =
        Enum.reduce(Enum.reverse(batch_dims), output_shape, fn x, acc ->
          Tuple.insert_at(acc, 0, x)
        end)

      output_names = batch_names ++ output_names
      {output_shape, output_names}
    end
  end

  defp prep_dot_batch_output(s1, c1, names1, b1, s2, c2, names2, b2) do
    case {b1, b2} do
      {[], []} ->
        {nil, nil, s1, c1, names1, s2, c2, names2}

      {b1, b2} ->
        batch_dims = Enum.map(b1, &elem(s1, &1))
        batch_names = Enum.map(b1, &Enum.at(names1, &1))
        {s1, c1, names1} = shift_left_for_batch(s1, c1, b1, names1)
        {s2, c2, names2} = shift_left_for_batch(s2, c2, b2, names2)
        {batch_dims, batch_names, s1, c1, names1, s2, c2, names2}
    end
  end

  defp shift_left_for_batch(shape, contract_axes, batch_axes, names) do
    non_batch_shapes =
      batch_axes
      |> Enum.reduce(shape, fn _, acc -> Tuple.delete_at(acc, 0) end)

    contract_axes = shift_left_axes(contract_axes, length(batch_axes))

    names =
      batch_axes
      |> Enum.reduce(names, fn _, [_ | tail] -> tail end)

    {non_batch_shapes, contract_axes, names}
  end

  defp shift_left_axes(axes, num_batch_dims) do
    Enum.map(axes, fn a -> a - num_batch_dims end)
  end

  defp validate_dot_axes!(s1, c1, b1, s2, c2, b2) do
    left_batched? = b1 != []
    right_batched? = b2 != []

    if not left_batched? and right_batched? do
      raise ArgumentError, "left tensor must be batched if right tensor is batched"
    end

    if left_batched? and not right_batched? do
      raise ArgumentError, "right tensor must be batched if left tensor is batched"
    end

    # batch axes must be increasing starting from 0
    valid_batch_axes = Enum.to_list(0..(length(b1) - 1)//1)

    # ensure normalized batch axis of left is valid value
    if left_batched? and b1 != valid_batch_axes do
      raise ArgumentError,
            "invalid dot batch axis for the left tensor, batch axes must be successive" <>
              " dimensions starting from 0, got #{inspect(b1)}"
    end

    # ensure normalized batch axis of right is valid value
    if right_batched? and b2 != valid_batch_axes do
      raise ArgumentError,
            "invalid dot batch axis for the right tensor, batch axes must be successive" <>
              " dimensions starting from 0, got #{inspect(b2)}"
    end

    b1_sizes = Enum.map(b1, &elem(s1, &1))
    b2_sizes = Enum.map(b2, &elem(s2, &1))

    # ensure batch dim sizes match if both tensors are batched
    if left_batched? and right_batched? and b1_sizes != b2_sizes do
      raise ArgumentError,
            "dot batch dimension sizes must match, but the left " <>
              "batch dimension of axes #{inspect(b1)} has dimension sizes #{inspect(b1_sizes)}" <>
              "and the right batch dimension of axes #{inspect(b2)} has sizes #{inspect(b2_sizes)}"
    end

    # ensure there is no conflict between left batch axes and left contract axes
    if left_batched? and Enum.any?(b1, &(&1 in c1)) do
      raise ArgumentError,
            "dot batch axes for left tensor (#{inspect(b1)}) cannot be in contract axes" <>
              " (#{inspect(c1)})"
    end

    # ensure there is no conflict between right batch axis and right contract axes
    if right_batched? and Enum.any?(b2, &(&1 in c2)) do
      raise ArgumentError,
            "dot batch axes for right tensor (#{inspect(b2)}) cannot be in contract axes" <>
              " (#{inspect(c2)})"
    end

    :ok
  end

  @doc """
  Returns the shape and names after a Cholesky decomposition.

  ## Examples

      iex> Nx.Shape.cholesky({4, 4}, [:x, :y])
      {{4, 4}, [:x, :y]}

      iex> Nx.Shape.cholesky({3, 3, 3}, [:x, :y, :z])
      {{3, 3, 3}, [:x, :y, :z]}

  ## Error Cases

      iex> Nx.Shape.cholesky({2, 3, 2}, [:x, :y, :z])
      ** (ArgumentError) tensor must be a square matrix or a batch of square matrices, got shape: {2, 3, 2}

      iex> Nx.Shape.cholesky({3}, [:x])
      ** (ArgumentError) tensor must have at least rank 2, got rank 1 with shape {3}
  """
  def cholesky(shape, names) when tuple_size(shape) > 1 do
    rank = tuple_size(shape)
    matrix_shape = {elem(shape, rank - 2), elem(shape, rank - 1)}

    unless match?({n, n}, matrix_shape) do
      raise(
        ArgumentError,
        "tensor must be a square matrix or a batch of square matrices, got shape: #{inspect(shape)}"
      )
    end

    {shape, names}
  end

  def cholesky(shape, _names),
    do:
      raise(
        ArgumentError,
        "tensor must have at least rank 2, got rank #{tuple_size(shape)} with shape #{inspect(shape)}"
      )

  def qr(shape, opts) when tuple_size(shape) > 2 do
    rank = tuple_size(shape)
    matrix_shape = {elem(shape, rank - 2), elem(shape, rank - 1)}
    {{m1, n1}, {m2, n2}} = qr(matrix_shape, opts)

    put_rows_and_columns = fn m, n ->
      shape
      |> Tuple.to_list()
      |> List.replace_at(-2, m)
      |> List.replace_at(-1, n)
      |> List.to_tuple()
    end

    {put_rows_and_columns.(m1, n1), put_rows_and_columns.(m2, n2)}
  end

  def qr({m, n}, opts) do
    mode = opts[:mode]

    k = min(m, n)

    case mode do
      :reduced ->
        {{m, k}, {k, n}}

      _ ->
        {{m, m}, {m, n}}
    end
  end

  def qr(shape, _opts),
    do:
      raise(
        ArgumentError,
        "tensor must have at least rank 2, got rank #{tuple_size(shape)} with shape #{inspect(shape)}"
      )

  def eigh(shape) when tuple_size(shape) > 1 do
    rank = tuple_size(shape)
    {m, n} = {elem(shape, rank - 2), elem(shape, rank - 1)}
    {unchanged_shape, _} = Tuple.to_list(shape) |> Enum.split(-2)

    unless m == n do
      raise(
        ArgumentError,
        "tensor must be a square matrix or a batch of square matrices, got shape: #{inspect(shape)}"
      )
    end

    {
      List.to_tuple(unchanged_shape ++ [m]),
      List.to_tuple(unchanged_shape ++ [m, m])
    }
  end

  def eigh(shape),
    do:
      raise(
        ArgumentError,
        "tensor must have at least rank 2, got rank #{tuple_size(shape)} with shape #{inspect(shape)}"
      )

  def svd(shape, opts \\ [])

  def svd(shape, opts) when tuple_size(shape) > 1 do
    rank = tuple_size(shape)
    {m, n} = {elem(shape, rank - 2), elem(shape, rank - 1)}
    {unchanged_shape, _} = Tuple.to_list(shape) |> Enum.split(-2)
    k = min(m, n)

    if opts[:full_matrices?] do
      [
        [unchanged_shape, [m, m]],
        [unchanged_shape, [k]],
        [unchanged_shape, [n, n]]
      ]
    else
      [
        [unchanged_shape, [m, k]],
        [unchanged_shape, [k]],
        [unchanged_shape, [k, n]]
      ]
    end
    |> Enum.map(&List.flatten/1)
    |> Enum.map(&List.to_tuple/1)
    |> List.to_tuple()
  end

  def svd(shape, _opts),
    do:
      raise(
        ArgumentError,
        "tensor must have at least rank 2, got rank #{tuple_size(shape)} with shape #{inspect(shape)}"
      )

  def lu(shape) when tuple_size(shape) > 1 do
    rank = tuple_size(shape)
    matrix_shape = {elem(shape, rank - 2), elem(shape, rank - 1)}

    unless match?({n, n}, matrix_shape) do
      raise(
        ArgumentError,
        "tensor must be a square matrix or a batch of square matrices, got shape: #{inspect(shape)}"
      )
    end

    {shape, shape, shape}
  end

  def lu(shape),
    do:
      raise(
        ArgumentError,
        "tensor must have at least rank 2, got rank #{tuple_size(shape)} with shape #{inspect(shape)}"
      )

  def matrix_power(shape) when tuple_size(shape) > 1 do
    rank = tuple_size(shape)
    matrix_shape = {elem(shape, rank - 2), elem(shape, rank - 1)}

    unless match?({n, n}, matrix_shape) do
      raise(
        ArgumentError,
        "matrix_power/2 expects a square matrix or a batch of square matrices, got tensor with shape: #{inspect(shape)}"
      )
    end

    :ok
  end

  def matrix_power(shape) do
    raise(
      ArgumentError,
      "matrix_power/2 expects a square matrix or a batch of square matrices, got tensor with shape: #{inspect(shape)}"
    )
  end

  def triangular_solve({n, n}, {n}, _left_side), do: :ok
  def triangular_solve({n, n}, {n, _}, true), do: :ok
  def triangular_solve({n, n}, {_, n}, false), do: :ok

  def triangular_solve({m, n}, _, _) when m != n do
    raise(
      ArgumentError,
      "triangular_solve/3 expected a square matrix or a batch of square matrices, got tensor with shape: #{inspect({m, n})}"
    )
  end

  def triangular_solve(a_shape, b_shape, left_side)
      when tuple_size(a_shape) > 1 and tuple_size(b_shape) > 1 do
    {a_batch_shape, [a_m, a_n]} = a_shape |> Tuple.to_list() |> Enum.split(-2)
    {b_1d_batch_shape, [b_n]} = b_shape |> Tuple.to_list() |> Enum.split(-1)
    {b_2d_batch_shape, [b_m, ^b_n]} = b_shape |> Tuple.to_list() |> Enum.split(-2)

    unless a_m == a_n do
      raise ArgumentError,
            "triangular_solve/3 expected a square matrix or a batch of square matrices, got tensor with shape: #{inspect(a_shape)}"
    end

    cond do
      a_batch_shape == b_1d_batch_shape and a_n == b_n ->
        :ok

      a_batch_shape == b_2d_batch_shape and a_n == b_m and left_side ->
        :ok

      a_batch_shape == b_2d_batch_shape and a_n == b_n and not left_side ->
        :ok

      true ->
        raise ArgumentError, "incompatible dimensions for a and b on triangular solve"
    end
  end

  def triangular_solve(_, _, _),
    do: raise(ArgumentError, "incompatible dimensions for a and b on triangular solve")

  def solve({n, n}, {n}), do: {n}
  def solve({n, n}, {n, m}), do: {n, m}

  def solve({n, n}, b_shape) do
    raise(
      ArgumentError,
      "`b` tensor has incompatible dimensions, expected #{inspect({n, n})} or {#{n}}, got: " <>
        inspect(b_shape)
    )
  end

  def solve(a_shape, b_shape) when tuple_size(a_shape) > 1 and tuple_size(b_shape) > 1 do
    {a_batch_shape, [a_m, a_n]} = a_shape |> Tuple.to_list() |> Enum.split(-2)
    {b_1d_batch_shape, [b_n]} = b_shape |> Tuple.to_list() |> Enum.split(-1)
    {b_2d_batch_shape, [b_m, ^b_n]} = b_shape |> Tuple.to_list() |> Enum.split(-2)

    unless a_m == a_n do
      raise(
        ArgumentError,
        "`a` tensor has incompatible dimensions, expected a square matrix or a batch of square matrices, got: " <>
          inspect(a_shape)
      )
    end

    cond do
      a_batch_shape == b_1d_batch_shape and a_n == b_n ->
        b_shape

      a_batch_shape == b_2d_batch_shape and a_n == b_m ->
        b_shape

      true ->
        expected_1d = List.to_tuple(a_batch_shape ++ [a_n])
        expected_2d = List.to_tuple(a_batch_shape ++ [a_n, "m"])

        raise(
          ArgumentError,
          "`b` tensor has incompatible dimensions, expected #{inspect(expected_2d)} or #{inspect(expected_1d)}, got: " <>
            inspect(b_shape)
        )
    end
  end

  def solve(a_shape, _b_shape) do
    raise(
      ArgumentError,
      "`a` tensor has incompatible dimensions, expected a square matrix or a batch of square matrices, got: " <>
        inspect(a_shape)
    )
  end

  def fft({}) do
    raise ArgumentError, "expected a tensor with rank > 0, got tensor with rank 0"
  end

  def fft(shape) when is_tuple(shape), do: shape

  def fft2(shape) when tuple_size(shape) in [0, 1] do
    raise ArgumentError,
          "expected a tensor with rank > 1, got tensor with rank #{tuple_size(shape)}"
  end

  def fft2(shape) when is_tuple(shape), do: shape

  @doc """
  Output shape after a top_k operation.

  ## Examples

      iex> Nx.Shape.top_k({3, 3, 3}, [:a, :b, :c], 2)
      {{3, 3, 2}, [:a, :b, :c]}

      iex> Nx.Shape.top_k({2, 3, 1}, [:a, :b, :c], 1)
      {{2, 3, 1}, [:a, :b, :c]}

  ## Error cases

      iex> Nx.Shape.top_k({}, [], 1)
      ** (ArgumentError) top_k input must have at least rank 1

      iex> Nx.Shape.top_k({2, 3, 1}, [:a, :b, :c], 2)
      ** (ArgumentError) top_k input last axis size must be greater than or equal to k, got size=1 and k=2

      iex> Nx.Shape.top_k({2, 3, 1}, [:a, :b, :c], -1)
      ** (ArgumentError) top_k k must be an integer greater than or equal to 1, got k=-1
  """
  def top_k(shape, names, k) when is_integer(k) and k >= 1 do
    case shape do
      {} ->
        raise ArgumentError, "top_k input must have at least rank 1"

      tuple ->
        rank = tuple_size(tuple)
        last_axis = elem(tuple, rank - 1)

        if last_axis < k do
          raise ArgumentError,
                "top_k input last axis size must be greater than or" <>
                  " equal to k, got size=#{last_axis} and k=#{k}"
        end

        {put_elem(tuple, rank - 1, k), names}
    end
  end

  def top_k(_shape, _names, k),
    do: raise(ArgumentError, "top_k k must be an integer greater than or equal to 1, got k=#{k}")

  @doc """
  Merges names, raising on mismatch.

  It assumes their length match.
  """
  def merge_names!(left, right) do
    merge_names!(left, right, 0)
  end

  defp merge_names!([left_head | left], [right_head | right], axis),
    do: [merge_names!(left_head, right_head, axis, axis) | merge_names!(left, right, axis + 1)]

  defp merge_names!([], [], _axis),
    do: []

  ## Helpers

  defp count_up(0, _n), do: []
  defp count_up(i, n), do: [n | count_up(i - 1, n + 1)]

  defp count_down(0, _n), do: []
  defp count_down(i, n), do: [n | count_down(i - 1, n - 1)]

  defp merge_names!(nil, nil, _, _), do: nil
  defp merge_names!(nil, name, _, _) when is_atom(name), do: name
  defp merge_names!(name, nil, _, _) when is_atom(name), do: name
  defp merge_names!(name, name, _, _) when is_atom(name), do: name

  defp merge_names!(l_name, r_name, l_axis, r_axis) do
    raise ArgumentError,
          "cannot merge name #{inspect(l_name)} on axis #{l_axis} " <>
            "with name #{inspect(r_name)} on axis #{r_axis}"
  end
end
