lib/explorer/series.ex

defmodule Explorer.Series do
  @moduledoc """
  The Series struct and API.

  A series can be of the following data types:

    * `:binary` - Binaries (sequences of bytes)
    * `:boolean` - Boolean
    * `:category` - Strings but represented internally as integers
    * `:date` - Date type that unwraps to `Elixir.Date`
    * `{:datetime, precision}` - DateTime type with millisecond/microsecond/nanosecond precision that unwraps to `Elixir.NaiveDateTime`
    * `{:duration, precision}` - Duration type with millisecond/microsecond/nanosecond precision that unwraps to `Explorer.Duration`
    * `{:f, size}` - a 64-bit or 32-bit floating point number
    * `:integer` - 64-bit signed integer
    * `:string` - UTF-8 encoded binary
    * `:time` - Time type that unwraps to `Elixir.Time`
    * `{:list, dtype}` - A recursive dtype that can store lists. Examples: `{:list, :integer}` or
      a nested list dtype like `{:list, {:list, :integer}}`.

  The following data type aliases are also supported:

    * The atom `:float` as an alias for `{:f, 64}` to mirror Elixir's floats
    * The atoms `:f32` and `:f64` as aliases to `{:f, 32}` and `{:f, 64}` for Nx compabitility

  A series must consist of a single data type only. Series may have `nil` values in them.
  The series `dtype` can be retrieved via the `dtype/1` function or directly accessed as
  `series.dtype`. A `series.name` field is also available, but it is always `nil` unless
  the series is retrieved from a dataframe.

  Many functions only apply to certain dtypes. These functions may appear on distinct
  categories on the sidebar. Other functions may work on several datatypes, such as
  comparison functions. In such cases, a "Supported dtypes" section will be available
  in the function documentation.

  ## Creating series

  Series can be created using `from_list/2`, `from_binary/3`, and friends:

  Series can be made of numbers:

      iex> Explorer.Series.from_list([1, 2, 3])
      #Explorer.Series<
        Polars[3]
        integer [1, 2, 3]
      >

  Series are nullable, so you may also include nils:

      iex> Explorer.Series.from_list([1.0, nil, 2.5, 3.1])
      #Explorer.Series<
        Polars[4]
        f64 [1.0, nil, 2.5, 3.1]
      >

  Any of the dtypes above are supported, such as strings:

      iex> Explorer.Series.from_list(["foo", "bar", "baz"])
      #Explorer.Series<
        Polars[3]
        string ["foo", "bar", "baz"]
      >

  """

  import Kernel, except: [and: 2, not: 1, in: 2]

  alias __MODULE__, as: Series
  alias Kernel, as: K
  alias Explorer.Duration
  alias Explorer.Shared

  @datetime_dtypes Explorer.Shared.datetime_types()
  @duration_dtypes Explorer.Shared.duration_types()
  @float_dtypes Explorer.Shared.float_types()
  @date_or_datetime_dtypes [:date | @datetime_dtypes]
  @temporal_dtypes [:time | @date_or_datetime_dtypes ++ @duration_dtypes]
  @numeric_dtypes [:integer | @float_dtypes]
  @numeric_or_temporal_dtypes @numeric_dtypes ++ @temporal_dtypes

  @io_dtypes Shared.dtypes() -- [:binary, :string, {:list, :any}]

  @type dtype ::
          :binary
          | :boolean
          | :category
          | :date
          | :time
          | datetime_dtype
          | duration_dtype
          | {:f, 32}
          | {:f, 64}
          | :integer
          | :string
          | list_dtype

  @type time_unit :: :nanosecond | :microsecond | :millisecond
  @type datetime_dtype :: {:datetime, time_unit}
  @type duration_dtype :: {:duration, time_unit}
  @type list_dtype :: {:list, dtype()}

  @type t :: %Series{data: Explorer.Backend.Series.t(), dtype: dtype()}
  @type lazy_t :: %Series{data: Explorer.Backend.LazySeries.t(), dtype: dtype()}

  @type non_finite :: :nan | :infinity | :neg_infinity
  @type inferable_scalar ::
          number()
          | non_finite()
          | boolean()
          | String.t()
          | Date.t()
          | Time.t()
          | NaiveDateTime.t()

  @doc false
  @enforce_keys [:data, :dtype]
  defstruct [:data, :dtype, :name]

  @behaviour Access
  @compile {:no_warn_undefined, Nx}

  defguardp is_numeric(n) when K.or(is_number(n), K.in(n, [:nan, :infinity, :neg_infinity]))

  defguardp is_io_dtype(dtype) when K.in(dtype, @io_dtypes)

  defguardp is_numeric_dtype(dtype) when K.in(dtype, [{:f, 32}, {:f, 64}, :integer])

  defguardp is_numeric_or_bool_dtype(dtype)
            when K.in(dtype, [{:f, 32}, {:f, 64}, :integer, :boolean])

  defguardp is_numeric_or_temporal_dtype(dtype)
            when K.in(dtype, [
                   {:f, 32},
                   {:f, 64},
                   :integer,
                   :date,
                   :time,
                   {:datetime, :nanosecond},
                   {:datetime, :microsecond},
                   {:datetime, :millisecond},
                   {:duration, :nanosecond},
                   {:duration, :microsecond},
                   {:duration, :millisecond}
                 ])

  @impl true
  def fetch(series, idx) when is_integer(idx), do: {:ok, fetch!(series, idx)}
  def fetch(series, indices) when is_list(indices), do: {:ok, slice(series, indices)}
  def fetch(series, %Range{} = range), do: {:ok, slice(series, range)}

  @impl true
  def pop(series, idx) when is_integer(idx) do
    mask = 0..(size(series) - 1) |> Enum.map(&(&1 != idx)) |> from_list()
    value = fetch!(series, idx)
    series = mask(series, mask)
    {value, series}
  end

  def pop(series, indices) when is_list(indices) do
    mask = 0..(size(series) - 1) |> Enum.map(&K.not(Enum.member?(indices, &1))) |> from_list()
    value = slice(series, indices)
    series = mask(series, mask)
    {value, series}
  end

  def pop(series, %Range{} = range) do
    mask = 0..(size(series) - 1) |> Enum.map(&K.not(Enum.member?(range, &1))) |> from_list()
    value = slice(series, range)
    series = mask(series, mask)
    {value, series}
  end

  @impl true
  def get_and_update(series, idx, fun) when is_integer(idx) do
    value = fetch!(series, idx)
    {current_value, new_value} = fun.(value)
    new_data = series |> to_list() |> List.replace_at(idx, new_value) |> from_list()
    {current_value, new_data}
  end

  defp fetch!(series, idx) do
    size = size(series)
    idx = if idx < 0, do: idx + size, else: idx

    if K.or(idx < 0, idx > size),
      do: raise(ArgumentError, "index #{idx} out of bounds for series of size #{size}")

    apply_series(series, :at, [idx])
  end

  # Conversion

  @doc """
  Creates a new series from a list.

  The list must consist of a single data type and nils. It is possible to have
  a list of only nil values. In this case, the list will have the `:dtype` of float.

  ## Options

    * `:backend` - The backend to allocate the series on.
    * `:dtype` - Cast the series to a given `:dtype`. By default this is `nil`, which means
      that Explorer will infer the type from the values in the list.

  ## Examples

  Explorer will infer the type from the values in the list:

      iex> Explorer.Series.from_list([1, 2, 3])
      #Explorer.Series<
        Polars[3]
        integer [1, 2, 3]
      >

  Series are nullable, so you may also include nils:

      iex> Explorer.Series.from_list([1.0, nil, 2.5, 3.1])
      #Explorer.Series<
        Polars[4]
        f64 [1.0, nil, 2.5, 3.1]
      >

  A mix of integers and floats will be cast to a float:

      iex> Explorer.Series.from_list([1, 2.0])
      #Explorer.Series<
        Polars[2]
        f64 [1.0, 2.0]
      >

  Floats series can accept NaN, Inf, and -Inf values:

      iex> Explorer.Series.from_list([1.0, 2.0, :nan, 4.0])
      #Explorer.Series<
        Polars[4]
        f64 [1.0, 2.0, NaN, 4.0]
      >

      iex> Explorer.Series.from_list([1.0, 2.0, :infinity, 4.0])
      #Explorer.Series<
        Polars[4]
        f64 [1.0, 2.0, Inf, 4.0]
      >

      iex> Explorer.Series.from_list([1.0, 2.0, :neg_infinity, 4.0])
      #Explorer.Series<
        Polars[4]
        f64 [1.0, 2.0, -Inf, 4.0]
      >

  Trying to create a "nil" series will, by default, result in a series of floats:

      iex> Explorer.Series.from_list([nil, nil])
      #Explorer.Series<
        Polars[2]
        f64 [nil, nil]
      >

  You can specify the desired `dtype` for a series with the `:dtype` option.

      iex> Explorer.Series.from_list([nil, nil], dtype: :integer)
      #Explorer.Series<
        Polars[2]
        integer [nil, nil]
      >

      iex> Explorer.Series.from_list([1, nil], dtype: :string)
      #Explorer.Series<
        Polars[2]
        string ["1", nil]
      >

      iex> Explorer.Series.from_list([1, 2], dtype: :f32)
      #Explorer.Series<
        Polars[2]
        f32 [1.0, 2.0]
      >

      iex> Explorer.Series.from_list([1, nil, 2], dtype: :float)
      #Explorer.Series<
        Polars[3]
        f64 [1.0, nil, 2.0]
      >

  The `dtype` option is particulary important if a `:binary` series is desired, because
  by default binary series will have the dtype of `:string`:

      iex> Explorer.Series.from_list([<<228, 146, 51>>, <<42, 209, 236>>], dtype: :binary)
      #Explorer.Series<
        Polars[2]
        binary [<<228, 146, 51>>, <<42, 209, 236>>]
      >

  A series mixing UTF8 strings and binaries is possible:

      iex> Explorer.Series.from_list([<<228, 146, 51>>, "Elixir"], dtype: :binary)
      #Explorer.Series<
        Polars[2]
        binary [<<228, 146, 51>>, "Elixir"]
      >

  Another option is to create a categorical series from a list of strings:

      iex> Explorer.Series.from_list(["EUA", "Brazil", "Poland"], dtype: :category)
      #Explorer.Series<
        Polars[3]
        category ["EUA", "Brazil", "Poland"]
      >

  It is possible to create a series of `:datetime` from a list of microseconds since Unix Epoch.

      iex> Explorer.Series.from_list([1649883642 * 1_000 * 1_000], dtype: {:datetime, :microsecond})
      #Explorer.Series<
        Polars[1]
        datetime[μs] [2022-04-13 21:00:42.000000]
      >

  It is possible to create a series of `:time` from a list of nanoseconds since midnight.

      iex> Explorer.Series.from_list([123 * 1_000 * 1_000 * 1_000], dtype: :time)
      #Explorer.Series<
        Polars[1]
        time [00:02:03.000000]
      >

  Mixing non-numeric data types will raise an ArgumentError:

      iex> Explorer.Series.from_list([1, "a"])
      ** (ArgumentError) the value "a" does not match the inferred series dtype :integer
  """
  @doc type: :conversion
  @spec from_list(list :: list(), opts :: Keyword.t()) :: Series.t()
  def from_list(list, opts \\ []) do
    opts = Keyword.validate!(opts, [:dtype, :backend])
    backend = backend_from_options!(opts)

    normalised_dtype = if opts[:dtype], do: Shared.normalise_dtype!(opts[:dtype])

    type = Shared.dtype_from_list!(list, normalised_dtype)
    {list, type} = Shared.cast_numerics(list, type)

    series = backend.from_list(list, type)

    case normalised_dtype do
      nil -> series
      ^type -> series
      other -> cast(series, other)
    end
  end

  defp from_same_value(%{data: %backend{}}, value) do
    backend.from_list([value], Shared.dtype_from_list!([value], nil))
  end

  defp from_same_value(%{data: %backend{}}, value, dtype) do
    backend.from_list([value], dtype)
  end

  @doc """
  Builds a series of `dtype` from `binary`.

  All binaries must be in native endianness.

  ## Options

    * `:backend` - The backend to allocate the series on.

  ## Examples

  Integers and floats follow their native encoding:

      iex> Explorer.Series.from_binary(<<1.0::float-64-native, 2.0::float-64-native>>, {:f, 64})
      #Explorer.Series<
        Polars[2]
        f64 [1.0, 2.0]
      >

      iex> Explorer.Series.from_binary(<<-1::signed-64-native, 1::signed-64-native>>, :integer)
      #Explorer.Series<
        Polars[2]
        integer [-1, 1]
      >

  Booleans are unsigned integers:

      iex> Explorer.Series.from_binary(<<1, 0, 1>>, :boolean)
      #Explorer.Series<
        Polars[3]
        boolean [true, false, true]
      >

  Dates are encoded as i32 representing days from the Unix epoch (1970-01-01):

      iex> binary = <<-719162::signed-32-native, 0::signed-32-native, 6129::signed-32-native>>
      iex> Explorer.Series.from_binary(binary, :date)
      #Explorer.Series<
        Polars[3]
        date [0001-01-01, 1970-01-01, 1986-10-13]
      >

  Times are encoded as i64 representing nanoseconds from midnight:

      iex> binary = <<0::signed-64-native, 86399999999000::signed-64-native>>
      iex> Explorer.Series.from_binary(binary, :time)
      #Explorer.Series<
        Polars[2]
        time [00:00:00.000000, 23:59:59.999999]
      >

  Datetimes are encoded as i64 representing microseconds from the Unix epoch (1970-01-01):

      iex> binary = <<0::signed-64-native, 529550625987654::signed-64-native>>
      iex> Explorer.Series.from_binary(binary, {:datetime, :microsecond})
      #Explorer.Series<
        Polars[2]
        datetime[μs] [1970-01-01 00:00:00.000000, 1986-10-13 01:23:45.987654]
      >

  """
  @doc type: :conversion
  @spec from_binary(
          binary,
          :float
          | {:f, 32}
          | {:f, 64}
          | :integer
          | :boolean
          | :date
          | :time
          | datetime_dtype
          | duration_dtype,
          keyword
        ) ::
          Series.t()
  def from_binary(binary, dtype, opts \\ []) when K.and(is_binary(binary), is_list(opts)) do
    opts = Keyword.validate!(opts, [:backend])
    {_type, alignment} = dtype |> Shared.normalise_dtype!() |> Shared.dtype_to_iotype!()

    if rem(bit_size(binary), alignment) != 0 do
      raise ArgumentError, "binary for dtype #{dtype} is expected to be #{alignment}-bit aligned"
    end

    backend = backend_from_options!(opts)
    backend.from_binary(binary, dtype)
  end

  @doc """
  Converts a `t:Nx.Tensor.t/0` to a series.

  > #### Warning {: .warning}
  >
  > `Nx` is an optional dependency. You will need to ensure it's installed to use this function.

  ## Options

    * `:backend` - The backend to allocate the series on.
    * `:dtype` - The dtype of the series, it must match the underlying tensor type.

  ## Examples

  Integers and floats:

      iex> tensor = Nx.tensor([1, 2, 3])
      iex> Explorer.Series.from_tensor(tensor)
      #Explorer.Series<
        Polars[3]
        integer [1, 2, 3]
      >

      iex> tensor = Nx.tensor([1.0, 2.0, 3.0], type: :f64)
      iex> Explorer.Series.from_tensor(tensor)
      #Explorer.Series<
        Polars[3]
        f64 [1.0, 2.0, 3.0]
      >

  Unsigned 8-bit tensors are assumed to be booleans:

      iex> tensor = Nx.tensor([1, 0, 1], type: :u8)
      iex> Explorer.Series.from_tensor(tensor)
      #Explorer.Series<
        Polars[3]
        boolean [true, false, true]
      >

  Signed 32-bit tensors are assumed to be dates:

      iex> tensor = Nx.tensor([-719162, 0, 6129], type: :s32)
      iex> Explorer.Series.from_tensor(tensor)
      #Explorer.Series<
        Polars[3]
        date [0001-01-01, 1970-01-01, 1986-10-13]
      >

  Times are signed 64-bit representing nanoseconds from midnight and
  therefore must have their dtype explicitly given:

      iex> tensor = Nx.tensor([0, 86399999999000])
      iex> Explorer.Series.from_tensor(tensor, dtype: :time)
      #Explorer.Series<
        Polars[2]
        time [00:00:00.000000, 23:59:59.999999]
      >

  Datetimes are signed 64-bit and therefore must have their dtype explicitly given:

      iex> tensor = Nx.tensor([0, 529550625987654])
      iex> Explorer.Series.from_tensor(tensor, dtype: {:datetime, :microsecond})
      #Explorer.Series<
        Polars[2]
        datetime[μs] [1970-01-01 00:00:00.000000, 1986-10-13 01:23:45.987654]
      >
  """
  @doc type: :conversion
  @spec from_tensor(tensor :: Nx.Tensor.t(), opts :: Keyword.t()) :: Series.t()
  def from_tensor(tensor, opts \\ []) when is_struct(tensor, Nx.Tensor) do
    opts = Keyword.validate!(opts, [:dtype, :backend])
    type = Nx.type(tensor)
    {dtype, opts} = Keyword.pop_lazy(opts, :dtype, fn -> Shared.iotype_to_dtype!(type) end)

    if Shared.dtype_to_iotype!(dtype) != type do
      raise ArgumentError,
            "dtype #{dtype} expects a tensor of type #{inspect(Shared.dtype_to_iotype!(dtype))} " <>
              "but got type #{inspect(type)}"
    end

    backend = backend_from_options!(opts)
    tensor |> Nx.to_binary() |> backend.from_binary(dtype)
  end

  @doc """
  Replaces the contents of the given series by the one given in
  a tensor or list.

  The new series will have the same dtype and backend as the current
  series, but the size may not necessarily match.

  ## Tensor examples

      iex> s = Explorer.Series.from_list([0, 1, 2])
      iex> Explorer.Series.replace(s, Nx.tensor([1, 2, 3]))
      #Explorer.Series<
        Polars[3]
        integer [1, 2, 3]
      >

  This is particularly useful for categorical columns:

      iex> s = Explorer.Series.from_list(["foo", "bar", "baz"], dtype: :category)
      iex> Explorer.Series.replace(s, Nx.tensor([2, 1, 0]))
      #Explorer.Series<
        Polars[3]
        category ["baz", "bar", "foo"]
      >

  ## List examples

  Similar to tensors, we can also replace by lists:

      iex> s = Explorer.Series.from_list([0, 1, 2])
      iex> Explorer.Series.replace(s, [1, 2, 3, 4, 5])
      #Explorer.Series<
        Polars[5]
        integer [1, 2, 3, 4, 5]
      >

  The same considerations as above apply.
  """
  @doc type: :conversion
  @spec replace(Series.t(), Nx.Tensor.t() | list()) :: Series.t()
  def replace(series, tensor_or_list)

  def replace(series, tensor) when is_struct(tensor, Nx.Tensor) do
    replace_tensor_or_list(series, :from_tensor, tensor)
  end

  def replace(series, list) when is_list(list) do
    replace_tensor_or_list(series, :from_list, list)
  end

  defp replace_tensor_or_list(series, fun, arg) do
    backend_series_string = Atom.to_string(series.data.__struct__)
    backend_string = binary_part(backend_series_string, 0, byte_size(backend_series_string) - 7)
    backend = String.to_atom(backend_string)

    case series.dtype do
      :category ->
        Series
        |> apply(fun, [arg, [dtype: :integer, backend: backend]])
        |> categorise(series)

      dtype ->
        apply(Series, fun, [arg, [dtype: dtype, backend: backend]])
    end
  end

  @doc """
  Converts a series to a list.

  > #### Warning {: .warning}
  >
  > You must avoid converting a series to list, as that requires copying
  > the whole series in memory. Prefer to use the operations in this module
  > rather than the ones in `Enum` whenever possible, as this module is
  > optimized for large series.

  ## Examples

      iex> series = Explorer.Series.from_list([1, 2, 3])
      iex> Explorer.Series.to_list(series)
      [1, 2, 3]
  """
  @doc type: :conversion
  @spec to_list(series :: Series.t()) :: list()
  def to_list(series), do: apply_series(series, :to_list)

  @doc """
  Converts a series to an enumerable.

  The enumerable will lazily traverse the series.

  > #### Warning {: .warning}
  >
  > You must avoid converting a series to enum, as that will copy the whole
  > series in memory as you traverse it. Prefer to use the operations in this
  > module rather than the ones in `Enum` whenever possible, as this module is
  > optimized for large series.

  ## Examples

      iex> series = Explorer.Series.from_list([1, 2, 3])
      iex> series |> Explorer.Series.to_enum() |> Enum.to_list()
      [1, 2, 3]
  """
  @doc type: :conversion
  @spec to_enum(series :: Series.t()) :: Enumerable.t()
  def to_enum(series), do: Explorer.Series.Iterator.new(series)

  @doc """
  Returns a series as a list of fixed-width binaries.

  An io vector (`iovec`) is the Erlang VM term for a flat list of binaries.
  This is typically a reference to the in-memory representation of the series.
  If the whole series in contiguous in memory, then the list will have a single
  element. All binaries are in native endianness.

  This operation fails if the series has `nil` values.
  Use `fill_missing/1` to handle them accordingly.

  To retrieve the type of the underlying io vector, use `iotype/1`.
  To convert an iovec to a binary, you can use `IO.iodata_to_binary/1`.

  ## Examples

  Integers and floats follow their native encoding:

      iex> series = Explorer.Series.from_list([-1, 0, 1])
      iex> Explorer.Series.to_iovec(series)
      [<<-1::signed-64-native, 0::signed-64-native, 1::signed-64-native>>]

      iex> series = Explorer.Series.from_list([1.0, 2.0, 3.0])
      iex> Explorer.Series.to_iovec(series)
      [<<1.0::float-64-native, 2.0::float-64-native, 3.0::float-64-native>>]

  Booleans are encoded as 0 and 1:

      iex> series = Explorer.Series.from_list([true, false, true])
      iex> Explorer.Series.to_iovec(series)
      [<<1, 0, 1>>]

  Dates are encoded as i32 representing days from the Unix epoch (1970-01-01):

      iex> series = Explorer.Series.from_list([~D[0001-01-01], ~D[1970-01-01], ~D[1986-10-13]])
      iex> Explorer.Series.to_iovec(series)
      [<<-719162::signed-32-native, 0::signed-32-native, 6129::signed-32-native>>]

  Times are encoded as i64 representing nanoseconds from midnight:

      iex> series = Explorer.Series.from_list([~T[00:00:00.000000], ~T[23:59:59.999999]])
      iex> Explorer.Series.to_iovec(series)
      [<<0::signed-64-native, 86399999999000::signed-64-native>>]

  Datetimes are encoded as i64 representing their precision from the Unix epoch (1970-01-01):

      iex> series = Explorer.Series.from_list([~N[0001-01-01 00:00:00], ~N[1970-01-01 00:00:00], ~N[1986-10-13 01:23:45.987654]])
      iex> Explorer.Series.to_iovec(series)
      [<<-62135596800000000::signed-64-native, 0::signed-64-native, 529550625987654::signed-64-native>>]

  The operation raises for binaries and strings, as they do not provide a fixed-width
  binary representation:

      iex> s = Explorer.Series.from_list(["a", "b", "c", "b"])
      iex> Explorer.Series.to_iovec(s)
      ** (ArgumentError) cannot convert series of dtype :string into iovec

  However, if appropriate, you can convert them to categorical types,
  which will then return the index of each category:

      iex> series = Explorer.Series.from_list(["a", "b", "c", "b"], dtype: :category)
      iex> Explorer.Series.to_iovec(series)
      [<<0::unsigned-32-native, 1::unsigned-32-native, 2::unsigned-32-native, 1::unsigned-32-native>>]

  """
  @doc type: :conversion
  @spec to_iovec(series :: Series.t()) :: [binary]
  def to_iovec(%Series{dtype: dtype} = series) do
    if is_io_dtype(dtype) do
      apply_series(series, :to_iovec)
    else
      raise ArgumentError, "cannot convert series of dtype #{inspect(dtype)} into iovec"
    end
  end

  @doc """
  Returns a series as a fixed-width binary.

  This is a shortcut around `to_iovec/1`. If possible, prefer
  to use `to_iovec/1` as that avoids copying binaries.

  ## Examples

      iex> series = Explorer.Series.from_list([1, 2, 3])
      iex> Explorer.Series.to_binary(series)
      <<1::signed-64-native, 2::signed-64-native, 3::signed-64-native>>

      iex> series = Explorer.Series.from_list([true, false, true])
      iex> Explorer.Series.to_binary(series)
      <<1, 0, 1>>

  """
  @doc type: :conversion
  @spec to_binary(series :: Series.t()) :: binary
  def to_binary(series), do: series |> to_iovec() |> IO.iodata_to_binary()

  @doc """
  Converts a series to a `t:Nx.Tensor.t/0`.

  Note that `Explorer.Series` are automatically converted
  to tensors when passed to numerical definitions.
  The tensor type is given by `iotype/1`.

  > #### Warning {: .warning}
  >
  > `Nx` is an optional dependency. You will need to ensure it's installed to use this function.

  ## Options

    * `:backend` - the Nx backend to allocate the tensor on

  ## Examples

      iex> s = Explorer.Series.from_list([1, 2, 3])
      iex> Explorer.Series.to_tensor(s)
      #Nx.Tensor<
        s64[3]
        [1, 2, 3]
      >

      iex> s = Explorer.Series.from_list([true, false, true])
      iex> Explorer.Series.to_tensor(s)
      #Nx.Tensor<
        u8[3]
        [1, 0, 1]
      >

  """
  @doc type: :conversion
  @spec to_tensor(series :: Series.t(), tensor_opts :: Keyword.t()) :: Nx.Tensor.t()
  def to_tensor(%Series{dtype: dtype} = series, tensor_opts \\ []) do
    case iotype(series) do
      {_, _} = type ->
        Nx.from_binary(to_binary(series), type, tensor_opts)

      :none when Kernel.in(dtype, [:string, :binary]) ->
        raise ArgumentError,
              "cannot convert #{inspect(dtype)} series to tensor (consider casting the series to a :category type before)"

      :none ->
        raise ArgumentError, "cannot convert #{inspect(dtype)} series to tensor"
    end
  end

  @doc """
  Cast the series to another type.

  ## Examples

      iex> s = Explorer.Series.from_list([1, 2, 3])
      iex> Explorer.Series.cast(s, :string)
      #Explorer.Series<
        Polars[3]
        string ["1", "2", "3"]
      >

      iex> s = Explorer.Series.from_list([1, 2, 3])
      iex> Explorer.Series.cast(s, {:f, 64})
      #Explorer.Series<
        Polars[3]
        f64 [1.0, 2.0, 3.0]
      >

      iex> s = Explorer.Series.from_list([1, 2, 3])
      iex> Explorer.Series.cast(s, :date)
      #Explorer.Series<
        Polars[3]
        date [1970-01-02, 1970-01-03, 1970-01-04]
      >

  Note that `time` is represented as an integer of nanoseconds since midnight.
  In Elixir we can't represent nanoseconds, only microseconds. So be aware that
  information can be lost if a conversion is needed (e.g. calling `to_list/1`).

      iex> s = Explorer.Series.from_list([1_000, 2_000, 3_000])
      iex> Explorer.Series.cast(s, :time)
      #Explorer.Series<
        Polars[3]
        time [00:00:00.000001, 00:00:00.000002, 00:00:00.000003]
      >

      iex> s = Explorer.Series.from_list([86399 * 1_000 * 1_000 * 1_000])
      iex> Explorer.Series.cast(s, :time)
      #Explorer.Series<
        Polars[1]
        time [23:59:59.000000]
      >

  Note that `datetime` is represented as an integer of microseconds since Unix Epoch (1970-01-01 00:00:00).

      iex> s = Explorer.Series.from_list([1, 2, 3])
      iex> Explorer.Series.cast(s, {:datetime, :microsecond})
      #Explorer.Series<
        Polars[3]
        datetime[μs] [1970-01-01 00:00:00.000001, 1970-01-01 00:00:00.000002, 1970-01-01 00:00:00.000003]
      >

      iex> s = Explorer.Series.from_list([1649883642 * 1_000 * 1_000])
      iex> Explorer.Series.cast(s, {:datetime, :microsecond})
      #Explorer.Series<
        Polars[1]
        datetime[μs] [2022-04-13 21:00:42.000000]
      >

  You can also use `cast/2` to categorise a string:

      iex> s = Explorer.Series.from_list(["apple", "banana",  "apple", "lemon"])
      iex> Explorer.Series.cast(s, :category)
      #Explorer.Series<
        Polars[4]
        category ["apple", "banana", "apple", "lemon"]
      >

  `cast/2` will return the series as a no-op if you try to cast to the same dtype.

      iex> s = Explorer.Series.from_list([1, 2, 3])
      iex> Explorer.Series.cast(s, :integer)
      #Explorer.Series<
        Polars[3]
        integer [1, 2, 3]
      >
  """
  @doc type: :element_wise
  @spec cast(series :: Series.t(), dtype :: dtype()) :: Series.t()
  def cast(%Series{dtype: dtype} = series, dtype), do: series

  def cast(series, dtype) do
    if normalised = Shared.normalise_dtype(dtype) do
      apply_series(series, :cast, [normalised])
    else
      dtype_error("cast/2", dtype, Shared.dtypes())
    end
  end

  @doc """
  Converts a string series to a datetime series with a given `format_string`.

  For the format string specification, refer to the
  [chrono crate documentation](https://docs.rs/chrono/latest/chrono/format/strftime/).

  Use `cast(series, :datetime)` if you prefer the format to be inferred (if possible).

  ## Examples

      iex> s = Explorer.Series.from_list(["2023-01-05 12:34:56", "XYZ", nil])
      iex> Explorer.Series.strptime(s, "%Y-%m-%d %H:%M:%S")
      #Explorer.Series<
        Polars[3]
        datetime[μs] [2023-01-05 12:34:56.000000, nil, nil]
      >
  """
  @doc type: :element_wise
  @spec strptime(series :: Series.t(), format_string :: String.t()) :: Series.t()
  def strptime(%Series{dtype: dtype} = series, format_string) when K.in(dtype, [:string]),
    do: apply_series(series, :strptime, [format_string])

  def strptime(%Series{dtype: dtype}, _format_string),
    do: dtype_error("strptime/2", dtype, [:string])

  @doc """
  Converts a datetime series to a string series.

  For the format string specification, refer to the
  [chrono crate documentation](https://docs.rs/chrono/latest/chrono/format/strftime/index.html).

  Use `cast(series, :string)` for the default `"%Y-%m-%d %H:%M:%S%.6f"` format.

  ## Examples

      iex> s = Explorer.Series.from_list([~N[2023-01-05 12:34:56], nil])
      iex> Explorer.Series.strftime(s, "%Y/%m/%d %H:%M:%S")
      #Explorer.Series<
        Polars[2]
        string ["2023/01/05 12:34:56", nil]
      >
  """
  @doc type: :element_wise
  @spec strftime(series :: Series.t(), format_string :: String.t()) :: Series.t()
  def strftime(%Series{dtype: dtype} = series, format_string) when K.in(dtype, @datetime_dtypes),
    do: apply_series(series, :strftime, [format_string])

  def strftime(%Series{dtype: dtype}, _format_string),
    do: dtype_error("strftime/2", dtype, @datetime_dtypes)

  @doc """
  Clip (or clamp) the values in a series.

  Values that fall outside of the interval defined by the `min` and `max`
  bounds are clipped to the bounds.

  ## Supported dtypes

    * `:integer`
    * `{:f, 32}`
    * `{:f, 64}`

  Clipping other dtypes are possible using `select/3`.

  ## Examples

      iex> s = Explorer.Series.from_list([-50, 5, nil, 50])
      iex> Explorer.Series.clip(s, 1, 10)
      #Explorer.Series<
        Polars[4]
        integer [1, 5, nil, 10]
      >

      iex> s = Explorer.Series.from_list([-50, 5, nil, 50])
      iex> Explorer.Series.clip(s, 1.5, 10.5)
      #Explorer.Series<
        Polars[4]
        f64 [1.5, 5.0, nil, 10.5]
      >
  """
  @doc type: :element_wise
  @spec clip(series :: Series.t(), min :: number(), max :: number()) :: Series.t()
  def clip(%Series{dtype: dtype} = series, min, max) when is_numeric_dtype(dtype) do
    if !K.and(is_number(min), is_number(max)) do
      raise ArgumentError,
            "Explorer.Series.clip/3 expects both the min and max bounds to be numbers"
    end

    if min > max do
      raise ArgumentError,
            "Explorer.Series.clip/3 expects the max bound to be greater than the min bound"
    end

    apply_series(series, :clip, [min, max])
  end

  def clip(%Series{dtype: dtype}, _min, _max),
    do: dtype_error("clip/3", dtype, @numeric_dtypes)

  # Introspection

  @doc """
  Returns the data type of the series.

  See the moduledoc for all supported dtypes.

  ## Examples

      iex> s = Explorer.Series.from_list([1, 2, 3])
      iex> Explorer.Series.dtype(s)
      :integer

      iex> s = Explorer.Series.from_list(["a", nil, "b", "c"])
      iex> Explorer.Series.dtype(s)
      :string
  """
  @doc type: :introspection
  @spec dtype(series :: Series.t()) :: dtype()
  def dtype(%Series{dtype: dtype}), do: dtype

  @doc """
  Returns the size of the series.

  This is not allowed inside a lazy series. Use `count/1` instead.

  ## Examples

      iex> s = Explorer.Series.from_list([~D[1999-12-31], ~D[1989-01-01]])
      iex> Explorer.Series.size(s)
      2
  """
  @doc type: :introspection
  @spec size(series :: Series.t()) :: non_neg_integer() | lazy_t()
  def size(series), do: apply_series(series, :size)

  @doc """
  Returns the type of the underlying fixed-width binary representation.

  It returns something in the shape of `{atom(), bits_size}` or `:none`.
  It is often used in conjunction with `to_iovec/1` and `to_binary/1`.

  The possible iotypes are:

  * `:u` for unsigned integers.
  * `:s` for signed integers.
  * `:f` for floats.

  ## Examples

      iex> s = Explorer.Series.from_list([1, 2, 3, 4])
      iex> Explorer.Series.iotype(s)
      {:s, 64}

      iex> s = Explorer.Series.from_list([~D[1999-12-31], ~D[1989-01-01]])
      iex> Explorer.Series.iotype(s)
      {:s, 32}

      iex> s = Explorer.Series.from_list([~T[00:00:00.000000], ~T[23:59:59.999999]])
      iex> Explorer.Series.iotype(s)
      {:s, 64}

      iex> s = Explorer.Series.from_list([1.2, 2.3, 3.5, 4.5])
      iex> Explorer.Series.iotype(s)
      {:f, 64}

      iex> s = Explorer.Series.from_list([true, false, true])
      iex> Explorer.Series.iotype(s)
      {:u, 8}

  The operation returns `:none` for strings and binaries, as they do not
  provide a fixed-width binary representation:

      iex> s = Explorer.Series.from_list(["a", "b", "c"])
      iex> Explorer.Series.iotype(s)
      :none

  However, if appropriate, you can convert them to categorical types,
  which will then return the index of each category:

      iex> s = Explorer.Series.from_list(["a", "b", "c"], dtype: :category)
      iex> Explorer.Series.iotype(s)
      {:u, 32}

  """
  @doc type: :introspection
  @spec iotype(series :: Series.t()) :: {:s | :u | :f, non_neg_integer()} | :none
  def iotype(%Series{dtype: dtype} = series) do
    if is_io_dtype(dtype) do
      apply_series(series, :iotype)
    else
      :none
    end
  end

  @doc """
  Return a series with the category names of a categorical series.

  Each category has the index equal to its position.
  No order for the categories is guaranteed.

  ## Examples

      iex> s = Explorer.Series.from_list(["a", "b", "c", nil, "a", "c"], dtype: :category)
      iex> Explorer.Series.categories(s)
      #Explorer.Series<
        Polars[3]
        string ["a", "b", "c"]
      >

      iex> s = Explorer.Series.from_list(["c", "a", "b"], dtype: :category)
      iex> Explorer.Series.categories(s)
      #Explorer.Series<
        Polars[3]
        string ["c", "a", "b"]
      >

  """
  @doc type: :introspection
  @spec categories(series :: Series.t()) :: Series.t()
  def categories(%Series{dtype: :category} = series), do: apply_series(series, :categories)
  def categories(%Series{dtype: dtype}), do: dtype_error("categories/1", dtype, [:category])

  @doc """
  Categorise a series of integers or strings according to `categories`.

  This function receives a series of integers or strings and convert them
  into the categories specified by the second argument.
  The second argument can be one of:

    * a series with dtype `:category`. The integers will be indexes into
      the categories of the given series (returned by `categories/1`)

    * a series with dtype `:string`. The integers will be indexes into
      the series itself

    * a list of strings. The integers will be indexes into the list

  This is going to essentially "copy" the source of categories from the left series
  to the right. All members from the left that are not present in the right hand-side
  are going to be `nil`.

  If you have a series of strings and you want to convert them into categories,
  invoke `cast(series, :category)` instead.

  ## Examples

  If a categorical series is given as second argument, we will extract its
  categories and map the integers into it:

      iex> categories = Explorer.Series.from_list(["a", "b", "c", nil, "a"], dtype: :category)
      iex> indexes = Explorer.Series.from_list([0, 2, 1, 0, 2])
      iex> Explorer.Series.categorise(indexes, categories)
      #Explorer.Series<
        Polars[5]
        category ["a", "c", "b", "a", "c"]
      >

  Otherwise, if a list of strings or a series of strings is given, they are
  considered to be the categories series itself:

      iex> categories = Explorer.Series.from_list(["a", "b", "c"])
      iex> indexes = Explorer.Series.from_list([0, 2, 1, 0, 2])
      iex> Explorer.Series.categorise(indexes, categories)
      #Explorer.Series<
        Polars[5]
        category ["a", "c", "b", "a", "c"]
      >

      iex> indexes = Explorer.Series.from_list([0, 2, 1, 0, 2])
      iex> Explorer.Series.categorise(indexes, ["a", "b", "c"])
      #Explorer.Series<
        Polars[5]
        category ["a", "c", "b", "a", "c"]
      >

  Elements that are not mapped to a category will become `nil`:

      iex> indexes = Explorer.Series.from_list([0, 2, nil, 0, 2, 7])
      iex> Explorer.Series.categorise(indexes, ["a", "b", "c"])
      #Explorer.Series<
        Polars[6]
        category ["a", "c", nil, "a", "c", nil]
      >

  Strings can be used as "indexes" to create a categorical series
  with the intersection of members:

      iex> strings = Explorer.Series.from_list(["a", "c", nil, "c", "b", "d"])
      iex> Explorer.Series.categorise(strings, ["a", "b", "c"])
      #Explorer.Series<
        Polars[6]
        category ["a", "c", nil, "c", "b", nil]
      >

  """
  @doc type: :element_wise
  def categorise(%Series{dtype: l_dtype} = series, %Series{dtype: dtype} = categories)
      when K.and(K.in(l_dtype, [:integer, :string]), K.in(dtype, [:string, :category])),
      do: apply_series(series, :categorise, [categories])

  def categorise(%Series{dtype: l_dtype} = series, [head | _] = categories)
      when K.and(K.in(l_dtype, [:integer, :string]), is_binary(head)),
      do: apply_series(series, :categorise, [from_list(categories, dtype: :string)])

  # Slice and dice

  @doc """
  Returns the first N elements of the series.

  ## Examples

      iex> s = 1..100 |> Enum.to_list() |> Explorer.Series.from_list()
      iex> Explorer.Series.head(s)
      #Explorer.Series<
        Polars[10]
        integer [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
      >
  """
  @doc type: :shape
  @spec head(series :: Series.t(), n_elements :: integer()) :: Series.t()
  def head(series, n_elements \\ 10), do: apply_series(series, :head, [n_elements])

  @doc """
  Returns the last N elements of the series.

  ## Examples

      iex> s = 1..100 |> Enum.to_list() |> Explorer.Series.from_list()
      iex> Explorer.Series.tail(s)
      #Explorer.Series<
        Polars[10]
        integer [91, 92, 93, 94, 95, 96, 97, 98, 99, 100]
      >
  """
  @doc type: :shape
  @spec tail(series :: Series.t(), n_elements :: integer()) :: Series.t()
  def tail(series, n_elements \\ 10), do: apply_series(series, :tail, [n_elements])

  @doc """
  Returns the first element of the series.

  ## Examples

      iex> s = 1..100 |> Enum.to_list() |> Explorer.Series.from_list()
      iex> Explorer.Series.first(s)
      1
  """
  @doc type: :shape
  @spec first(series :: Series.t()) :: any()
  def first(series), do: apply_series(series, :first, [])

  @doc """
  Returns the last element of the series.

  ## Examples

      iex> s = 1..100 |> Enum.to_list() |> Explorer.Series.from_list()
      iex> Explorer.Series.last(s)
      100
  """
  @doc type: :shape
  @spec last(series :: Series.t()) :: any()
  def last(series), do: apply_series(series, :last, [])

  @doc """
  Shifts `series` by `offset` with `nil` values.

  Positive offset shifts from first, negative offset shifts from last.

  ## Examples

      iex> s = 1..5 |> Enum.to_list() |> Explorer.Series.from_list()
      iex> Explorer.Series.shift(s, 2)
      #Explorer.Series<
        Polars[5]
        integer [nil, nil, 1, 2, 3]
      >

      iex> s = 1..5 |> Enum.to_list() |> Explorer.Series.from_list()
      iex> Explorer.Series.shift(s, -2)
      #Explorer.Series<
        Polars[5]
        integer [3, 4, 5, nil, nil]
      >
  """
  @doc type: :shape
  @spec shift(series :: Series.t(), offset :: integer()) :: Series.t()
  def shift(series, offset)
      when is_integer(offset),
      do: apply_series(series, :shift, [offset, nil])

  @doc """
  Returns a series from two series, based on a predicate.

  The resulting series is built by evaluating each element of
  `predicate` and returning either the corresponding element from
  `on_true` or `on_false`.

  `predicate` must be a boolean series. `on_true` and `on_false` must be
  a series of the same size as `predicate` or a series of size 1.
  """
  @doc type: :element_wise
  @spec select(
          predicate :: Series.t(),
          on_true :: Series.t() | inferable_scalar(),
          on_false :: Series.t() | inferable_scalar()
        ) ::
          Series.t()
  def select(%Series{dtype: predicate_dtype} = predicate, on_true, on_false) do
    if predicate_dtype != :boolean do
      raise ArgumentError,
            "Explorer.Series.select/3 expect the first argument to be a series of booleans, got: #{inspect(predicate_dtype)}"
    end

    %Series{dtype: on_true_dtype} = on_true = maybe_from_list(on_true)
    %Series{dtype: on_false_dtype} = on_false = maybe_from_list(on_false)

    cond do
      K.and(is_numeric_dtype(on_true_dtype), is_numeric_dtype(on_false_dtype)) ->
        apply_series_list(:select, [predicate, on_true, on_false])

      on_true_dtype == on_false_dtype ->
        apply_series_list(:select, [predicate, on_true, on_false])

      true ->
        dtype_mismatch_error("select/3", on_true_dtype, on_false_dtype)
    end
  end

  defp maybe_from_list(%Series{} = series), do: series
  defp maybe_from_list(other), do: from_list([other])

  @doc """
  Returns a random sample of the series.

  If given an integer as the second argument, it will return N samples. If given a float, it will
  return that proportion of the series.

  Can sample with or without replace.

  ## Options

    * `:replace` - If set to `true`, each sample will be independent and therefore values may repeat.
      Required to be `true` for `n` greater then the number of rows in the series or `frac` > 1.0. (default: `false`)
    * `:seed` - An integer to be used as a random seed. If nil, a random value between 0 and 2^64 − 1 will be used. (default: nil)
    * `:shuffle` - In case the sample is equal to the size of the series, shuffle tells if the resultant
      series should be shuffled or if it should return the same series. (default: `false`).

  ## Examples

      iex> s = 1..100 |> Enum.to_list() |> Explorer.Series.from_list()
      iex> Explorer.Series.sample(s, 10, seed: 100)
      #Explorer.Series<
        Polars[10]
        integer [57, 9, 54, 62, 50, 77, 35, 88, 1, 69]
      >

      iex> s = 1..100 |> Enum.to_list() |> Explorer.Series.from_list()
      iex> Explorer.Series.sample(s, 0.05, seed: 100)
      #Explorer.Series<
        Polars[5]
        integer [9, 56, 79, 28, 54]
      >

      iex> s = 1..5 |> Enum.to_list() |> Explorer.Series.from_list()
      iex> Explorer.Series.sample(s, 7, seed: 100, replace: true)
      #Explorer.Series<
        Polars[7]
        integer [4, 1, 3, 4, 3, 4, 2]
      >

      iex> s = 1..5 |> Enum.to_list() |> Explorer.Series.from_list()
      iex> Explorer.Series.sample(s, 1.2, seed: 100, replace: true)
      #Explorer.Series<
        Polars[6]
        integer [4, 1, 3, 4, 3, 4]
      >

      iex> s = 0..9 |> Enum.to_list() |> Explorer.Series.from_list()
      iex> Explorer.Series.sample(s, 1.0, seed: 100, shuffle: false)
      #Explorer.Series<
        Polars[10]
        integer [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
      >

      iex> s = 0..9 |> Enum.to_list() |> Explorer.Series.from_list()
      iex> Explorer.Series.sample(s, 1.0, seed: 100, shuffle: true)
      #Explorer.Series<
        Polars[10]
        integer [7, 9, 2, 0, 4, 1, 3, 8, 5, 6]
      >

  """
  @doc type: :shape
  @spec sample(series :: Series.t(), n_or_frac :: number(), opts :: Keyword.t()) :: Series.t()
  def sample(series, n_or_frac, opts \\ []) when is_number(n_or_frac) do
    opts = Keyword.validate!(opts, replace: false, shuffle: false, seed: nil)

    size = size(series)

    # In case the series is lazy, we don't perform this check here.
    if K.and(
         is_integer(size),
         K.and(opts[:replace] == false, invalid_size_for_sample?(n_or_frac, size))
       ) do
      raise ArgumentError,
            "in order to sample more elements than are in the series (#{size}), sampling " <>
              "`replace` must be true"
    end

    apply_series(series, :sample, [n_or_frac, opts[:replace], opts[:shuffle], opts[:seed]])
  end

  defp invalid_size_for_sample?(n, size) when is_integer(n), do: n > size

  defp invalid_size_for_sample?(frac, size) when is_float(frac),
    do: invalid_size_for_sample?(round(frac * size), size)

  @doc """
  Change the elements order randomly.

  ## Options

    * `:seed` - An integer to be used as a random seed. If nil,
      a random value between 0 and 2^64 − 1 will be used. (default: nil)

  ## Examples

      iex> s = 1..10 |> Enum.to_list() |> Explorer.Series.from_list()
      iex> Explorer.Series.shuffle(s, seed: 100)
      #Explorer.Series<
        Polars[10]
        integer [8, 10, 3, 1, 5, 2, 4, 9, 6, 7]
      >

  """
  @doc type: :shape
  @spec shuffle(series :: Series.t(), opts :: Keyword.t()) :: Series.t()
  def shuffle(series, opts \\ [])

  def shuffle(series, opts) do
    opts = Keyword.validate!(opts, seed: nil)

    sample(series, 1.0, seed: opts[:seed], shuffle: true)
  end

  @doc """
  Takes every *n*th value in this series, returned as a new series.

  ## Examples

      iex> s = 1..10 |> Enum.to_list() |> Explorer.Series.from_list()
      iex> Explorer.Series.at_every(s, 2)
      #Explorer.Series<
        Polars[5]
        integer [1, 3, 5, 7, 9]
      >

  If *n* is bigger than the size of the series, the result is a new series with only the first value of the supplied series.

      iex> s = 1..10 |> Enum.to_list() |> Explorer.Series.from_list()
      iex> Explorer.Series.at_every(s, 20)
      #Explorer.Series<
        Polars[1]
        integer [1]
      >
  """
  @doc type: :shape
  @spec at_every(series :: Series.t(), every_n :: integer()) :: Series.t()
  def at_every(series, every_n), do: apply_series(series, :at_every, [every_n])

  @doc """
  Picks values based on an `Explorer.Query`.

  The query is compiled and runs efficiently against the series.
  The query must return a boolean expression or a list of boolean expressions.
  When a list is returned, they are joined as `and` expressions.

  > #### Notice {: .notice}
  >
  > This is a macro. You must `require Explorer.Series` before using it.

  Besides element-wise series operations, you can also use window functions
  and aggregations inside comparisons.

  See `filter_with/2` for a callback version of this function without
  `Explorer.Query`.
  See `mask/2` if you want to filter values based on another series.

  ## Syntax

  > #### Notice {: .notice}
  >
  > This macro uses the special `_` syntax.

  DataFrames have named columns, so their queries use column names as variables:

      iex> require Explorer.DataFrame
      iex> df = Explorer.DataFrame.new(col_name: [1, 2, 3])
      iex> Explorer.DataFrame.filter(df, col_name > 2)
      #Explorer.DataFrame<
        Polars[1 x 1]
        col_name integer [3]
      >

  Series have no named columns.
  (A series constitutes a single column, so no name is required.)
  This means their queries can't use column names as variables.
  Instead, series queries use the special `_` variable like so:

      iex> s = Explorer.Series.from_list([1, 2, 3])
      iex> Explorer.Series.filter(s, _ > 2)
      #Explorer.Series<
        Polars[1]
        integer [3]
      >

  ## Examples

      iex> s = Explorer.Series.from_list(["a", "b", "c"])
      iex> Explorer.Series.filter(s, _ == "b")
      #Explorer.Series<
        Polars[1]
        string ["b"]
      >

      iex> s = Explorer.Series.from_list([1, 2, 3])
      iex> Explorer.Series.filter(s, remainder(_, 2) == 1)
      #Explorer.Series<
        Polars[2]
        integer [1, 3]
      >

  Returning a non-boolean expression errors:

      iex> s = Explorer.Series.from_list([1, 2, 3])
      iex> Explorer.Series.filter(s, cumulative_max(_))
      ** (ArgumentError) expecting the function to return a boolean LazySeries, but instead it returned a LazySeries of type :integer

  Which can be addressed by converting it to boolean:

      iex> s = Explorer.Series.from_list([1, 2, 3])
      iex> Explorer.Series.filter(s, cumulative_max(_) == 1)
      #Explorer.Series<
        Polars[1]
        integer [1]
      >
  """
  @doc type: :element_wise
  defmacro filter(series, query) do
    quote do
      require Explorer.Query

      Explorer.DataFrame.new(_: unquote(series))
      |> Explorer.DataFrame.filter_with(Explorer.Query.query(unquote(query)))
      |> Explorer.DataFrame.pull(:_)
    end
  end

  @doc """
  Filters a series with a callback function.

  See `mask/2` if you want to filter values based on another series.

  ## Examples

      iex> series = Explorer.Series.from_list([1, 2, 3])
      iex> is_odd = fn s -> s |> Explorer.Series.remainder(2) |> Explorer.Series.equal(1) end
      iex> Explorer.Series.filter_with(series, is_odd)
      #Explorer.Series<
        Polars[2]
        integer [1, 3]
      >
  """
  @doc type: :element_wise
  @spec filter_with(
          series :: Series.t(),
          fun :: (Series.t() -> Series.lazy_t())
        ) :: Series.t()
  def filter_with(%Series{} = series, fun) when is_function(fun, 1) do
    Explorer.DataFrame.new(series: series)
    |> Explorer.DataFrame.filter_with(&fun.(&1[:series]))
    |> Explorer.DataFrame.pull(:series)
  end

  @doc """
  Filters a series with a mask.

  ## Examples

      iex> s1 = Explorer.Series.from_list([1,2,3])
      iex> s2 = Explorer.Series.from_list([true, false, true])
      iex> Explorer.Series.mask(s1, s2)
      #Explorer.Series<
        Polars[2]
        integer [1, 3]
      >
  """
  @doc type: :element_wise
  @spec mask(series :: Series.t(), mask :: Series.t()) :: Series.t()
  def mask(series, %Series{} = mask), do: apply_series(series, :mask, [mask])

  @doc """
  Assign ranks to data with appropriate handling of tied values.

  ## Options

  * `:method` - Determine how ranks are assigned to tied elements. The following methods are available:
    - `"average"` : Each value receives the average rank that would be assigned to all tied values. (default)
    - `"min"` : Tied values are assigned the minimum rank. Also known as "competition" ranking.
    - `"max"` : Tied values are assigned the maximum of their ranks.
    - `"dense"` : Similar to `"min"`, but the rank of the next highest element is assigned the rank immediately after those assigned to the tied elements.
    - `"ordinal"` : Each value is given a distinct rank based on its occurrence in the series.
    - `"random"` : Similar to `"ordinal"`, but the rank for ties is not dependent on the order that the values occur in the Series.
  * `:descending` - Rank in descending order.
  * `:seed` - An integer to be used as a random seed. If nil, a random value between 0 and 2^64 − 1 will be used. (default: nil)

  ## Examples

      iex> s = Explorer.Series.from_list([3, 6, 1, 1, 6])
      iex> Explorer.Series.rank(s)
      #Explorer.Series<
        Polars[5]
        f64 [3.0, 4.5, 1.5, 1.5, 4.5]
      >

      iex> s = Explorer.Series.from_list([1.1, 2.4, 3.2])
      iex> Explorer.Series.rank(s, method: "ordinal")
      #Explorer.Series<
        Polars[3]
        integer [1, 2, 3]
      >

      iex> s = Explorer.Series.from_list([ ~N[2022-07-07 17:44:13.020548], ~N[2022-07-07 17:43:08.473561], ~N[2022-07-07 17:45:00.116337] ])
      iex> Explorer.Series.rank(s, method: "average")
      #Explorer.Series<
        Polars[3]
        f64 [2.0, 1.0, 3.0]
      >

      iex> s = Explorer.Series.from_list([3, 6, 1, 1, 6])
      iex> Explorer.Series.rank(s, method: "min")
      #Explorer.Series<
        Polars[5]
        integer [3, 4, 1, 1, 4]
      >

      iex> s = Explorer.Series.from_list([3, 6, 1, 1, 6])
      iex> Explorer.Series.rank(s, method: "dense")
      #Explorer.Series<
        Polars[5]
        integer [2, 3, 1, 1, 3]
      >


      iex> s = Explorer.Series.from_list([3, 6, 1, 1, 6])
      iex> Explorer.Series.rank(s, method: "random", seed: 42)
      #Explorer.Series<
        Polars[5]
        integer [3, 4, 2, 1, 5]
      >
  """
  @doc type: :element_wise
  @spec rank(series :: Series.t(), opts :: Keyword.t()) :: Series.t()
  def rank(series, opts \\ [])

  def rank(series, opts) do
    opts = Keyword.validate!(opts, method: "average", descending: false, seed: nil)

    apply_series(series, :rank, [opts[:method], opts[:descending], opts[:seed]])
  end

  @doc """
  Returns a slice of the series, with `size` elements starting at `offset`.

  ## Examples

      iex> s = Explorer.Series.from_list([1, 2, 3, 4, 5])
      iex> Explorer.Series.slice(s, 1, 2)
      #Explorer.Series<
        Polars[2]
        integer [2, 3]
      >

  Negative offsets count from the end of the series:

      iex> s = Explorer.Series.from_list([1, 2, 3, 4, 5])
      iex> Explorer.Series.slice(s, -3, 2)
      #Explorer.Series<
        Polars[2]
        integer [3, 4]
      >

  If the offset runs past the end of the series,
  the series is empty:

      iex> s = Explorer.Series.from_list([1, 2, 3, 4, 5])
      iex> Explorer.Series.slice(s, 10, 3)
      #Explorer.Series<
        Polars[0]
        integer []
      >

  If the size runs past the end of the series,
  the result may be shorter than the size:

      iex> s = Explorer.Series.from_list([1, 2, 3, 4, 5])
      iex> Explorer.Series.slice(s, -3, 4)
      #Explorer.Series<
        Polars[3]
        integer [3, 4, 5]
      >
  """
  @doc type: :shape
  @spec slice(series :: Series.t(), offset :: integer(), size :: integer()) :: Series.t()
  def slice(series, offset, size), do: apply_series(series, :slice, [offset, size])

  @doc """
  Slices the elements at the given indices as a new series.

  The indices may be either a list of indices or a range.
  A list of indices does not support negative numbers.
  Ranges may be negative on either end, which are then
  normalized. Note ranges in Elixir are inclusive.

  ## Examples

      iex> s = Explorer.Series.from_list(["a", "b", "c"])
      iex> Explorer.Series.slice(s, [0, 2])
      #Explorer.Series<
        Polars[2]
        string ["a", "c"]
      >

      iex> s = Explorer.Series.from_list(["a", "b", "c"])
      iex> Explorer.Series.slice(s, 1..2)
      #Explorer.Series<
        Polars[2]
        string ["b", "c"]
      >

      iex> s = Explorer.Series.from_list(["a", "b", "c"])
      iex> Explorer.Series.slice(s, -2..-1)
      #Explorer.Series<
        Polars[2]
        string ["b", "c"]
      >

      iex> s = Explorer.Series.from_list(["a", "b", "c"])
      iex> Explorer.Series.slice(s, 3..2//1)
      #Explorer.Series<
        Polars[0]
        string []
      >

  """
  @doc type: :shape
  @spec slice(series :: Series.t(), indices :: [integer()] | Range.t() | Series.t()) :: Series.t()
  def slice(series, indices) when is_list(indices),
    do: apply_series(series, :slice, [indices])

  def slice(series, %Series{dtype: :integer} = indices),
    do: apply_series(series, :slice, [indices])

  def slice(_series, %Series{dtype: invalid_dtype}),
    do: dtype_error("slice/2", invalid_dtype, [:integer])

  def slice(series, first..last//1) do
    first = if first < 0, do: first + size(series), else: first
    last = if last < 0, do: last + size(series), else: last
    size = last - first + 1

    if K.and(first >= 0, size >= 0) do
      apply_series(series, :slice, [first, size])
    else
      apply_series(series, :slice, [[]])
    end
  end

  def slice(series, %Range{} = range),
    do: slice(series, Enum.slice(0..(size(series) - 1)//1, range))

  @doc """
  Returns the value of the series at the given index.

  This function will raise an error in case the index
  is out of bounds.

  ## Examples

      iex> s = Explorer.Series.from_list(["a", "b", "c"])
      iex> Explorer.Series.at(s, 2)
      "c"

      iex> s = Explorer.Series.from_list(["a", "b", "c"])
      iex> Explorer.Series.at(s, 4)
      ** (ArgumentError) index 4 out of bounds for series of size 3
  """
  @doc type: :shape
  @spec at(series :: Series.t(), idx :: integer()) :: any()
  def at(series, idx), do: fetch!(series, idx)

  @doc """
  Returns a string series with all values concatenated.

  ## Examples

      iex> s1 = Explorer.Series.from_list(["a", "b", "c"])
      iex> s2 = Explorer.Series.from_list(["d", "e", "f"])
      iex> s3 = Explorer.Series.from_list(["g", "h", "i"])
      iex> Explorer.Series.format([s1, s2, s3])
      #Explorer.Series<
        Polars[3]
        string ["adg", "beh", "cfi"]
      >

      iex> s1 = Explorer.Series.from_list(["a", "b", "c", "d"])
      iex> s2 = Explorer.Series.from_list([1, 2, 3, 4])
      iex> s3 = Explorer.Series.from_list([1.5, :nan, :infinity, :neg_infinity])
      iex> Explorer.Series.format([s1, "/", s2, "/", s3])
      #Explorer.Series<
        Polars[4]
        string ["a/1/1.5", "b/2/NaN", "c/3/inf", "d/4/-inf"]
      >

      iex> s1 = Explorer.Series.from_list([<<1>>, <<239, 191, 19>>], dtype: :binary)
      iex> s2 = Explorer.Series.from_list([<<3>>, <<4>>], dtype: :binary)
      iex> Explorer.Series.format([s1, s2])
      ** (RuntimeError) Polars Error: invalid utf-8 sequence
  """
  @doc type: :shape
  @spec format([Series.t() | String.t()]) :: Series.t()
  def format([_ | _] = list) do
    list = cast_to_string(list)
    impl!(list).format(list)
  end

  defp cast_to_string(list) do
    Enum.map(list, fn
      %Series{dtype: :string} = s ->
        s

      %Series{} = s ->
        cast(s, :string)

      value when is_binary(value) ->
        from_list([value], dtype: :string)

      other ->
        raise ArgumentError,
              "format/1 expects a list of series or strings, got: #{inspect(other)}"
    end)
  end

  @doc """
  Concatenate one or more series.

  The dtypes must match unless all are numeric, in which case all series will be downcast to float.

  ## Examples

      iex> s1 = Explorer.Series.from_list([1, 2, 3])
      iex> s2 = Explorer.Series.from_list([4, 5, 6])
      iex> Explorer.Series.concat([s1, s2])
      #Explorer.Series<
        Polars[6]
        integer [1, 2, 3, 4, 5, 6]
      >

      iex> s1 = Explorer.Series.from_list([1, 2, 3])
      iex> s2 = Explorer.Series.from_list([4.0, 5.0, 6.4])
      iex> Explorer.Series.concat([s1, s2])
      #Explorer.Series<
        Polars[6]
        f64 [1.0, 2.0, 3.0, 4.0, 5.0, 6.4]
      >
  """
  @doc type: :shape
  @spec concat([Series.t()]) :: Series.t()
  def concat([%Series{} | _t] = series) do
    dtypes = series |> Enum.map(& &1.dtype) |> Enum.uniq()

    case dtypes do
      [_dtype] ->
        impl!(series).concat(series)

      [a, b] when K.and(is_numeric_dtype(a), is_numeric_dtype(b)) ->
        series = Enum.map(series, &cast(&1, {:f, 64}))
        impl!(series).concat(series)

      incompatible ->
        raise ArgumentError,
              "cannot concatenate series with mismatched dtypes: #{inspect(incompatible)}. " <>
                "First cast the series to the desired dtype."
    end
  end

  @doc """
  Concatenate two series.

  `concat(s1, s2)` is equivalent to `concat([s1, s2])`.
  """
  @doc type: :shape
  @spec concat(s1 :: Series.t(), s2 :: Series.t()) :: Series.t()
  def concat(%Series{} = s1, %Series{} = s2),
    do: concat([s1, s2])

  @doc """
  Finds the first non-missing element at each position.

  ## Examples

      iex> s1 = Explorer.Series.from_list([1, 2, nil, nil])
      iex> s2 = Explorer.Series.from_list([1, 2, nil, 4])
      iex> s3 = Explorer.Series.from_list([nil, nil, 3, 4])
      iex> Explorer.Series.coalesce([s1, s2, s3])
      #Explorer.Series<
        Polars[4]
        integer [1, 2, 3, 4]
      >
  """
  @doc type: :element_wise
  @spec coalesce([Series.t()]) :: Series.t()
  def coalesce([%Series{} = h | t]),
    do: Enum.reduce(t, h, &coalesce(&2, &1))

  @doc """
  Finds the first non-missing element at each position.

  `coalesce(s1, s2)` is equivalent to `coalesce([s1, s2])`.

  ## Examples

      iex> s1 = Explorer.Series.from_list([1, nil, 3, nil])
      iex> s2 = Explorer.Series.from_list([1, 2, nil, 4])
      iex> Explorer.Series.coalesce(s1, s2)
      #Explorer.Series<
        Polars[4]
        integer [1, 2, 3, 4]
      >

      iex> s1 = Explorer.Series.from_list(["foo", nil, "bar", nil])
      iex> s2 = Explorer.Series.from_list([1, 2, nil, 4])
      iex> Explorer.Series.coalesce(s1, s2)
      ** (ArgumentError) cannot invoke Explorer.Series.coalesce/2 with mismatched dtypes: :string and :integer
  """
  @doc type: :element_wise
  @spec coalesce(s1 :: Series.t(), s2 :: Series.t()) :: Series.t()
  def coalesce(s1, s2) do
    :ok = check_dtypes_for_coalesce!(s1, s2)
    apply_series_list(:coalesce, [s1, s2])
  end

  # Aggregation

  @doc """
  Gets the sum of the series.

  ## Supported dtypes

    * `:integer`
    * `{:f, 32}`
    * `{:f, 64}`
    * `:boolean`

  ## Examples

      iex> s = Explorer.Series.from_list([1, 2, nil, 3])
      iex> Explorer.Series.sum(s)
      6

      iex> s = Explorer.Series.from_list([1.0, 2.0, nil, 3.0])
      iex> Explorer.Series.sum(s)
      6.0

      iex> s = Explorer.Series.from_list([true, false, true])
      iex> Explorer.Series.sum(s)
      2

      iex> s = Explorer.Series.from_list([~D[2021-01-01], ~D[1999-12-31]])
      iex> Explorer.Series.sum(s)
      ** (ArgumentError) Explorer.Series.sum/1 not implemented for dtype :date. Valid dtypes are [:integer, {:f, 32}, {:f, 64}, :boolean]
  """
  @doc type: :aggregation
  @spec sum(series :: Series.t()) :: number() | non_finite() | nil
  def sum(%Series{dtype: dtype} = series) when is_numeric_or_bool_dtype(dtype),
    do: apply_series(series, :sum)

  def sum(%Series{dtype: dtype}),
    do: dtype_error("sum/1", dtype, [:integer, {:f, 32}, {:f, 64}, :boolean])

  @doc """
  Gets the minimum value of the series.

  ## Supported dtypes

    * `:integer`
    * `{:f, 32}`
    * `{:f, 64}`
    * `:date`
    * `:time`
    * `:datetime`
    * `:duration`

  ## Examples

      iex> s = Explorer.Series.from_list([1, 2, nil, 3])
      iex> Explorer.Series.min(s)
      1

      iex> s = Explorer.Series.from_list([1.0, 2.0, nil, 3.0])
      iex> Explorer.Series.min(s)
      1.0

      iex> s = Explorer.Series.from_list([~D[2021-01-01], ~D[1999-12-31]])
      iex> Explorer.Series.min(s)
      ~D[1999-12-31]

      iex> s = Explorer.Series.from_list([~N[2021-01-01 00:00:00], ~N[1999-12-31 00:00:00]])
      iex> Explorer.Series.min(s)
      ~N[1999-12-31 00:00:00.000000]

      iex> s = Explorer.Series.from_list([~T[00:02:03.000451], ~T[00:05:04.000134]])
      iex> Explorer.Series.min(s)
      ~T[00:02:03.000451]

      iex> s = Explorer.Series.from_list(["a", "b", "c"])
      iex> Explorer.Series.min(s)
      ** (ArgumentError) Explorer.Series.min/1 not implemented for dtype :string. Valid dtypes are [:integer, {:f, 32}, {:f, 64}, :time, :date, {:datetime, :nanosecond}, {:datetime, :microsecond}, {:datetime, :millisecond}, {:duration, :nanosecond}, {:duration, :microsecond}, {:duration, :millisecond}]
  """
  @doc type: :aggregation
  @spec min(series :: Series.t()) ::
          number() | non_finite() | Date.t() | Time.t() | NaiveDateTime.t() | nil
  def min(%Series{dtype: dtype} = series) when is_numeric_or_temporal_dtype(dtype),
    do: apply_series(series, :min)

  def min(%Series{dtype: dtype}), do: dtype_error("min/1", dtype, @numeric_or_temporal_dtypes)

  @doc """
  Gets the maximum value of the series.

  ## Supported dtypes

    * `:integer`
    * `{:f, 32}`
    * `{:f, 64}`
    * `:date`
    * `:time`
    * `:datetime`
    * `:duration`

  ## Examples

      iex> s = Explorer.Series.from_list([1, 2, nil, 3])
      iex> Explorer.Series.max(s)
      3

      iex> s = Explorer.Series.from_list([1.0, 2.0, nil, 3.0])
      iex> Explorer.Series.max(s)
      3.0

      iex> s = Explorer.Series.from_list([~D[2021-01-01], ~D[1999-12-31]])
      iex> Explorer.Series.max(s)
      ~D[2021-01-01]

      iex> s = Explorer.Series.from_list([~N[2021-01-01 00:00:00], ~N[1999-12-31 00:00:00]])
      iex> Explorer.Series.max(s)
      ~N[2021-01-01 00:00:00.000000]

      iex> s = Explorer.Series.from_list([~T[00:02:03.000212], ~T[00:05:04.000456]])
      iex> Explorer.Series.max(s)
      ~T[00:05:04.000456]

      iex> s = Explorer.Series.from_list(["a", "b", "c"])
      iex> Explorer.Series.max(s)
      ** (ArgumentError) Explorer.Series.max/1 not implemented for dtype :string. Valid dtypes are [:integer, {:f, 32}, {:f, 64}, :time, :date, {:datetime, :nanosecond}, {:datetime, :microsecond}, {:datetime, :millisecond}, {:duration, :nanosecond}, {:duration, :microsecond}, {:duration, :millisecond}]
  """
  @doc type: :aggregation
  @spec max(series :: Series.t()) ::
          number() | non_finite() | Date.t() | Time.t() | NaiveDateTime.t() | nil
  def max(%Series{dtype: dtype} = series) when is_numeric_or_temporal_dtype(dtype),
    do: apply_series(series, :max)

  def max(%Series{dtype: dtype}), do: dtype_error("max/1", dtype, @numeric_or_temporal_dtypes)

  @doc """
  Gets the index of the maximum value of the series.

  ## Supported dtypes

    * `:integer`
    * `{:f, 32}`
    * `{:f, 64}`
    * `:date`
    * `:time`
    * `:datetime`
    * `:duration`

  ## Examples

      iex> s = Explorer.Series.from_list([1, 2, nil, 3])
      iex> Explorer.Series.argmax(s)
      3

      iex> s = Explorer.Series.from_list([1.0, 2.0, nil, 3.0])
      iex> Explorer.Series.argmax(s)
      3

      iex> s = Explorer.Series.from_list([~D[2021-01-01], ~D[1999-12-31]])
      iex> Explorer.Series.argmax(s)
      0

      iex> s = Explorer.Series.from_list([~N[2021-01-01 00:00:00], ~N[1999-12-31 00:00:00]])
      iex> Explorer.Series.argmax(s)
      0

      iex> s = Explorer.Series.from_list([~T[00:02:03.000212], ~T[00:05:04.000456]])
      iex> Explorer.Series.argmax(s)
      1

      iex> s = Explorer.Series.from_list([], dtype: :integer)
      iex> Explorer.Series.argmax(s)
      nil

      iex> s = Explorer.Series.from_list(["a", "b", "c"])
      iex> Explorer.Series.argmax(s)
      ** (ArgumentError) Explorer.Series.argmax/1 not implemented for dtype :string. Valid dtypes are [:integer, {:f, 32}, {:f, 64}, :time, :date, {:datetime, :nanosecond}, {:datetime, :microsecond}, {:datetime, :millisecond}, {:duration, :nanosecond}, {:duration, :microsecond}, {:duration, :millisecond}]
  """
  @doc type: :aggregation
  @spec argmax(series :: Series.t()) :: number() | non_finite() | nil
  def argmax(%Series{dtype: dtype} = series) when is_numeric_or_temporal_dtype(dtype),
    do: apply_series(series, :argmax)

  def argmax(%Series{dtype: dtype}),
    do: dtype_error("argmax/1", dtype, @numeric_or_temporal_dtypes)

  @doc """
  Gets the index of the minimum value of the series.

  Note that `nil` is ignored. In case an empty list
  or a series whose all elements are `nil` is used,
  the result will be `nil`.

  ## Supported dtypes

    * `:integer`
    * `{:f, 32}`
    * `{:f, 64}`
    * `:date`
    * `:time`
    * `:datetime`
    * `:duration`

  ## Examples

      iex> s = Explorer.Series.from_list([1, 2, nil, 3])
      iex> Explorer.Series.argmin(s)
      0

      iex> s = Explorer.Series.from_list([1.0, 2.0, nil, 3.0])
      iex> Explorer.Series.argmin(s)
      0

      iex> s = Explorer.Series.from_list([~D[2021-01-01], ~D[1999-12-31]])
      iex> Explorer.Series.argmin(s)
      1

      iex> s = Explorer.Series.from_list([~N[2021-01-01 00:00:00], ~N[1999-12-31 00:00:00]])
      iex> Explorer.Series.argmin(s)
      1

      iex> s = Explorer.Series.from_list([~T[00:02:03.000212], ~T[00:05:04.000456]])
      iex> Explorer.Series.argmin(s)
      0

      iex> s = Explorer.Series.from_list([], dtype: :integer)
      iex> Explorer.Series.argmin(s)
      nil

      iex> s = Explorer.Series.from_list([nil], dtype: :integer)
      iex> Explorer.Series.argmin(s)
      nil

      iex> s = Explorer.Series.from_list(["a", "b", "c"])
      iex> Explorer.Series.argmin(s)
      ** (ArgumentError) Explorer.Series.argmin/1 not implemented for dtype :string. Valid dtypes are [:integer, {:f, 32}, {:f, 64}, :time, :date, {:datetime, :nanosecond}, {:datetime, :microsecond}, {:datetime, :millisecond}, {:duration, :nanosecond}, {:duration, :microsecond}, {:duration, :millisecond}]
  """
  @doc type: :aggregation
  @spec argmin(series :: Series.t()) :: number() | non_finite() | nil
  def argmin(%Series{dtype: dtype} = series) when is_numeric_or_temporal_dtype(dtype),
    do: apply_series(series, :argmin)

  def argmin(%Series{dtype: dtype}),
    do: dtype_error("argmin/1", dtype, @numeric_or_temporal_dtypes)

  @doc """
  Gets the mean value of the series.

  ## Supported dtypes

    * `:integer`
    * `{:f, 32}`
    * `{:f, 64}`

  ## Examples

      iex> s = Explorer.Series.from_list([1, 2, nil, 3])
      iex> Explorer.Series.mean(s)
      2.0

      iex> s = Explorer.Series.from_list([1.0, 2.0, nil, 3.0])
      iex> Explorer.Series.mean(s)
      2.0

      iex> s = Explorer.Series.from_list([~D[2021-01-01], ~D[1999-12-31]])
      iex> Explorer.Series.mean(s)
      ** (ArgumentError) Explorer.Series.mean/1 not implemented for dtype :date. Valid dtypes are [:integer, {:f, 32}, {:f, 64}]
  """
  @doc type: :aggregation
  @spec mean(series :: Series.t()) :: float() | non_finite() | nil
  def mean(%Series{dtype: dtype} = series) when is_numeric_dtype(dtype),
    do: apply_series(series, :mean)

  def mean(%Series{dtype: dtype}),
    do: dtype_error("mean/1", dtype, @numeric_dtypes)

  @doc """
  Gets the most common value(s) of the series.

  This function will return multiple values when there's a tie.

  ## Supported dtypes

  All except `:list`.

  ## Examples

      iex> s = Explorer.Series.from_list([1, 2, 2, nil])
      iex> Explorer.Series.mode(s)
      #Explorer.Series<
        Polars[1]
        integer [2]
      >

      iex> s = Explorer.Series.from_list(["a", "b", "b", "c"])
      iex> Explorer.Series.mode(s)
      #Explorer.Series<
        Polars[1]
        string ["b"]
      >

      s = Explorer.Series.from_list([1.0, 2.0, 2.0, 3.0, 3.0])
      Explorer.Series.mode(s)
      #Explorer.Series<
        Polars[2]
        f64 [2.0, 3.0]
      >
  """
  @doc type: :aggregation
  @spec mode(series :: Series.t()) :: Series.t() | nil
  def mode(%Series{dtype: {:list, _} = dtype}),
    do: dtype_error("mode/1", dtype, Shared.dtypes() -- [{:list, :any}])

  def mode(%Series{} = series),
    do: Shared.apply_impl(series, :mode)

  @doc """
  Gets the median value of the series.

  ## Supported dtypes

    * `:integer`
    * `{:f, 32}`
    * `{:f, 64}`

  ## Examples

      iex> s = Explorer.Series.from_list([1, 2, nil, 3])
      iex> Explorer.Series.median(s)
      2.0

      iex> s = Explorer.Series.from_list([1.0, 2.0, nil, 3.0])
      iex> Explorer.Series.median(s)
      2.0

      iex> s = Explorer.Series.from_list([~D[2021-01-01], ~D[1999-12-31]])
      iex> Explorer.Series.median(s)
      ** (ArgumentError) Explorer.Series.median/1 not implemented for dtype :date. Valid dtypes are [:integer, {:f, 32}, {:f, 64}]
  """
  @doc type: :aggregation
  @spec median(series :: Series.t()) :: float() | non_finite() | nil
  def median(%Series{dtype: dtype} = series) when is_numeric_dtype(dtype),
    do: apply_series(series, :median)

  def median(%Series{dtype: dtype}),
    do: dtype_error("median/1", dtype, @numeric_dtypes)

  @doc """
  Gets the variance of the series.

  By default, this is the sample variance. This function also takes an optional
  delta degrees of freedom (ddof). Setting this to zero corresponds to the population
  variance.

  ## Supported dtypes

    * `:integer`
    * `{:f, 32}`
    * `{:f, 64}`

  ## Examples

      iex> s = Explorer.Series.from_list([1, 2, nil, 3])
      iex> Explorer.Series.variance(s)
      1.0

      iex> s = Explorer.Series.from_list([1.0, 2.0, nil, 3.0])
      iex> Explorer.Series.variance(s)
      1.0

      iex> s = Explorer.Series.from_list([~N[2021-01-01 00:00:00], ~N[1999-12-31 00:00:00]])
      iex> Explorer.Series.variance(s)
      ** (ArgumentError) Explorer.Series.variance/1 not implemented for dtype {:datetime, :microsecond}. Valid dtypes are [:integer, {:f, 32}, {:f, 64}]
  """
  @doc type: :aggregation
  @spec variance(series :: Series.t(), ddof :: non_neg_integer()) :: float() | non_finite() | nil
  def variance(series, ddof \\ 1)

  def variance(%Series{dtype: dtype} = series, ddof) when is_numeric_dtype(dtype),
    do: apply_series(series, :variance, [ddof])

  def variance(%Series{dtype: dtype}, _), do: dtype_error("variance/1", dtype, @numeric_dtypes)

  @doc """
  Gets the standard deviation of the series.

  By default, this is the sample standard deviation. This function also takes an optional
  delta degrees of freedom (ddof). Setting this to zero corresponds to the population
  sample standard deviation.

  ## Supported dtypes

    * `:integer`
    * `{:f, 32}`
    * `{:f, 64}`

  ## Examples

      iex> s = Explorer.Series.from_list([1, 2, nil, 3])
      iex> Explorer.Series.standard_deviation(s)
      1.0

      iex> s = Explorer.Series.from_list([1.0, 2.0, nil, 3.0])
      iex> Explorer.Series.standard_deviation(s)
      1.0

      iex> s = Explorer.Series.from_list(["a", "b", "c"])
      iex> Explorer.Series.standard_deviation(s)
      ** (ArgumentError) Explorer.Series.standard_deviation/1 not implemented for dtype :string. Valid dtypes are [:integer, {:f, 32}, {:f, 64}]
  """
  @doc type: :aggregation
  @spec standard_deviation(series :: Series.t(), ddof :: non_neg_integer()) ::
          float() | non_finite() | nil
  def standard_deviation(series, ddof \\ 1)

  def standard_deviation(%Series{dtype: dtype} = series, ddof) when is_numeric_dtype(dtype),
    do: apply_series(series, :standard_deviation, [ddof])

  def standard_deviation(%Series{dtype: dtype}, _),
    do: dtype_error("standard_deviation/1", dtype, @numeric_dtypes)

  @doc """
  Reduce this Series to the product value.

  Note that an empty series is going to result in a
  product of `1`. Values that are `nil` are ignored.

  ## Supported dtypes

    * `:integer`
    * `{:f, 32}`
    * `{:f, 64}`

  ## Examples

      iex> s = Explorer.Series.from_list([1, 2, 3])
      iex> Explorer.Series.product(s)
      6

      iex> s = Explorer.Series.from_list([])
      iex> Explorer.Series.product(s)
      1.0

      iex> s = Explorer.Series.from_list([true, false, true])
      iex> Explorer.Series.product(s)
      ** (ArgumentError) Explorer.Series.product/1 not implemented for dtype :boolean. Valid dtypes are [:integer, {:f, 32}, {:f, 64}]
  """
  @doc type: :aggregation
  @spec product(series :: Series.t()) :: float() | non_finite() | nil
  def product(%Series{dtype: dtype} = series) when is_numeric_dtype(dtype),
    do: at(apply_series(series, :product), 0)

  def product(%Series{dtype: dtype}),
    do: dtype_error("product/1", dtype, @numeric_dtypes)

  @doc """
  Gets the given quantile of the series.

  ## Supported dtypes

    * `:integer`
    * `{:f, 32}`
    * `{:f, 64}`
    * `:date`
    * `:time`
    * `:datetime`
    * `:duration`

  ## Examples

      iex> s = Explorer.Series.from_list([1, 2, nil, 3])
      iex> Explorer.Series.quantile(s, 0.2)
      1

      iex> s = Explorer.Series.from_list([1.0, 2.0, nil, 3.0])
      iex> Explorer.Series.quantile(s, 0.5)
      2.0

      iex> s = Explorer.Series.from_list([~D[2021-01-01], ~D[1999-12-31]])
      iex> Explorer.Series.quantile(s, 0.5)
      ~D[2021-01-01]

      iex> s = Explorer.Series.from_list([~N[2021-01-01 00:00:00], ~N[1999-12-31 00:00:00]])
      iex> Explorer.Series.quantile(s, 0.5)
      ~N[2021-01-01 00:00:00.000000]

      iex> s = Explorer.Series.from_list([~T[01:55:00], ~T[15:35:00], ~T[23:00:00]])
      iex> Explorer.Series.quantile(s, 0.5)
      ~T[15:35:00]

      iex> s = Explorer.Series.from_list([true, false, true])
      iex> Explorer.Series.quantile(s, 0.5)
      ** (ArgumentError) Explorer.Series.quantile/2 not implemented for dtype :boolean. Valid dtypes are [:integer, {:f, 32}, {:f, 64}, :time, :date, {:datetime, :nanosecond}, {:datetime, :microsecond}, {:datetime, :millisecond}, {:duration, :nanosecond}, {:duration, :microsecond}, {:duration, :millisecond}]
  """
  @doc type: :aggregation
  @spec quantile(series :: Series.t(), quantile :: float()) :: any()
  def quantile(%Series{dtype: dtype} = series, quantile)
      when is_numeric_or_temporal_dtype(dtype),
      do: apply_series(series, :quantile, [quantile])

  def quantile(%Series{dtype: dtype}, _),
    do: dtype_error("quantile/2", dtype, @numeric_or_temporal_dtypes)

  @doc """
  Compute the sample skewness of a series.

  For normally distributed data, the skewness should be about zero.

  For unimodal continuous distributions, a skewness value greater
  than zero means that there is more weight in the right tail of the
  distribution.

  ## Supported dtypes

    * `:integer`
    * `{:f, 32}`
    * `{:f, 64}`

  ## Examples

      iex> s = Explorer.Series.from_list([1, 2, 3, 4, 5, 23])
      iex> Explorer.Series.skew(s)
      1.6727687946848508

      iex> s = Explorer.Series.from_list([1, 2, 3, 4, 5, 23])
      iex> Explorer.Series.skew(s, bias: false)
      2.2905330058490514

      iex> s = Explorer.Series.from_list([1, 2, 3, nil, 1])
      iex> Explorer.Series.skew(s, bias: false)
      0.8545630383279712

      iex> s = Explorer.Series.from_list([1, 2, 3, nil, 1])
      iex> Explorer.Series.skew(s)
      0.49338220021815865

      iex> s = Explorer.Series.from_list([true, false, true])
      iex> Explorer.Series.skew(s, false)
      ** (ArgumentError) Explorer.Series.skew/2 not implemented for dtype :boolean. Valid dtypes are [:integer, {:f, 32}, {:f, 64}]
  """
  @doc type: :aggregation
  @spec skew(series :: Series.t(), opts :: Keyword.t()) :: float() | non_finite() | nil
  def skew(series, opts \\ [])

  def skew(%Series{dtype: dtype} = series, opts) when is_numeric_dtype(dtype) do
    opts = Keyword.validate!(opts, bias: true)
    apply_series(series, :skew, [opts[:bias]])
  end

  def skew(%Series{dtype: dtype}, _),
    do: dtype_error("skew/2", dtype, @numeric_dtypes)

  @doc """
  Compute the Pearson's correlation between two series.

  The parameter `ddof` refers to the 'delta degrees of freedom' - the divisor
  used in the correlation calculation. Defaults to 1.

  ## Supported dtypes

    * `:integer`
    * `{:f, 32}`
    * `{:f, 64}`

  ## Examples

      iex> s1 = Series.from_list([1, 8, 3])
      iex> s2 = Series.from_list([4, 5, 2])
      iex> Series.correlation(s1, s2)
      0.5447047794019219
  """
  @doc type: :aggregation
  @spec correlation(
          left :: Series.t() | number(),
          right :: Series.t() | number(),
          ddof :: non_neg_integer()
        ) ::
          float() | non_finite() | nil
  def correlation(left, right, ddof \\ 1) when K.and(is_integer(ddof), ddof >= 0) do
    basic_numeric_operation(:correlation, left, right, [ddof])
  end

  @doc """
  Compute the covariance between two series.

  ## Supported dtypes

    * `:integer`
    * `{:f, 32}`
    * `{:f, 64}`

  ## Examples

      iex> s1 = Series.from_list([1, 8, 3])
      iex> s2 = Series.from_list([4, 5, 2])
      iex> Series.covariance(s1, s2)
      3.0
  """
  @doc type: :aggregation
  @spec covariance(
          left :: Series.t() | number(),
          right :: Series.t() | number(),
          ddof :: non_neg_integer()
        ) ::
          float() | non_finite() | nil
  def covariance(left, right, ddof \\ 1) do
    basic_numeric_operation(:covariance, left, right, [ddof])
  end

  # Cumulative

  @doc """
  Calculates the cumulative maximum of the series.

  Optionally, can accumulate in reverse.

  Does not fill nil values. See `fill_missing/2`.

  ## Supported dtypes

    * `:integer`
    * `{:f, 32}`
    * `{:f, 64}`
    * `:date`
    * `:time`
    * `:datetime`
    * `:duration`

  ## Examples

      iex> s = [1, 2, 3, 4] |> Explorer.Series.from_list()
      iex> Explorer.Series.cumulative_max(s)
      #Explorer.Series<
        Polars[4]
        integer [1, 2, 3, 4]
      >

      iex> s = [1, 2, nil, 4] |> Explorer.Series.from_list()
      iex> Explorer.Series.cumulative_max(s)
      #Explorer.Series<
        Polars[4]
        integer [1, 2, nil, 4]
      >

      iex> s = [~T[03:00:02.000000], ~T[02:04:19.000000], nil, ~T[13:24:56.000000]] |> Explorer.Series.from_list()
      iex> Explorer.Series.cumulative_max(s)
      #Explorer.Series<
        Polars[4]
        time [03:00:02.000000, 03:00:02.000000, nil, 13:24:56.000000]
      >
  """
  @doc type: :window
  @spec cumulative_max(series :: Series.t(), opts :: Keyword.t()) :: Series.t()
  def cumulative_max(series, opts \\ [])

  def cumulative_max(%Series{dtype: dtype} = series, opts)
      when is_numeric_or_temporal_dtype(dtype) do
    opts = Keyword.validate!(opts, reverse: false)
    apply_series(series, :cumulative_max, [opts[:reverse]])
  end

  def cumulative_max(%Series{dtype: dtype}, _),
    do: dtype_error("cumulative_max/2", dtype, @numeric_or_temporal_dtypes)

  @doc """
  Calculates the cumulative minimum of the series.

  Optionally, can accumulate in reverse.

  Does not fill nil values. See `fill_missing/2`.

  ## Supported dtypes

    * `:integer`
    * `{:f, 32}`
    * `{:f, 64}`
    * `:date`
    * `:time`
    * `:datetime`
    * `:duration`

  ## Examples

      iex> s = [1, 2, 3, 4] |> Explorer.Series.from_list()
      iex> Explorer.Series.cumulative_min(s)
      #Explorer.Series<
        Polars[4]
        integer [1, 1, 1, 1]
      >

      iex> s = [1, 2, nil, 4] |> Explorer.Series.from_list()
      iex> Explorer.Series.cumulative_min(s)
      #Explorer.Series<
        Polars[4]
        integer [1, 1, nil, 1]
      >

      iex> s = [~T[03:00:02.000000], ~T[02:04:19.000000], nil, ~T[13:24:56.000000]] |> Explorer.Series.from_list()
      iex> Explorer.Series.cumulative_min(s)
      #Explorer.Series<
        Polars[4]
        time [03:00:02.000000, 02:04:19.000000, nil, 02:04:19.000000]
      >
  """
  @doc type: :window
  @spec cumulative_min(series :: Series.t(), opts :: Keyword.t()) :: Series.t()
  def cumulative_min(series, opts \\ [])

  def cumulative_min(%Series{dtype: dtype} = series, opts)
      when is_numeric_or_temporal_dtype(dtype) do
    opts = Keyword.validate!(opts, reverse: false)
    apply_series(series, :cumulative_min, [opts[:reverse]])
  end

  def cumulative_min(%Series{dtype: dtype}, _),
    do: dtype_error("cumulative_min/2", dtype, @numeric_or_temporal_dtypes)

  @doc """
  Calculates the cumulative sum of the series.

  Optionally, can accumulate in reverse.

  Does not fill nil values. See `fill_missing/2`.

  ## Supported dtypes

    * `:integer`
    * `{:f, 32}`
    * `{:f, 64}`
    * `:boolean`

  ## Examples

      iex> s = [1, 2, 3, 4] |> Explorer.Series.from_list()
      iex> Explorer.Series.cumulative_sum(s)
      #Explorer.Series<
        Polars[4]
        integer [1, 3, 6, 10]
      >

      iex> s = [1, 2, nil, 4] |> Explorer.Series.from_list()
      iex> Explorer.Series.cumulative_sum(s)
      #Explorer.Series<
        Polars[4]
        integer [1, 3, nil, 7]
      >
  """
  @doc type: :window
  @spec cumulative_sum(series :: Series.t(), opts :: Keyword.t()) :: Series.t()
  def cumulative_sum(series, opts \\ [])

  def cumulative_sum(%Series{dtype: dtype} = series, opts)
      when is_numeric_dtype(dtype) do
    opts = Keyword.validate!(opts, reverse: false)
    apply_series(series, :cumulative_sum, [opts[:reverse]])
  end

  def cumulative_sum(%Series{dtype: dtype}, _),
    do: dtype_error("cumulative_sum/2", dtype, @numeric_dtypes)

  @doc """
  Calculates the cumulative product of the series.

  Optionally, can accumulate in reverse.

  Does not fill nil values. See `fill_missing/2`.

  ## Supported dtypes

    * `:integer`
    * `{:f, 32}`
    * `{:f, 64}`

  ## Examples

      iex> s = [1, 2, 3, 2] |> Explorer.Series.from_list()
      iex> Explorer.Series.cumulative_product(s)
      #Explorer.Series<
        Polars[4]
        integer [1, 2, 6, 12]
      >

      iex> s = [1, 2, nil, 4] |> Explorer.Series.from_list()
      iex> Explorer.Series.cumulative_product(s)
      #Explorer.Series<
        Polars[4]
        integer [1, 2, nil, 8]
      >
  """
  @doc type: :window
  @spec cumulative_product(series :: Series.t(), opts :: Keyword.t()) :: Series.t()
  def cumulative_product(series, opts \\ [])

  def cumulative_product(%Series{dtype: dtype} = series, opts)
      when is_numeric_dtype(dtype) do
    opts = Keyword.validate!(opts, reverse: false)
    apply_series(series, :cumulative_product, [opts[:reverse]])
  end

  def cumulative_product(%Series{dtype: dtype}, _),
    do: dtype_error("cumulative_product/2", dtype, @numeric_dtypes)

  # Local minima/maxima

  @doc """
  Returns a boolean mask with `true` where the 'peaks' (series max or min, default max) are.

  ## Supported dtypes

    * `:integer`
    * `{:f, 32}`
    * `{:f, 64}`
    * `:date`
    * `:time`
    * `:datetime`
    * `:duration`

  ## Examples

      iex> s = Explorer.Series.from_list([1, 2, 4, 1, 4])
      iex> Explorer.Series.peaks(s)
      #Explorer.Series<
        Polars[5]
        boolean [false, false, true, false, true]
      >

      iex> s = [~T[03:00:02.000000], ~T[13:24:56.000000], ~T[02:04:19.000000]] |> Explorer.Series.from_list()
      iex> Explorer.Series.peaks(s)
      #Explorer.Series<
        Polars[3]
        boolean [false, true, false]
      >
  """
  @doc type: :element_wise
  @spec peaks(series :: Series.t(), max_or_min :: :max | :min) :: Series.t()
  def peaks(series, max_or_min \\ :max)

  def peaks(%Series{dtype: dtype} = series, max_or_min)
      when is_numeric_or_temporal_dtype(dtype),
      do: apply_series(series, :peaks, [max_or_min])

  def peaks(%Series{dtype: dtype}, _),
    do: dtype_error("peaks/2", dtype, @numeric_or_temporal_dtypes)

  # Arithmetic

  defp cast_for_arithmetic(function, [_, _] = args) do
    args
    |> case do
      [%Series{}, %Series{}] -> args
      [left, %Series{} = right] -> [from_list([left]), right]
      [%Series{} = left, right] -> [left, from_list([right])]
      [left, right] -> no_series_error(function, left, right)
    end
    |> enforce_highest_precision()
  end

  defp enforce_highest_precision([
         %Series{dtype: {left_base, left_timeunit}} = left,
         %Series{dtype: {right_base, right_timeunit}} = right
       ])
       when K.and(is_atom(left_timeunit), is_atom(right_timeunit)) do
    # Higher precision wins, otherwise information is lost.
    case {left_timeunit, right_timeunit} do
      {equal, equal} -> [left, right]
      {:nanosecond, _} -> [left, cast(right, {right_base, :nanosecond})]
      {_, :nanosecond} -> [cast(left, {left_base, :nanosecond}), right]
      {:microsecond, _} -> [left, cast(right, {right_base, :microsecond})]
      {_, :microsecond} -> [cast(left, {left_base, :microsecond}), right]
    end
  end

  defp enforce_highest_precision(args), do: args

  @doc """
  Adds right to left, element-wise.

  When mixing floats and integers, the resulting series will have dtype `{:f, 64}`.

  At least one of the arguments must be a series. If both
  sizes are series, the series must have the same size or
  at last one of them must have size of 1.

  ## Supported dtypes

    * `:integer`
    * `{:f, 32}`
    * `{:f, 64}`

  ## Examples

      iex> s1 = Explorer.Series.from_list([1, 2, 3])
      iex> s2 = Explorer.Series.from_list([4, 5, 6])
      iex> Explorer.Series.add(s1, s2)
      #Explorer.Series<
        Polars[3]
        integer [5, 7, 9]
      >

  You can also use scalar values on both sides:

      iex> s1 = Explorer.Series.from_list([1, 2, 3])
      iex> Explorer.Series.add(s1, 2)
      #Explorer.Series<
        Polars[3]
        integer [3, 4, 5]
      >

      iex> s1 = Explorer.Series.from_list([1, 2, 3])
      iex> Explorer.Series.add(2, s1)
      #Explorer.Series<
        Polars[3]
        integer [3, 4, 5]
      >
  """
  @doc type: :element_wise
  @spec add(
          left :: Series.t() | number() | Date.t() | NaiveDateTime.t() | Duration.t(),
          right :: Series.t() | number() | Date.t() | NaiveDateTime.t() | Duration.t()
        ) :: Series.t()
  def add(left, right) do
    [left, right] = cast_for_arithmetic("add/2", [left, right])

    if out_dtype = cast_to_add(dtype(left), dtype(right)) do
      apply_series_list(:add, [out_dtype, left, right])
    else
      dtype_mismatch_error("add/2", left, right)
    end
  end

  defp cast_to_add(:integer, :integer), do: :integer
  defp cast_to_add(:integer, {:f, _} = float), do: float
  defp cast_to_add({:f, _} = float, :integer), do: float
  defp cast_to_add({:f, _}, {:f, _}), do: {:f, 64}
  defp cast_to_add(:date, {:duration, _}), do: :date
  defp cast_to_add({:duration, _}, :date), do: :date
  defp cast_to_add({:datetime, p}, {:duration, p}), do: {:datetime, p}
  defp cast_to_add({:duration, p}, {:datetime, p}), do: {:datetime, p}
  defp cast_to_add({:duration, p}, {:duration, p}), do: {:duration, p}
  defp cast_to_add(_, _), do: nil

  @doc """
  Subtracts right from left, element-wise.

  When mixing floats and integers, the resulting series will have dtype `{:f, 64}`.

  At least one of the arguments must be a series. If both
  sizes are series, the series must have the same size or
  at last one of them must have size of 1.

  ## Supported dtypes

    * `:integer`
    * `{:f, 32}`
    * `{:f, 64}`

  ## Examples

      iex> s1 = Explorer.Series.from_list([1, 2, 3])
      iex> s2 = Explorer.Series.from_list([4, 5, 6])
      iex> Explorer.Series.subtract(s1, s2)
      #Explorer.Series<
        Polars[3]
        integer [-3, -3, -3]
      >

  You can also use scalar values on both sides:

      iex> s1 = Explorer.Series.from_list([1, 2, 3])
      iex> Explorer.Series.subtract(s1, 2)
      #Explorer.Series<
        Polars[3]
        integer [-1, 0, 1]
      >

      iex> s1 = Explorer.Series.from_list([1, 2, 3])
      iex> Explorer.Series.subtract(2, s1)
      #Explorer.Series<
        Polars[3]
        integer [1, 0, -1]
      >
  """
  @doc type: :element_wise
  @spec subtract(
          left :: Series.t() | number() | Date.t() | NaiveDateTime.t() | Duration.t(),
          right :: Series.t() | number() | Date.t() | NaiveDateTime.t() | Duration.t()
        ) :: Series.t()
  def subtract(left, right) do
    [left, right] = cast_for_arithmetic("subtract/2", [left, right])

    if out_dtype = cast_to_subtract(dtype(left), dtype(right)) do
      apply_series_list(:subtract, [out_dtype, left, right])
    else
      dtype_mismatch_error("subtract/2", left, right)
    end
  end

  defp cast_to_subtract(:integer, :integer), do: :integer
  defp cast_to_subtract(:integer, {:f, _} = float), do: float
  defp cast_to_subtract({:f, _} = float, :integer), do: float
  defp cast_to_subtract({:f, _}, {:f, _}), do: {:f, 64}

  defp cast_to_subtract(:date, :date), do: {:duration, :millisecond}
  defp cast_to_subtract(:date, {:duration, _}), do: :date
  defp cast_to_subtract({:datetime, p}, {:datetime, p}), do: {:duration, p}
  defp cast_to_subtract({:datetime, p}, {:duration, p}), do: {:datetime, p}
  defp cast_to_subtract({:duration, p}, {:duration, p}), do: {:duration, p}
  defp cast_to_subtract(_, _), do: nil

  @doc """
  Multiplies left and right, element-wise.

  When mixing floats and integers, the resulting series will have dtype `{:f, 64}`.

  At least one of the arguments must be a series. If both
  sizes are series, the series must have the same size or
  at last one of them must have size of 1.

  ## Supported dtypes

    * `:integer`
    * `{:f, 32}`
    * `{:f, 64}`

  ## Examples

      iex> s1 = 1..10 |> Enum.to_list() |> Explorer.Series.from_list()
      iex> s2 = 11..20 |> Enum.to_list() |> Explorer.Series.from_list()
      iex> Explorer.Series.multiply(s1, s2)
      #Explorer.Series<
        Polars[10]
        integer [11, 24, 39, 56, 75, 96, 119, 144, 171, 200]
      >

      iex> s1 = 1..5 |> Enum.to_list() |> Explorer.Series.from_list()
      iex> Explorer.Series.multiply(s1, 2)
      #Explorer.Series<
        Polars[5]
        integer [2, 4, 6, 8, 10]
      >
  """
  @doc type: :element_wise
  @spec multiply(
          left :: Series.t() | number() | Duration.t(),
          right :: Series.t() | number() | Duration.t()
        ) :: Series.t()
  def multiply(left, right) do
    [left, right] = cast_for_arithmetic("multiply/2", [left, right])

    if out_dtype = cast_to_multiply(dtype(left), dtype(right)) do
      apply_series_list(:multiply, [out_dtype, left, right])
    else
      dtype_mismatch_error("multiply/2", left, right)
    end
  end

  defp cast_to_multiply(:integer, :integer), do: :integer
  defp cast_to_multiply(:integer, {:f, _} = float), do: float
  defp cast_to_multiply({:f, _} = float, :integer), do: float
  defp cast_to_multiply({:f, _}, {:f, _}), do: {:f, 64}
  defp cast_to_multiply(:integer, {:duration, p}), do: {:duration, p}
  defp cast_to_multiply({:duration, p}, :integer), do: {:duration, p}
  defp cast_to_multiply({:f, _}, {:duration, p}), do: {:duration, p}
  defp cast_to_multiply({:duration, p}, {:f, _}), do: {:duration, p}
  defp cast_to_multiply(_, _), do: nil

  @doc """
  Divides left by right, element-wise.

  The resulting series will have the dtype as `{:f, 64}`.

  At least one of the arguments must be a series. If both
  sizes are series, the series must have the same size or
  at last one of them must have size of 1.

  ## Supported dtypes

    * `:integer`
    * `{:f, 32}`
    * `{:f, 64}`

  ## Examples

      iex> s1 = [10, 10, 10] |> Explorer.Series.from_list()
      iex> s2 = [2, 2, 2] |> Explorer.Series.from_list()
      iex> Explorer.Series.divide(s1, s2)
      #Explorer.Series<
        Polars[3]
        f64 [5.0, 5.0, 5.0]
      >

      iex> s1 = [10, 10, 10] |> Explorer.Series.from_list()
      iex> Explorer.Series.divide(s1, 2)
      #Explorer.Series<
        Polars[3]
        f64 [5.0, 5.0, 5.0]
      >

      iex> s1 = [10, 52 ,10] |> Explorer.Series.from_list()
      iex> Explorer.Series.divide(s1, 2.5)
      #Explorer.Series<
        Polars[3]
        f64 [4.0, 20.8, 4.0]
      >

      iex> s1 = [10, 10, 10] |> Explorer.Series.from_list()
      iex> s2 = [2, 0, 2] |> Explorer.Series.from_list()
      iex> Explorer.Series.divide(s1, s2)
      #Explorer.Series<
        Polars[3]
        f64 [5.0, Inf, 5.0]
      >
  """
  @doc type: :element_wise
  @spec divide(
          left :: Series.t() | number() | Duration.t(),
          right :: Series.t() | number()
        ) :: Series.t()
  def divide(left, right) do
    [left, right] = cast_for_arithmetic("divide/2", [left, right])

    if out_dtype = cast_to_divide(dtype(left), dtype(right)) do
      apply_series_list(:divide, [out_dtype, left, right])
    else
      case dtype(right) do
        {:duration, _} -> raise(ArgumentError, "cannot divide by duration")
        _ -> dtype_mismatch_error("divide/2", left, right)
      end
    end
  end

  defp cast_to_divide(:integer, :integer), do: {:f, 64}
  defp cast_to_divide(:integer, {:f, _} = float), do: float
  defp cast_to_divide({:f, _} = float, :integer), do: float
  defp cast_to_divide({:f, _}, {:f, _}), do: {:f, 64}
  defp cast_to_divide({:duration, p}, :integer), do: {:duration, p}
  defp cast_to_divide({:duration, p}, {:f, _}), do: {:duration, p}
  defp cast_to_divide(_, _), do: nil

  @doc """
  Raises a numeric series to the power of the exponent.

  At least one of the arguments must be a series. If both
  sizes are series, the series must have the same size or
  at last one of them must have size of 1.

  ## Supported dtypes

    * `:integer`
    * `{:f, 32}`
    * `{:f, 64}`

  ## Examples

      iex> s = [8, 16, 32] |> Explorer.Series.from_list()
      iex> Explorer.Series.pow(s, 2.0)
      #Explorer.Series<
        Polars[3]
        f64 [64.0, 256.0, 1024.0]
      >

      iex> s = [2, 4, 6] |> Explorer.Series.from_list()
      iex> Explorer.Series.pow(s, 3)
      #Explorer.Series<
        Polars[3]
        integer [8, 64, 216]
      >

      iex> s = [2, 4, 6] |> Explorer.Series.from_list()
      iex> Explorer.Series.pow(s, -3.0)
      #Explorer.Series<
        Polars[3]
        f64 [0.125, 0.015625, 0.004629629629629629]
      >

      iex> s = [1.0, 2.0, 3.0] |> Explorer.Series.from_list()
      iex> Explorer.Series.pow(s, 3.0)
      #Explorer.Series<
        Polars[3]
        f64 [1.0, 8.0, 27.0]
      >

      iex> s = [2.0, 4.0, 6.0] |> Explorer.Series.from_list()
      iex> Explorer.Series.pow(s, 2)
      #Explorer.Series<
        Polars[3]
        f64 [4.0, 16.0, 36.0]
      >
  """
  @doc type: :element_wise
  @spec pow(left :: Series.t() | number(), right :: Series.t() | number()) :: Series.t()
  def pow(left, right), do: basic_numeric_operation(:pow, left, right)

  @doc """
  Calculates the natural logarithm.

  The resultant series is going to be of dtype `{:f, 64}`.
  See `log/2` for passing a custom base.

  ## Supported dtypes

    * `:integer`
    * `{:f, 32}`
    * `{:f, 64}`

  ## Examples

      iex> s = Explorer.Series.from_list([1, 2, 3, nil, 4])
      iex> Explorer.Series.log(s)
      #Explorer.Series<
        Polars[5]
        f64 [0.0, 0.6931471805599453, 1.0986122886681098, nil, 1.3862943611198906]
      >

  """
  @doc type: :element_wise
  @spec log(argument :: Series.t()) :: Series.t()
  def log(%Series{} = s), do: apply_series(s, :log, [])

  @doc """
  Calculates the logarithm on a given base.

  The resultant series is going to be of dtype `{:f, 64}`.

  ## Supported dtypes

    * `:integer`
    * `{:f, 32}`
    * `{:f, 64}`

  ## Examples

      iex> s = Explorer.Series.from_list([8, 16, 32])
      iex> Explorer.Series.log(s, 2)
      #Explorer.Series<
        Polars[3]
        f64 [3.0, 4.0, 5.0]
      >

  """
  @doc type: :element_wise
  @spec log(argument :: Series.t(), base :: number()) :: Series.t()
  def log(%Series{dtype: dtype} = series, base)
      when K.and(is_numeric_dtype(dtype), is_number(base)) do
    if base <= 0, do: raise(ArgumentError, "base must be a positive number")
    if base == 1, do: raise(ArgumentError, "base cannot be equal to 1")

    base = if is_integer(base), do: base / 1.0, else: base
    apply_series(series, :log, [base])
  end

  @doc """
  Calculates the exponential of all elements.
  """
  @doc type: :element_wise
  @spec exp(Series.t()) :: Series.t()
  def exp(%Series{} = series), do: apply_series(series, :exp, [])

  @doc """
  Element-wise integer division.

  At least one of the arguments must be a series. If both
  sizes are series, the series must have the same size or
  at last one of them must have size of 1.

  ## Supported dtype

    * `:integer`

  Returns `nil` if there is a zero in the right-hand side.

  ## Examples

      iex> s1 = [10, 11, 10] |> Explorer.Series.from_list()
      iex> s2 = [2, 2, 2] |> Explorer.Series.from_list()
      iex> Explorer.Series.quotient(s1, s2)
      #Explorer.Series<
        Polars[3]
        integer [5, 5, 5]
      >

      iex> s1 = [10, 11, 10] |> Explorer.Series.from_list()
      iex> s2 = [2, 2, 0] |> Explorer.Series.from_list()
      iex> Explorer.Series.quotient(s1, s2)
      #Explorer.Series<
        Polars[3]
        integer [5, 5, nil]
      >

      iex> s1 = [10, 12, 15] |> Explorer.Series.from_list()
      iex> Explorer.Series.quotient(s1, 3)
      #Explorer.Series<
        Polars[3]
        integer [3, 4, 5]
      >

  """
  @doc type: :element_wise
  @spec quotient(left :: Series.t(), right :: Series.t() | integer()) :: Series.t()
  def quotient(%Series{dtype: :integer} = left, %Series{dtype: :integer} = right),
    do: apply_series_list(:quotient, [left, right])

  def quotient(%Series{dtype: :integer} = left, right) when is_integer(right),
    do: apply_series_list(:quotient, [left, from_list([right])])

  def quotient(left, %Series{dtype: :integer} = right) when is_integer(left),
    do: apply_series_list(:quotient, [from_list([left]), right])

  @doc """
  Computes the remainder of an element-wise integer division.

  At least one of the arguments must be a series. If both
  sizes are series, the series must have the same size or
  at last one of them must have size of 1.

  ## Supported dtype

    * `:integer`

  Returns `nil` if there is a zero in the right-hand side.

  ## Examples

      iex> s1 = [10, 11, 10] |> Explorer.Series.from_list()
      iex> s2 = [2, 2, 2] |> Explorer.Series.from_list()
      iex> Explorer.Series.remainder(s1, s2)
      #Explorer.Series<
        Polars[3]
        integer [0, 1, 0]
      >

      iex> s1 = [10, 11, 10] |> Explorer.Series.from_list()
      iex> s2 = [2, 2, 0] |> Explorer.Series.from_list()
      iex> Explorer.Series.remainder(s1, s2)
      #Explorer.Series<
        Polars[3]
        integer [0, 1, nil]
      >

      iex> s1 = [10, 11, 9] |> Explorer.Series.from_list()
      iex> Explorer.Series.remainder(s1, 3)
      #Explorer.Series<
        Polars[3]
        integer [1, 2, 0]
      >

  """
  @doc type: :element_wise
  @spec remainder(left :: Series.t(), right :: Series.t() | integer()) :: Series.t()
  def remainder(%Series{dtype: :integer} = left, %Series{dtype: :integer} = right),
    do: apply_series_list(:remainder, [left, right])

  def remainder(%Series{dtype: :integer} = left, right) when is_integer(right),
    do: apply_series_list(:remainder, [left, from_list([right])])

  def remainder(left, %Series{dtype: :integer} = right) when is_integer(left),
    do: apply_series_list(:remainder, [from_list([left]), right])

  @doc """
  Computes the the sine of a number (in radians).
  The resultant series is going to be of dtype `{:f, 64}`, with values between 1 and -1.

  ## Supported dtype

    * `{:f, 32}`
    * `{:f, 64}`

  ## Examples

      iex> pi = :math.pi()
      iex> s = [-pi * 3/2, -pi, -pi / 2, -pi / 4, 0, pi / 4, pi / 2, pi, pi * 3/2] |> Explorer.Series.from_list()
      iex> Explorer.Series.sin(s)
      #Explorer.Series<
        Polars[9]
        f64 [1.0, -1.2246467991473532e-16, -1.0, -0.7071067811865475, 0.0, 0.7071067811865475, 1.0, 1.2246467991473532e-16, -1.0]
      >
  """
  @doc type: :float_wise
  @spec sin(series :: Series.t()) :: Series.t()
  def sin(%Series{dtype: dtype} = series) when K.in(dtype, @float_dtypes),
    do: apply_series(series, :sin)

  def sin(%Series{dtype: dtype}),
    do: dtype_error("sin/1", dtype, [{:f, 32}, {:f, 64}])

  @doc """
  Computes the the cosine of a number (in radians).
  The resultant series is going to be of dtype `{:f, 64}`, with values between 1 and -1.

  ## Supported dtype

    * `{:f, 32}`
    * `{:f, 64}`

  ## Examples

      iex> pi = :math.pi()
      iex> s = [-pi * 3/2, -pi, -pi / 2, -pi / 4, 0, pi / 4, pi / 2, pi, pi * 3/2] |> Explorer.Series.from_list()
      iex> Explorer.Series.cos(s)
      #Explorer.Series<
        Polars[9]
        f64 [-1.8369701987210297e-16, -1.0, 6.123233995736766e-17, 0.7071067811865476, 1.0, 0.7071067811865476, 6.123233995736766e-17, -1.0, -1.8369701987210297e-16]
      >
  """
  @doc type: :float_wise
  @spec cos(series :: Series.t()) :: Series.t()
  def cos(%Series{dtype: dtype} = series) when K.in(dtype, @float_dtypes),
    do: apply_series(series, :cos)

  def cos(%Series{dtype: dtype}),
    do: dtype_error("cos/1", dtype, [{:f, 32}, {:f, 64}])

  @doc """
  Computes the tangent of a number (in radians).
  The resultant series is going to be of dtype `{:f, 64}`.

  ## Supported dtype

    * `{:f, 32}`
    * `{:f, 64}`

  ## Examples

      iex> pi = :math.pi()
      iex> s = [-pi * 3/2, -pi, -pi / 2, -pi / 4, 0, pi / 4, pi / 2, pi, pi * 3/2] |> Explorer.Series.from_list()
      iex> Explorer.Series.tan(s)
      #Explorer.Series<
        Polars[9]
        f64 [-5443746451065123.0, 1.2246467991473532e-16, -1.633123935319537e16, -0.9999999999999999, 0.0, 0.9999999999999999, 1.633123935319537e16, -1.2246467991473532e-16, 5443746451065123.0]
      >
  """
  @doc type: :float_wise
  @spec tan(series :: Series.t()) :: Series.t()
  def tan(%Series{dtype: dtype} = series) when K.in(dtype, @float_dtypes),
    do: apply_series(series, :tan)

  def tan(%Series{dtype: dtype}),
    do: dtype_error("tan/1", dtype, [{:f, 32}, {:f, 64}])

  @doc """
  Computes the the arcsine of a number.
  The resultant series is going to be of dtype `{:f, 64}`, in radians, with values between -pi/2 and pi/2.

  ## Supported dtype

    * `{:f, 32}`
    * `{:f, 64}`

  ## Examples

      iex> s = [1.0, 0.0, -1.0, -0.7071067811865475, 0.7071067811865475] |> Explorer.Series.from_list()
      iex> Explorer.Series.asin(s)
      #Explorer.Series<
        Polars[5]
        f64 [1.5707963267948966, 0.0, -1.5707963267948966, -0.7853981633974482, 0.7853981633974482]
      >
  """
  @doc type: :float_wise
  @spec asin(series :: Series.t()) :: Series.t()
  def asin(%Series{dtype: dtype} = series) when K.in(dtype, @float_dtypes),
    do: apply_series(series, :asin)

  def asin(%Series{dtype: dtype}),
    do: dtype_error("asin/1", dtype, [{:f, 32}, {:f, 64}])

  @doc """
  Computes the the arccosine of a number.
  The resultant series is going to be of dtype `{:f, 64}`, in radians, with values between 0 and pi.

  ## Supported dtype

    * `{:f, 32}`
    * `{:f, 64}`

  ## Examples

      iex> s = [1.0, 0.0, -1.0, -0.7071067811865475, 0.7071067811865475] |> Explorer.Series.from_list()
      iex> Explorer.Series.acos(s)
      #Explorer.Series<
        Polars[5]
        f64 [0.0, 1.5707963267948966, 3.141592653589793, 2.356194490192345, 0.7853981633974484]
      >
  """
  @doc type: :float_wise
  @spec acos(series :: Series.t()) :: Series.t()
  def acos(%Series{dtype: dtype} = series) when K.in(dtype, @float_dtypes),
    do: apply_series(series, :acos)

  def acos(%Series{dtype: dtype}),
    do: dtype_error("acos/1", dtype, [{:f, 32}, {:f, 64}])

  @doc """
  Computes the the arctangent of a number.
  The resultant series is going to be of dtype `{:f, 64}`, in radians, with values between -pi/2 and pi/2.

  ## Supported dtype

    * `{:f, 32}`
    * `{:f, 64}`

  ## Examples

      iex> s = [1.0, 0.0, -1.0, -0.7071067811865475, 0.7071067811865475] |> Explorer.Series.from_list()
      iex> Explorer.Series.atan(s)
      #Explorer.Series<
        Polars[5]
        f64 [0.7853981633974483, 0.0, -0.7853981633974483, -0.6154797086703873, 0.6154797086703873]
      >
  """
  @doc type: :float_wise
  @spec atan(series :: Series.t()) :: Series.t()
  def atan(%Series{dtype: dtype} = series) when K.in(dtype, @float_dtypes),
    do: apply_series(series, :atan)

  def atan(%Series{dtype: dtype}),
    do: dtype_error("atan/1", dtype, [{:f, 32}, {:f, 64}])

  defp basic_numeric_operation(operation, left, right, args \\ [])

  defp basic_numeric_operation(operation, %Series{} = left, right, args) when is_numeric(right),
    do: basic_numeric_operation(operation, left, from_same_value(left, right), args)

  defp basic_numeric_operation(operation, left, %Series{} = right, args) when is_numeric(left),
    do: basic_numeric_operation(operation, from_same_value(right, left), right, args)

  defp basic_numeric_operation(
         operation,
         %Series{dtype: left_dtype} = left,
         %Series{dtype: right_dtype} = right,
         args
       )
       when K.and(is_numeric_dtype(left_dtype), is_numeric_dtype(right_dtype)),
       do: apply_series_list(operation, [left, right | args])

  defp basic_numeric_operation(operation, %Series{} = left, %Series{} = right, args),
    do: dtype_mismatch_error("#{operation}/#{length(args) + 2}", left, right)

  defp basic_numeric_operation(operation, _, %Series{dtype: dtype}, args),
    do: dtype_error("#{operation}/#{length(args) + 2}", dtype, @numeric_dtypes)

  defp basic_numeric_operation(operation, %Series{dtype: dtype}, _, args),
    do: dtype_error("#{operation}/#{length(args) + 2}", dtype, @numeric_dtypes)

  defp basic_numeric_operation(operation, left, right, args)
       when K.and(is_numeric(left), is_numeric(right)),
       do: no_series_error("#{operation}/#{length(args) + 2}", left, right)

  defp no_series_error(function, left, right) do
    raise ArgumentError,
          "#{function} expects a series as one of its arguments, " <>
            "instead got two scalars: #{inspect(left)} and #{inspect(right)}"
  end

  # Comparisons

  @doc """
  Returns boolean mask of `left == right`, element-wise.

  At least one of the arguments must be a series. If both
  sizes are series, the series must have the same size or
  at last one of them must have size of 1.

  ## Examples

      iex> s1 = Explorer.Series.from_list([1, 2, 3])
      iex> s2 = Explorer.Series.from_list([1, 2, 4])
      iex> Explorer.Series.equal(s1, s2)
      #Explorer.Series<
        Polars[3]
        boolean [true, true, false]
      >

      iex> s = Explorer.Series.from_list([1, 2, 3])
      iex> Explorer.Series.equal(s, 1)
      #Explorer.Series<
        Polars[3]
        boolean [true, false, false]
      >

      iex> s = Explorer.Series.from_list([true, true, false])
      iex> Explorer.Series.equal(s, true)
      #Explorer.Series<
        Polars[3]
        boolean [true, true, false]
      >

      iex> s = Explorer.Series.from_list(["a", "b", "c"])
      iex> Explorer.Series.equal(s, "a")
      #Explorer.Series<
        Polars[3]
        boolean [true, false, false]
      >

      iex> s = Explorer.Series.from_list([~D[2021-01-01], ~D[1999-12-31]])
      iex> Explorer.Series.equal(s, ~D[1999-12-31])
      #Explorer.Series<
        Polars[2]
        boolean [false, true]
      >

      iex> s = Explorer.Series.from_list([~N[2022-01-01 00:00:00], ~N[2022-01-01 23:00:00]])
      iex> Explorer.Series.equal(s, ~N[2022-01-01 00:00:00])
      #Explorer.Series<
        Polars[2]
        boolean [true, false]
      >

      iex> s = Explorer.Series.from_list(["a", "b", "c"])
      iex> Explorer.Series.equal(s, false)
      ** (ArgumentError) cannot invoke Explorer.Series.equal/2 with mismatched dtypes: :string and false
  """
  @doc type: :element_wise
  @spec equal(
          left :: Series.t() | number() | Date.t() | NaiveDateTime.t() | boolean() | String.t(),
          right :: Series.t() | number() | Date.t() | NaiveDateTime.t() | boolean() | String.t()
        ) :: Series.t()
  def equal(left, right) do
    if args = cast_for_comparable_operation(left, right) do
      apply_series_list(:equal, args)
    else
      dtype_mismatch_error("equal/2", left, right)
    end
  end

  @doc """
  Returns boolean mask of `left != right`, element-wise.

  At least one of the arguments must be a series. If both
  sizes are series, the series must have the same size or
  at last one of them must have size of 1.

  ## Examples

      iex> s1 = Explorer.Series.from_list([1, 2, 3])
      iex> s2 = Explorer.Series.from_list([1, 2, 4])
      iex> Explorer.Series.not_equal(s1, s2)
      #Explorer.Series<
        Polars[3]
        boolean [false, false, true]
      >

      iex> s = Explorer.Series.from_list([1, 2, 3])
      iex> Explorer.Series.not_equal(s, 1)
      #Explorer.Series<
        Polars[3]
        boolean [false, true, true]
      >

      iex> s = Explorer.Series.from_list([true, true, false])
      iex> Explorer.Series.not_equal(s, true)
      #Explorer.Series<
        Polars[3]
        boolean [false, false, true]
      >

      iex> s = Explorer.Series.from_list(["a", "b", "c"])
      iex> Explorer.Series.not_equal(s, "a")
      #Explorer.Series<
        Polars[3]
        boolean [false, true, true]
      >

      iex> s = Explorer.Series.from_list([~D[2021-01-01], ~D[1999-12-31]])
      iex> Explorer.Series.not_equal(s, ~D[1999-12-31])
      #Explorer.Series<
        Polars[2]
        boolean [true, false]
      >

      iex> s = Explorer.Series.from_list([~N[2022-01-01 00:00:00], ~N[2022-01-01 23:00:00]])
      iex> Explorer.Series.not_equal(s, ~N[2022-01-01 00:00:00])
      #Explorer.Series<
        Polars[2]
        boolean [false, true]
      >

      iex> s = Explorer.Series.from_list(["a", "b", "c"])
      iex> Explorer.Series.not_equal(s, false)
      ** (ArgumentError) cannot invoke Explorer.Series.not_equal/2 with mismatched dtypes: :string and false
  """
  @doc type: :element_wise
  @spec not_equal(
          left :: Series.t() | number() | Date.t() | NaiveDateTime.t() | boolean() | String.t(),
          right :: Series.t() | number() | Date.t() | NaiveDateTime.t() | boolean() | String.t()
        ) :: Series.t()
  def not_equal(left, right) do
    if args = cast_for_comparable_operation(left, right) do
      apply_series_list(:not_equal, args)
    else
      dtype_mismatch_error("not_equal/2", left, right)
    end
  end

  @doc """
  Returns boolean mask of `left > right`, element-wise.

  At least one of the arguments must be a series. If both
  sizes are series, the series must have the same size or
  at last one of them must have size of 1.

  ## Supported dtypes

    * `:integer`
    * `{:f, 32}`
    * `{:f, 64}`
    * `:date`
    * `:time`
    * `:datetime`
    * `:duration`

  ## Examples

      iex> s1 = Explorer.Series.from_list([1, 2, 3])
      iex> s2 = Explorer.Series.from_list([1, 2, 4])
      iex> Explorer.Series.greater(s1, s2)
      #Explorer.Series<
        Polars[3]
        boolean [false, false, false]
      >
  """
  @doc type: :element_wise
  @spec greater(
          left :: Series.t() | number() | Date.t() | NaiveDateTime.t(),
          right :: Series.t() | number() | Date.t() | NaiveDateTime.t()
        ) :: Series.t()
  def greater(left, right) do
    if args = cast_for_ordered_operation(left, right) do
      apply_series_list(:greater, args)
    else
      dtype_mismatch_error("greater/2", left, right, @numeric_or_temporal_dtypes)
    end
  end

  @doc """
  Returns boolean mask of `left >= right`, element-wise.

  At least one of the arguments must be a series. If both
  sizes are series, the series must have the same size or
  at last one of them must have size of 1.

  ## Supported dtypes

    * `:integer`
    * `{:f, 32}`
    * `{:f, 64}`
    * `:date`
    * `:time`
    * `:datetime`
    * `:duration`

  ## Examples

      iex> s1 = Explorer.Series.from_list([1, 2, 3])
      iex> s2 = Explorer.Series.from_list([1, 2, 4])
      iex> Explorer.Series.greater_equal(s1, s2)
      #Explorer.Series<
        Polars[3]
        boolean [true, true, false]
      >
  """
  @doc type: :element_wise
  @spec greater_equal(
          left :: Series.t() | number() | Date.t() | NaiveDateTime.t(),
          right :: Series.t() | number() | Date.t() | NaiveDateTime.t()
        ) :: Series.t()
  def greater_equal(left, right) do
    if args = cast_for_ordered_operation(left, right) do
      apply_series_list(:greater_equal, args)
    else
      dtype_mismatch_error("greater_equal/2", left, right, @numeric_or_temporal_dtypes)
    end
  end

  @doc """
  Returns boolean mask of `left < right`, element-wise.

  At least one of the arguments must be a series. If both
  sizes are series, the series must have the same size or
  at last one of them must have size of 1.

  ## Supported dtypes

    * `:integer`
    * `{:f, 32}`
    * `{:f, 64}`
    * `:date`
    * `:time`
    * `:datetime`
    * `:duration`

  ## Examples

      iex> s1 = Explorer.Series.from_list([1, 2, 3])
      iex> s2 = Explorer.Series.from_list([1, 2, 4])
      iex> Explorer.Series.less(s1, s2)
      #Explorer.Series<
        Polars[3]
        boolean [false, false, true]
      >
  """
  @doc type: :element_wise
  @spec less(
          left :: Series.t() | number() | Date.t() | NaiveDateTime.t(),
          right :: Series.t() | number() | Date.t() | NaiveDateTime.t()
        ) :: Series.t()
  def less(left, right) do
    if args = cast_for_ordered_operation(left, right) do
      apply_series_list(:less, args)
    else
      dtype_mismatch_error("less/2", left, right, @numeric_or_temporal_dtypes)
    end
  end

  @doc """
  Returns boolean mask of `left <= right`, element-wise.

  At least one of the arguments must be a series. If both
  sizes are series, the series must have the same size or
  at last one of them must have size of 1.

  ## Supported dtypes

    * `:integer`
    * `{:f, 32}`
    * `{:f, 64}`
    * `:date`
    * `:time`
    * `:datetime`
    * `:duration`

  ## Examples

      iex> s1 = Explorer.Series.from_list([1, 2, 3])
      iex> s2 = Explorer.Series.from_list([1, 2, 4])
      iex> Explorer.Series.less_equal(s1, s2)
      #Explorer.Series<
        Polars[3]
        boolean [true, true, true]
      >
  """
  @doc type: :element_wise
  @spec less_equal(
          left :: Series.t() | number() | Date.t() | NaiveDateTime.t(),
          right :: Series.t() | number() | Date.t() | NaiveDateTime.t()
        ) :: Series.t()
  def less_equal(left, right) do
    if args = cast_for_ordered_operation(left, right) do
      apply_series_list(:less_equal, args)
    else
      dtype_mismatch_error("less_equal/2", left, right, @numeric_or_temporal_dtypes)
    end
  end

  @doc """
  Checks if each element of the series in the left exists in the series
  on the right, returning a boolean mask.

  The series sizes do not have to match.

  See `member?/2` if you want to check if a literal belongs to a list.

  ## Examples

      iex> left = Explorer.Series.from_list([1, 2, 3])
      iex> right = Explorer.Series.from_list([1, 2])
      iex> Series.in(left, right)
      #Explorer.Series<
        Polars[3]
        boolean [true, true, false]
      >

      iex> left = Explorer.Series.from_list([~D[1970-01-01], ~D[2000-01-01], ~D[2010-04-17]])
      iex> right = Explorer.Series.from_list([~D[1970-01-01], ~D[2010-04-17]])
      iex> Series.in(left, right)
      #Explorer.Series<
        Polars[3]
        boolean [true, false, true]
      >
  """
  @doc type: :element_wise
  def (%Series{} = left) in (%Series{} = right) do
    if args = cast_for_comparable_operation(left, right) do
      apply_series_list(:binary_in, args)
    else
      dtype_mismatch_error("in/2", left, right)
    end
  end

  def (%Series{data: %backend{}} = left) in right when is_list(right),
    do: left in backend.from_list(right, Shared.dtype_from_list!(right, nil))

  ## Comparable (a superset of ordered)

  defp cast_for_comparable_operation(
         %Series{dtype: left_dtype} = left,
         %Series{dtype: right_dtype} = right
       ) do
    if valid_comparable_series?(left_dtype, right_dtype) do
      [left, right]
    else
      nil
    end
  end

  defp cast_for_comparable_operation(%Series{dtype: dtype} = series, value) do
    if dtype = cast_to_comparable_series(dtype, value) do
      [series, from_same_value(series, value, dtype)]
    else
      nil
    end
  end

  defp cast_for_comparable_operation(value, %Series{dtype: dtype} = series) do
    if dtype = cast_to_comparable_series(dtype, value) do
      [from_same_value(series, value, dtype), series]
    else
      nil
    end
  end

  defp cast_for_comparable_operation(_left, _right),
    do: nil

  defp valid_comparable_series?(:category, :string), do: true
  defp valid_comparable_series?(:string, :category), do: true

  defp valid_comparable_series?(left_dtype, right_dtype),
    do: valid_ordered_series?(left_dtype, right_dtype)

  defp cast_to_comparable_series(:category, value) when is_binary(value), do: :string
  defp cast_to_comparable_series(:string, value) when is_binary(value), do: :string
  defp cast_to_comparable_series(:binary, value) when is_binary(value), do: :binary
  defp cast_to_comparable_series(:boolean, value) when is_boolean(value), do: :boolean
  defp cast_to_comparable_series(dtype, value), do: cast_to_ordered_series(dtype, value)

  ## Ordered

  defp cast_for_ordered_operation(
         %Series{dtype: left_dtype} = left,
         %Series{dtype: right_dtype} = right
       ) do
    if valid_ordered_series?(left_dtype, right_dtype) do
      [left, right]
    else
      nil
    end
  end

  defp cast_for_ordered_operation(%Series{dtype: dtype} = series, value) do
    if dtype = cast_to_ordered_series(dtype, value) do
      [series, from_same_value(series, value, dtype)]
    else
      nil
    end
  end

  defp cast_for_ordered_operation(value, %Series{dtype: dtype} = series) do
    if dtype = cast_to_ordered_series(dtype, value) do
      [from_same_value(series, value, dtype), series]
    else
      nil
    end
  end

  defp cast_for_ordered_operation(_left, _right),
    do: nil

  defp valid_ordered_series?(dtype, dtype),
    do: true

  defp valid_ordered_series?(left_dtype, right_dtype)
       when K.and(is_numeric_dtype(left_dtype), is_numeric_dtype(right_dtype)),
       do: true

  defp valid_ordered_series?(_, _),
    do: false

  defp cast_to_ordered_series(dtype, value)
       when K.and(is_numeric_dtype(dtype), is_integer(value)),
       do: :integer

  defp cast_to_ordered_series(dtype, value)
       when K.and(is_numeric_dtype(dtype), is_numeric(value)),
       do: {:f, 64}

  defp cast_to_ordered_series(:date, %Date{}), do: :date
  defp cast_to_ordered_series(:time, %Time{}), do: :time

  defp cast_to_ordered_series({:datetime, _}, %NaiveDateTime{}),
    do: {:datetime, :microsecond}

  defp cast_to_ordered_series({:duration, _}, value)
       when is_integer(value),
       do: :integer

  defp cast_to_ordered_series({:duration, _}, %Explorer.Duration{}),
    do: :duration

  defp cast_to_ordered_series(_dtype, _value),
    do: nil

  @doc """
  Returns a boolean mask of `left and right`, element-wise.

  Both sizes must be series, the series must have the same
  size or at last one of them must have size of 1.

  ## Examples

      iex> s1 = Explorer.Series.from_list([1, 2, 3])
      iex> mask1 = Explorer.Series.greater(s1, 1)
      iex> mask2 = Explorer.Series.less(s1, 3)
      iex> Explorer.Series.and(mask1, mask2)
      #Explorer.Series<
        P