defmodule Explorer.PolarsBackend.Series do
  @moduledoc false

  alias Explorer.DataFrame
  alias Explorer.PolarsBackend.Shared
  alias Explorer.Series

  @type t :: %__MODULE__{resource: reference()}

  defstruct resource: nil

  @behaviour Explorer.Backend.Series

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

  @integer_types Explorer.Shared.integer_types()

  # Conversion

  @impl true
  def from_list(data, type) when is_list(data) do
    series = Shared.from_list(data, type)

    Explorer.Backend.Series.new(series, type)
  end

  @impl true
  def from_binary(data, dtype) when is_binary(data) do
    series = Shared.from_binary(data, dtype)
    Explorer.Backend.Series.new(series, dtype)
  end

  @impl true
  def to_list(series), do: Shared.apply_series(series, :s_to_list)

  @impl true
  def to_iovec(series), do: Shared.apply_series(series, :s_to_iovec)

  @impl true
  def cast(%Series{dtype: :string} = series, {:naive_datetime, precision}),
    do: Shared.apply_series(series, :s_strptime, [nil, precision])

  def cast(series, dtype),
    do: Shared.apply_series(series, :s_cast, [dtype])

  @impl true
  def strptime(%Series{} = series, format_string) do
    Shared.apply_series(series, :s_strptime, [format_string, nil])
  end

  @impl true
  def strftime(%Series{} = series, format_string) do
    Shared.apply_series(series, :s_strftime, [format_string])
  end

  # Ownership

  @impl true
  def owner_reference(series), do: series.data.resource

  @impl true
  def owner_export(series) do
    Explorer.DataFrame.new(series: series)
    |> Explorer.DataFrame.dump_ipc()
  end

  @impl true
  def owner_import(dumped) do
    with {:ok, df} <- Explorer.DataFrame.load_ipc(dumped) do
      {:ok, df[:series]}
    end
  end

  # Introspection

  defp dtype(series), do: Shared.apply_series(series, :s_dtype)

  @impl true
  def size(series), do: Shared.apply_series(series, :s_size)

  @impl true
  def categories(%Series{dtype: :category} = series),
    do: Shared.apply_series(series, :s_categories)

  @impl true
  def categorise(%Series{dtype: {integer_type, _}} = series, %Series{dtype: dtype} = categories)
      when dtype in [:string, :category] and integer_type in [:s, :u],
      do: Shared.apply_series(series, :s_categorise, [categories.data])

  @impl true
  def categorise(%Series{dtype: :string} = series, %Series{dtype: dtype} = categories)
      when dtype in [:string, :category],
      do: Shared.apply_series(series, :s_categorise, [categories.data])

  # Slice and dice

  @impl true
  def head(series, n_elements), do: Shared.apply_series(series, :s_head, [n_elements])

  @impl true
  def tail(series, n_elements), do: Shared.apply_series(series, :s_tail, [n_elements])

  @impl true
  def first(series), do: series[0]

  @impl true
  def last(series), do: series[-1]

  @impl true
  def shift(series, offset, nil), do: Shared.apply_series(series, :s_shift, [offset])

  @impl true
  def sample(series, n, replacement, shuffle, seed) when is_integer(n) do
    Shared.apply_series(series, :s_sample_n, [n, replacement, shuffle, seed])
  end

  @impl true
  def sample(series, frac, replacement, shuffle, seed) when is_float(frac) do
    Shared.apply_series(series, :s_sample_frac, [frac, replacement, shuffle, seed])
  end

  @impl true
  def rank(series, method, descending, seed) do
    Shared.apply_series(series, :s_rank, [method, descending, seed])
  end

  @impl true
  def at_every(series, every_n),
    do: Shared.apply_series(series, :s_at_every, [every_n])

  @impl true
  def mask(series, %Series{} = mask),
    do: Shared.apply_series(series, :s_mask, [mask.data])

  @impl true
  def slice(series, indices) when is_list(indices),
    do: Shared.apply_series(series, :s_slice_by_indices, [indices])

  @impl true
  def slice(series, %Series{} = indices),
    do: Shared.apply_series(series, :s_slice_by_series, [indices.data])

  @impl true
  def slice(series, offset, length), do: Shared.apply_series(series, :s_slice, [offset, length])

  @impl true
  def at(series, idx), do: Shared.apply_series(series, :s_at, [idx])

  @impl true
  def format(list) do
    {_, df_args, params} =
      Enum.reduce(list, {0, [], []}, fn s, {counter, df_args, params} ->
        if is_binary(s) or Kernel.is_nil(s) do
          {counter, df_args, [s | params]}
        else
          counter = counter + 1
          name = "#{counter}"
          column = Explorer.Backend.LazySeries.unbacked(:column, [name], :string)
          {counter, [{name, s} | df_args], [column | params]}
        end
      end)

    df = Explorer.PolarsBackend.DataFrame.from_series(df_args)
    format_expr = Explorer.Backend.LazySeries.unbacked(:format, [Enum.reverse(params)], :string)
    out_dtypes = Map.put(df.dtypes, "result", :string)
    out_names = ["result" | df.names]
    out_df = %{df | dtypes: out_dtypes, names: out_names}

    Explorer.PolarsBackend.DataFrame.mutate_with(df, out_df, [{"result", format_expr}])
    |> Explorer.PolarsBackend.DataFrame.pull("result")
  end

  @impl true
  def concat([%Series{} | _] = series) do
    polars_series = for s <- series, do: s.data

    Shared.apply(:s_concat, [polars_series])
    |> Shared.create_series()
  end

  @impl true
  def coalesce(s1, s2), do: Shared.apply_series(s1, :s_coalesce, [s2.data])

  @impl true
  def select(%Series{} = predicate, %Series{} = on_true, %Series{} = on_false) do
    predicate_size = size(predicate)
    on_true_size = size(on_true)
    on_false_size = size(on_false)
    singleton_condition = on_true_size == 1 or on_false_size == 1

    if on_true_size != on_false_size and not singleton_condition do
      raise ArgumentError,
            "series in select/3 must have the same size or size of 1, got: #{on_true_size} and #{on_false_size}"
    end

    if predicate_size != 1 and predicate_size != on_true_size and predicate_size != on_false_size and
         not singleton_condition do
      raise ArgumentError,
            "predicate in select/3 must have size of 1 or have the same size as operands, got: #{predicate_size} and #{Enum.max([on_true_size, on_false_size])}"
    end

    Shared.apply_series(predicate, :s_select, [on_true.data, on_false.data])
  end

  # Aggregation

  @impl true
  # There is no `count` equivalent in Polars, so we need to make our own.
  def count(series), do: size(series) - nil_count(series)

  @impl true
  def nil_count(series), do: Shared.apply_series(series, :s_nil_count)

  @impl true
  def sum(series), do: Shared.apply_series(series, :s_sum)

  @impl true
  def min(series), do: Shared.apply_series(series, :s_min)

  @impl true
  def max(series), do: Shared.apply_series(series, :s_max)

  @impl true
  def argmin(series), do: Shared.apply_series(series, :s_argmin)

  @impl true
  def argmax(series), do: Shared.apply_series(series, :s_argmax)

  @impl true
  def mean(series), do: Shared.apply_series(series, :s_mean)

  @impl true
  def median(series), do: Shared.apply_series(series, :s_median)

  @impl true
  def mode(series), do: Shared.apply_series(series, :s_mode)

  @impl true
  def variance(series, ddof), do: series |> Shared.apply_series(:s_variance, [ddof]) |> at(0)

  @impl true
  def standard_deviation(series, ddof),
    do: series |> Shared.apply_series(:s_standard_deviation, [ddof]) |> at(0)

  @impl true
  def quantile(series, quantile),
    do: Shared.apply_series(series, :s_quantile, [quantile, "nearest"])

  @impl true
  def product(series), do: first(Shared.apply_series(series, :s_product))

  @impl true
  def skew(series, bias?),
    do: Shared.apply_series(series, :s_skew, [bias?])

  @impl true
  def correlation(left, right, method),
    do: Shared.apply_series(matching_size!(left, right), :s_correlation, [right.data, method])

  @impl true
  def covariance(left, right, ddof),
    do: Shared.apply_series(matching_size!(left, right), :s_covariance, [right.data, ddof])

  @impl true
  def all?(series), do: Shared.apply_series(series, :s_all)

  @impl true
  def any?(series), do: Shared.apply_series(series, :s_any)

  @impl true
  def row_index(series), do: Shared.apply_series(series, :s_row_index)

  # Cumulative

  @impl true
  def cumulative_max(series, reverse?),
    do: Shared.apply_series(series, :s_cumulative_max, [reverse?])

  @impl true
  def cumulative_min(series, reverse?),
    do: Shared.apply_series(series, :s_cumulative_min, [reverse?])

  @impl true
  def cumulative_sum(series, reverse?),
    do: Shared.apply_series(series, :s_cumulative_sum, [reverse?])

  @impl true
  def cumulative_count(series, reverse?),
    do: Shared.apply_series(series, :s_cumulative_count, [reverse?])

  @impl true
  def cumulative_product(series, reverse?),
    do: Shared.apply_series(series, :s_cumulative_product, [reverse?])

  # Local minima/maxima

  @impl true
  def peaks(series, :max), do: Shared.apply_series(series, :s_peak_max)
  def peaks(series, :min), do: Shared.apply_series(series, :s_peak_min)

  # Arithmetic

  @impl true
  def add(out_dtype, left, right) do
    result = Shared.apply_series(matching_size!(left, right), :s_add, [right.data])

    if match?({:decimal, _, _}, out_dtype) and out_dtype != dtype(result) do
      cast(result, out_dtype)
    else
      result
    end
  end

  @impl true
  def subtract(out_dtype, left, right) do
    left = matching_size!(left, right)

    result = Shared.apply_series(left, :s_subtract, [right.data])

    if match?({:decimal, _, _}, out_dtype) and out_dtype != dtype(result) do
      cast(result, out_dtype)
    else
      result
    end
  end

  @impl true
  def multiply(out_dtype, left, right) do
    result = Shared.apply_series(matching_size!(left, right), :s_multiply, [right.data])

    # Polars currently returns inconsistent dtypes, e.g.:
    #   * `integer * duration -> duration` when `integer` is a scalar
    #   * `integer * duration ->  integer` when `integer` is a series
    # We need to return duration in these cases, so we need an additional cast.
    if (match?({:duration, _}, out_dtype) or match?({:decimal, _, _}, out_dtype)) and
         out_dtype != dtype(result) do
      cast(result, out_dtype)
    else
      result
    end
  end

  @impl true
  def divide(out_dtype, left, right) do
    result = Shared.apply_series(matching_size!(left, right), :s_divide, [right.data])

    # Polars currently returns inconsistent dtypes, e.g.:
    #   * `duration / integer -> duration` when `integer` is a scalar
    #   * `duration / integer ->  integer` when `integer` is a series
    # We need to return duration in these cases, so we need an additional cast.
    if (match?({:duration, _}, out_dtype) or match?({:decimal, _, _}, out_dtype)) and
         out_dtype != dtype(result) do
      cast(result, out_dtype)
    else
      result
    end
  end

  @impl true
  def quotient(left, right),
    do: Shared.apply_series(matching_size!(left, right), :s_quotient, [right.data])

  @impl true
  def remainder(left, right),
    do: Shared.apply_series(matching_size!(left, right), :s_remainder, [right.data])

  @impl true
  def pow(out_dtype, left, right) do
    _ = matching_size!(left, right)

    # We need to pre-cast or we may lose precision.
    left = Explorer.Series.cast(left, out_dtype)

    left_lazy = Explorer.Backend.LazySeries.unbacked(:column, ["base"], left.dtype)
    right_lazy = Explorer.Backend.LazySeries.unbacked(:column, ["exponent"], right.dtype)

    {df_args, pow_args} =
      case {size(left), size(right)} do
        {n, n} -> {[{"base", left}, {"exponent", right}], [left_lazy, right_lazy]}
        {1, _} -> {[{"exponent", right}], [Explorer.Series.at(left, 0), right_lazy]}
        {_, 1} -> {[{"base", left}], [left_lazy, Explorer.Series.at(right, 0)]}
      end

    df = Explorer.PolarsBackend.DataFrame.from_series(df_args)
    pow = Explorer.Backend.LazySeries.unbacked(:pow, pow_args, out_dtype)

    out_dtypes = Map.put(df.dtypes, "pow", out_dtype)
    out_names = df.names ++ ["pow"]
    out_df = %{df | dtypes: out_dtypes, names: out_names}

    Explorer.PolarsBackend.DataFrame.mutate_with(df, out_df, [{"pow", pow}])
    |> Explorer.PolarsBackend.DataFrame.pull("pow")
  end

  @impl true
  def log(%Series{} = argument), do: Shared.apply_series(argument, :s_log_natural, [])

  @impl true
  def log(%Series{} = argument, base) when is_numeric(base),
    do: Shared.apply_series(argument, :s_log, [base])

  @impl true
  def exp(%Series{} = s), do: Shared.apply_series(s, :s_exp, [])

  @impl true
  def abs(%Series{} = s), do: Shared.apply_series(s, :s_abs, [])

  @impl true
  def clip(%Series{dtype: dtype} = s, min, max)
      when dtype in @integer_types and is_integer(min) and is_integer(max),
      do: Shared.apply_series(s, :s_clip_integer, [min, max])

  def clip(%Series{} = s, min, max),
    do: s |> cast({:f, 64}) |> Shared.apply_series(:s_clip_float, [min * 1.0, max * 1.0])

  # Trigonometry

  @impl true
  def sin(%Series{} = s), do: Shared.apply_series(s, :s_sin, [])

  @impl true
  def cos(%Series{} = s), do: Shared.apply_series(s, :s_cos, [])

  @impl true
  def tan(%Series{} = s), do: Shared.apply_series(s, :s_tan, [])

  @impl true
  def asin(%Series{} = s), do: Shared.apply_series(s, :s_asin, [])

  @impl true
  def acos(%Series{} = s), do: Shared.apply_series(s, :s_acos, [])

  @impl true
  def atan(%Series{} = s), do: Shared.apply_series(s, :s_atan, [])

  @impl true
  def degrees(%Series{} = s), do: Shared.apply_series(s, :s_degrees, [])

  @impl true
  def radians(%Series{} = s), do: Shared.apply_series(s, :s_radians, [])

  # Comparisons

  @impl true
  def equal(left, right),
    do: Shared.apply_series(matching_size!(left, right), :s_equal, [right.data])

  @impl true
  def not_equal(left, right),
    do: Shared.apply_series(matching_size!(left, right), :s_not_equal, [right.data])

  @impl true
  def greater(left, right),
    do: Shared.apply_series(matching_size!(left, right), :s_greater, [right.data])

  @impl true
  def less(left, right),
    do: Shared.apply_series(matching_size!(left, right), :s_less, [right.data])

  @impl true
  def greater_equal(left, right),
    do: Shared.apply_series(matching_size!(left, right), :s_greater_equal, [right.data])

  @impl true
  def less_equal(left, right),
    do: Shared.apply_series(matching_size!(left, right), :s_less_equal, [right.data])

  @impl true
  def all_equal(%Series{} = left, %Series{} = right),
    do: Shared.apply_series(matching_size!(left, right), :s_series_equal, [right.data, true])

  @impl true
  def binary_in(%Series{} = left, %Series{} = right),
    do: Shared.apply_series(left, :s_in, [right.data])

  @impl true
  def binary_and(%Series{} = left, %Series{} = right),
    do: Shared.apply_series(matching_size!(left, right), :s_and, [right.data])

  @impl true
  def binary_or(%Series{} = left, %Series{} = right),
    do: Shared.apply_series(matching_size!(left, right), :s_or, [right.data])

  # Float predicates

  @impl true
  def is_finite(series), do: Shared.apply_series(series, :s_is_finite)

  @impl true
  def is_infinite(series), do: Shared.apply_series(series, :s_is_infinite)

  @impl true
  def is_nan(series), do: Shared.apply_series(series, :s_is_nan)

  # Sort

  @impl true
  def sort(series, descending?, maintain_order?, multithreaded?, nulls_last?)
      when is_boolean(descending?) and is_boolean(maintain_order?) and is_boolean(multithreaded?) and
             is_boolean(nulls_last?) do
    Shared.apply_series(series, :s_sort, [
      descending?,
      maintain_order?,
      multithreaded?,
      nulls_last?
    ])
  end

  @impl true
  def argsort(series, descending?, maintain_order?, multithreaded?, nulls_last?)
      when is_boolean(descending?) and is_boolean(maintain_order?) and is_boolean(multithreaded?) and
             is_boolean(nulls_last?) do
    Shared.apply_series(series, :s_argsort, [
      descending?,
      maintain_order?,
      multithreaded?,
      nulls_last?
    ])
  end

  @impl true
  def reverse(series), do: Shared.apply_series(series, :s_reverse)

  # Distinct

  @impl true
  def distinct(series), do: Shared.apply_series(series, :s_distinct)

  @impl true
  def unordered_distinct(series), do: Shared.apply_series(series, :s_unordered_distinct)

  @impl true
  def n_distinct(series), do: Shared.apply_series(series, :s_n_distinct)

  @impl true
  def frequencies(%Series{} = series) do
    Shared.apply(:s_frequencies, [series.data])
    |> Shared.create_dataframe!()
    |> DataFrame.rename(["values", "counts"])
  end

  # Categorisation

  @impl true
  def cut(series, bins, labels, break_point_label, category_label, left_close, include_breaks) do
    case Explorer.PolarsBackend.Native.s_cut(
           series.data,
           bins,
           labels,
           break_point_label,
           category_label,
           left_close,
           include_breaks
         ) do
      {:ok, polars_df} ->
        Shared.create_dataframe!(polars_df)

      {:error, "Polars Error: lengths don't match: " <> _rest} ->
        raise ArgumentError, "lengths don't match: labels count must equal bins count"

      {:error, msg} ->
        raise msg
    end
  end

  @impl true
  def qcut(
        series,
        quantiles,
        labels,
        break_point_label,
        category_label,
        allow_duplicates,
        left_close,
        include_breaks
      ) do
    Shared.apply(:s_qcut, [
      series.data,
      quantiles,
      labels,
      break_point_label,
      category_label,
      allow_duplicates,
      left_close,
      include_breaks
    ])
    |> Shared.create_dataframe!()
  end

  # Window

  @impl true
  def window_max(series, window_size, weights, min_periods, center) do
    window_function(:s_window_max, series, window_size, weights, min_periods, center)
  end

  @impl true
  def window_mean(series, window_size, weights, min_periods, center) do
    window_function(:s_window_mean, series, window_size, weights, min_periods, center)
  end

  @impl true
  def window_median(series, window_size, weights, min_periods, center) do
    window_function(:s_window_median, series, window_size, weights, min_periods, center)
  end

  @impl true
  def window_min(series, window_size, weights, min_periods, center) do
    window_function(:s_window_min, series, window_size, weights, min_periods, center)
  end

  @impl true
  def window_sum(series, window_size, weights, min_periods, center) do
    window_function(:s_window_sum, series, window_size, weights, min_periods, center)
  end

  @impl true
  def window_standard_deviation(series, window_size, weights, min_periods, center) do
    window_function(
      :s_window_standard_deviation,
      series,
      window_size,
      weights,
      min_periods,
      center
    )
  end

  defp window_function(operation, series, window_size, weights, min_periods, center) do
    series =
      if List.wrap(weights) == [] do
        series
      else
        cast(series, {:f, 64})
      end

    Shared.apply_series(series, operation, [window_size, weights, min_periods, center])
  end

  @impl true
  def ewm_mean(series, alpha, adjust, min_periods, ignore_nils) do
    Shared.apply_series(series, :s_ewm_mean, [alpha, adjust, min_periods, ignore_nils])
  end

  @impl true
  def ewm_standard_deviation(series, alpha, adjust, bias, min_periods, ignore_nils) do
    Shared.apply_series(
      series,
      :s_ewm_standard_deviation,
      [alpha, adjust, bias, min_periods, ignore_nils]
    )
  end

  @impl true
  def ewm_variance(series, alpha, adjust, bias, min_periods, ignore_nils) do
    Shared.apply_series(series, :s_ewm_variance, [alpha, adjust, bias, min_periods, ignore_nils])
  end

  # Missing values

  @impl true
  def fill_missing_with_strategy(series, strategy),
    do: Shared.apply_series(series, :s_fill_missing_with_strategy, [Atom.to_string(strategy)])

  @impl true
  def fill_missing_with_value(series, value) when is_atom(value) and not is_boolean(value) do
    Shared.apply_series(series, :s_fill_missing_with_atom, [Atom.to_string(value)])
  end

  def fill_missing_with_value(series, value) do
    operation =
      cond do
        is_float(value) -> :s_fill_missing_with_float
        is_integer(value) -> :s_fill_missing_with_int
        is_binary(value) -> :s_fill_missing_with_bin
        is_boolean(value) -> :s_fill_missing_with_boolean
        is_struct(value, Date) -> :s_fill_missing_with_date
        is_struct(value, NaiveDateTime) -> :s_fill_missing_with_datetime
        is_struct(value, Decimal) -> :s_fill_missing_with_decimal
        true -> raise "cannot fill missing with value: #{inspect(value)}"
      end

    Shared.apply_series(series, operation, [value])
  end

  @impl true
  def is_nil(series), do: Shared.apply_series(series, :s_is_null)

  @impl true
  def is_not_nil(series), do: Shared.apply_series(series, :s_is_not_null)

  @impl true
  def transform(series, fun) do
    series
    |> Series.to_list()
    |> Enum.map(fun)
    |> Series.from_list(backend: Explorer.PolarsBackend)
  end

  @impl true
  def inspect(series, opts) do
    Explorer.Backend.Series.inspect(series, "Polars", Series.size(series), opts)
  end

  # Inversions

  @impl true
  def unary_not(%Series{} = series), do: Shared.apply_series(series, :s_not, [])

  # Strings

  @impl true
  def contains(series, substring),
    do: Shared.apply_series(series, :s_contains, [substring, true])

  @impl true
  def re_contains(series, pattern),
    do: Shared.apply_series(series, :s_contains, [pattern, false])

  @impl true
  def upcase(series),
    do: Shared.apply_series(series, :s_upcase)

  @impl true
  def downcase(series),
    do: Shared.apply_series(series, :s_downcase)

  @impl true
  def replace(series, pattern, replacement),
    do: Shared.apply_series(series, :s_replace, [pattern, replacement, true])

  @impl true
  def re_replace(series, pattern, replacement),
    do: Shared.apply_series(series, :s_replace, [pattern, replacement, false])

  @impl true
  def strip(series, str),
    do: Shared.apply_series(series, :s_strip, [str])

  @impl true
  def lstrip(series, str),
    do: Shared.apply_series(series, :s_lstrip, [str])

  @impl true
  def rstrip(series, str),
    do: Shared.apply_series(series, :s_rstrip, [str])

  @impl true
  def substring(series, offset, length),
    do: Shared.apply_series(series, :s_substring, [offset, length])

  @impl true
  def split(series, by),
    do: Shared.apply_series(series, :s_split, [by])

  @impl true
  def split_into(series, by, fields),
    do: Shared.apply_series(series, :s_split_into, [by, fields])

  # Float round
  @impl true
  def round(series, decimals),
    do: Shared.apply_series(series, :s_round, [decimals])

  @impl true
  def floor(series),
    do: Shared.apply_series(series, :s_floor)

  @impl true
  def ceil(series),
    do: Shared.apply_series(series, :s_ceil)

  # Date / DateTime
  @impl true
  def day_of_week(series),
    do: Shared.apply_series(series, :s_day_of_week)

  @impl true
  def day_of_year(series),
    do: Shared.apply_series(series, :s_day_of_year)

  @impl true
  def week_of_year(series),
    do: Shared.apply_series(series, :s_week_of_year)

  @impl true
  def month(series),
    do: Shared.apply_series(series, :s_month)

  @impl true
  def year(series),
    do: Shared.apply_series(series, :s_year)

  @impl true
  def hour(series),
    do: Shared.apply_series(series, :s_hour)

  @impl true
  def minute(series),
    do: Shared.apply_series(series, :s_minute)

  @impl true
  def second(series),
    do: Shared.apply_series(series, :s_second)

  # Lists

  @impl true
  def join(series, separator),
    do: Shared.apply_series(series, :s_join, [separator])

  @impl true
  def lengths(series),
    do: Shared.apply_series(series, :s_lengths)

  @impl true
  def member?(%Series{dtype: {:list, inner_dtype}} = series, value),
    do: Shared.apply_series(series, :s_member, [value, inner_dtype])

  @impl true
  def field(%Series{dtype: {:struct, _inner_dtype}} = series, name),
    do: Shared.apply_series(series, :s_field, [name])

  @impl true
  def json_decode(series, dtype),
    do: Shared.apply_series(series, :s_json_decode, [dtype])

  @impl true
  def json_path_match(series, json_path),
    do: Shared.apply_series(series, :s_json_path_match, [json_path])

  @impl true
  def count_matches(series, substring) do
    Shared.apply_series(series, :s_count_matches, [substring, true])
  end

  @impl true
  def re_count_matches(series, pattern) do
    Shared.apply_series(series, :s_count_matches, [pattern, false])
  end

  @impl true
  def re_scan(series, pattern) do
    Shared.apply_series(series, :s_re_scan, [pattern])
  end

  @impl true
  def re_named_captures(series, pattern) do
    Shared.apply_series(series, :s_re_named_captures, [pattern])
  end

  @impl true
  def index_of(series, value) do
    value_series =
      try do
        case {series.dtype, value} do
          # cast value to duration of same type as series to ensure durations are correctly
          # compared at the same precision
          {{:duration, precision}, %Explorer.Duration{}} ->
            Series.from_list([value]) |> cast({:duration, precision})

          {{:duration, _}, _} ->
            raise ArgumentError,
                  "unable to get index of value: #{inspect(value)} in series of type: #{inspect(series.dtype)}"

          {dtype, _} ->
            Series.from_list([value], dtype: dtype)
        end
      rescue
        _ ->
          raise ArgumentError,
                "unable to get index of value: #{inspect(value)} in series of type: #{inspect(series.dtype)}"
      end

    Shared.apply_series(series, :s_index_of, [value_series.data])
  end

  # Polars specific functions

  def name(series), do: Shared.apply_series(series, :s_name)
  def rename(series, name), do: Shared.apply_series(series, :s_rename, [name])

  # Helpers

  defp matching_size!(series, other) do
    case size(series) do
      1 ->
        series

      i ->
        case size(other) do
          1 ->
            series

          ^i ->
            series

          j ->
            raise ArgumentError,
                  "series must either have the same size or one of them must have size of 1, got: #{i} and #{j}"
        end
    end
  end
end

defimpl Inspect, for: Explorer.PolarsBackend.Series do
  import Inspect.Algebra

  def inspect(s, _opts) do
    doc =
      try do
        Explorer.PolarsBackend.Native.s_as_str(s)
      rescue
        _ -> inspect(s.resource)
      else
        {:ok, str} -> str
        {:error, _} -> inspect(s.resource)
      end
      |> String.split("\n")
      |> Enum.intersperse(line())
      |> then(&concat([line() | &1]))
      |> nest(2)

    concat(["#Explorer.PolarsBackend.Series<", doc, line(), ">"])
  end
end
