defmodule Numerix.Tensor do
  @moduledoc """
  Defines a data structure for a tensor and its operations.

  You can construct a `Tensor` by calling `Tensor.new/1` and passing it a list, or a list of lists, or a list of lists of...you get the idea.

  Example

      use Numerix.Tensor

      x = Tensor.new([[1, 2, 3], [4, 5, 6], [7, 8, 9]])

  Once you have a `Tensor` (or three), you can then use it in normal math operations, e.g. elementwise matrix operations.

  Example

      x = Tensor.new([[1, 2, 3], [4, 5, 6], [7, 8, 9]])
      y = Tensor.new([[1, 2, 3], [4, 5, 6], [7, 8, 9]])
      assert x / y == Tensor.new([[1, 1, 1], [1, 1, 1], [1, 1, 1]])

  As it turns out, this is very handy when you need to implement complex math formulae as the code looks more like math functions than noisy code with a bunch of calls to `Enum.map/2`, `Enum.zip/2` and the like.

  Example

      x = Tensor.new([[0, 0.1, 0.5, 0.9, 1.0]])
      m = max(x)
      e = exp(x - m)
      s = sum(e)
      assert e / s == Tensor.new([[0.1119598021340303, 0.12373471731203411, 0.18459050724175335, 0.2753766776533774, 0.30433829565880477]])

  Oh, I should also mention that this API uses `Flow` to parallelize independent pieces of computation to speed things up! Depending on the type of calculations you're doing, the bigger the data and the more cores you have, the faster it gets.
  """

  alias Numerix.Tensor

  import Kernel, except: [+: 1, -: 1, abs: 1, +: 2, -: 2, *: 2, /: 2, max: 2]

  @max_long_value 9_223_372_036_854_775_807

  defstruct items: [], dims: 1, shape: {0}

  defmacro __using__(_opts) do
    quote do
      import Kernel, except: [+: 1, -: 1, abs: 1, +: 2, -: 2, *: 2, /: 2, max: 2]
      import Numerix.Tensor

      alias Numerix.Tensor
    end
  end

  @doc """
  Creates a new tensor from the given scalar, list or nested list.
  """
  @spec new(number | [number]) :: %Tensor{} | no_return()
  def new(x) when is_number(x) do
    %Tensor{items: x, dims: 0, shape: {}}
  end

  def new(x) when is_list(x) do
    shape = x |> calculate_shape |> Enum.reverse()
    %Tensor{items: x, dims: length(shape), shape: List.to_tuple(shape)}
  end

  def new(x) do
    raise "Tensor must be a numeric scalar, list or nested list, but got #{inspect(x)} instead"
  end

  @doc """
  Returns the biggest element in the given tensor.
  """
  @spec max(%Tensor{}) :: number
  def max(x = %Tensor{}) do
    x.items
    |> List.flatten()
    |> Enum.max()
  end

  @doc """
  Compares the given scalar with each element of the given tensor and returns the biggest of the two.
  """
  @spec max(number | %Tensor{}, %Tensor{}) :: %Tensor{}
  def max(s, x = %Tensor{}) when is_number(s) do
    fn i -> max(s, i) end
    |> t_apply(x)
  end

  def max(x = %Tensor{}, y = %Tensor{}) do
    fn i, j -> max(i, j) end
    |> t_apply(x, y)
  end

  def max(first, second) do
    # Falls back to Elixir's default `max` function.
    Kernel.max(first, second)
  end

  @doc """
  Calculates the sum of all the elements in the given tensor.
  """
  @spec sum(%Tensor{}) :: number
  def sum(x) do
    x.items
    |> List.flatten()
    |> Enum.sum()
  end

  Enum.each([:exp, :log, :sqrt, :tanh], fn fun ->
    @doc """
    Returns the `#{fun}` of the given tensor element-wise.
    """
    def unquote(:"#{fun}")(x) do
      fn i -> apply(:math, unquote(fun), [i]) end
      |> t_apply(x)
    end
  end)

  Enum.each([:pow], fn fun ->
    @doc """
    Returns the result of applying `#{fun}` to the given tensor element-wise.
    """
    def unquote(:"#{fun}")(x = %Tensor{}, s) when is_number(s) do
      fn i -> apply(:math, unquote(fun), [i, s]) end
      |> t_apply(x)
    end
  end)

  Enum.each([:+, :-, :abs], fn op ->
    @doc """
    Returns the result of applying `#{op}` to the given tensor element-wise.
    """
    def unquote(:"#{op}")(x = %Tensor{}) do
      fn i -> apply(Kernel, unquote(op), [i]) end
      |> t_apply(x)
    end

    def unquote(:"#{op}")(x) do
      # Falls back to Elixir's default `#{op}/1` function.
      apply(Kernel, unquote(op), [x])
    end
  end)

  Enum.each([:+, :-, :*, :/], fn op ->
    @doc """
    Returns the result of applying `#{op}` to the given tensor and/or scalar element-wise pair.
    """
    def unquote(:"#{op}")(x = %Tensor{}, s) when is_number(s) do
      fn i -> apply(Kernel, unquote(op), [i, s]) end
      |> t_apply(x)
    end

    # Returns the result of applying `#{op}` to the given scalar and tensor element-wise.
    def unquote(:"#{op}")(s, x = %Tensor{}) when is_number(s) do
      fn i -> apply(Kernel, unquote(op), [s, i]) end
      |> t_apply(x)
    end

    # Returns the result of applying `#{op}` to the given tensors element-wise.
    def unquote(:"#{op}")(x = %Tensor{}, y = %Tensor{}) do
      fn i, j -> apply(Kernel, unquote(op), [i, j]) end
      |> t_apply(x, y)
    end

    def unquote(:"#{op}")(x, y) do
      # Falls back to Elixir's default `#{op}/2` function.
      apply(Kernel, unquote(op), [x, y])
    end
  end)

  @doc """
  Applies the given function to the tensor element-wise and returns the result as a new tensor.
  """
  @spec t_apply(fun(), %Tensor{}) :: %Tensor{}
  def t_apply(fun, x) do
    fun
    |> do_apply(x.items, x.dims)
    |> Tensor.new()
  end

  @doc """
  Applies the given function to the two tensors element-wise and returns the result as a new tensor.
  """
  @spec t_apply(fun(), %Tensor{}, %Tensor{}) :: %Tensor{}
  def t_apply(fun, x, y) do
    fun
    |> do_apply(x.items, y.items, x.dims)
    |> Tensor.new()
  end

  defp calculate_shape(x, shape \\ [])
  defp calculate_shape([], shape), do: [0 | shape]

  defp calculate_shape(x = [y | _], shape) when is_number(y) do
    [length(x) | shape]
  end

  defp calculate_shape(x = [y | _], shape) do
    # credo:disable-for-next-line
    Enum.reduce(x, fn curr, prev ->
      if length(curr) != length(prev) do
        raise "Unexpected tensor shape, make sure every dimension has consistent lengths"
      end

      curr
    end)

    calculate_shape(y, [length(x) | shape])
  end

  defp calculate_shape(_, _) do
    raise "Tensor must be a numeric scalar, list or nested list"
  end

  defp do_apply(fun, x, 0) do
    fun.(x)
  rescue
    ArithmeticError -> @max_long_value
  end

  defp do_apply(fun, items, dim) do
    items
    |> Enum.with_index()
    |> Flow.from_enumerable()
    |> Flow.map(fn {a, i} -> {i, do_apply(fun, a, dim - 1)} end)
    |> to_ordered_values()
  end

  defp do_apply(fun, x, y, 0) do
    fun.(x, y)
  end

  defp do_apply(fun, x, y, dim) do
    x
    |> Stream.zip(y)
    |> Stream.with_index()
    |> Flow.from_enumerable()
    |> Flow.map(fn {{a, b}, i} ->
      {i, do_apply(fun, a, b, dim - 1)}
    end)
    |> to_ordered_values()
  end

  defp to_ordered_values(flow) do
    flow
    |> Enum.to_list()
    |> List.keysort(0)
    |> Keyword.values()
  end
end
