defmodule Plausible.Stats.SQL.Fragments do
  @moduledoc """
  Various macros and common SQL fragments used in Stats code.
  """

  defmacro __using__(_) do
    quote do
      import Plausible.Stats.SQL.Fragments
      require Plausible.Stats.SQL.Fragments
    end
  end

  defmacro scale_sample(sampled_fragment) do
    quote do
      fragment("toUInt64(round(? * any(_sample_factor)))", unquote(sampled_fragment))
    end
  end

  defmacro uniq(user_id) do
    quote do
      scale_sample(fragment("uniq(?)", unquote(user_id)))
    end
  end

  defmacro total() do
    quote do
      scale_sample(fragment("count()"))
    end
  end

  defmacro sample_percent() do
    quote do
      fragment("if(any(_sample_factor) > 1, round(100 / any(_sample_factor)), 100)")
    end
  end

  defmacro bounce_rate() do
    quote do
      fragment(
        # :TRICKY: Before PR #4493, we could have sessions where `sum(is_bounce * sign)`
        # is negative, leading to an underflow and >100% bounce rate. This works around
        # that issue.
        "toUInt32(greatest(ifNotFinite(round(sum(is_bounce * sign) / sum(sign) * 100), 0), 0))"
      )
    end
  end

  defmacro visit_duration() do
    quote do
      fragment("toUInt32(ifNotFinite(round(avg(duration * sign)), 0))")
    end
  end

  defmacro coalesce_string(fieldA, fieldB) do
    quote do
      fragment("if(empty(?), ?, ?)", unquote(fieldA), unquote(fieldB), unquote(fieldA))
    end
  end

  @doc """
  Converts time or date and time to the specified timezone.

  Reference: https://clickhouse.com/docs/en/sql-reference/functions/date-time-functions/#totimezone
  """
  defmacro to_timezone(date, timezone) do
    quote do
      fragment("toTimeZone(?, ?)", unquote(date), unquote(timezone))
    end
  end

  @doc """
  Returns the weekstart for `date`. If the weekstart is before the `not_before`
  boundary, `not_before` is returned.

  ## Examples

  In this pseudo-code example, the fragment returns the weekstart. The
  `not_before` boundary is set to the past Saturday, which is before the
  weekstart, therefore the cap does not apply.

    ```
    > this_wednesday = ~D[2022-11-09]
    > past_saturday = ~D[2022-11-05]
    > weekstart_not_before(this_wednesday, past_saturday)
    ~D[2022-11-07]
    ```

  In this other example, the fragment returns Tuesday and not the weekstart.
  The `not_before` boundary is set to Tuesday, which is past the weekstart,
  therefore the cap applies.

    ```
    > this_wednesday = ~D[2022-11-09]
    > this_tuesday = ~D[2022-11-08]
    > weekstart_not_before(this_wednesday, this_tuesday)
    ~D[2022-11-08]
    ```
  """
  defmacro weekstart_not_before(date, not_before) do
    quote do
      fragment(
        "if(toMonday(?) < toDate(?), toDate(?), toMonday(?))",
        unquote(date),
        unquote(not_before),
        unquote(not_before),
        unquote(date)
      )
    end
  end

  @doc """
  Returns whether a key (usually property) exists under `meta.key` array or similar.

  This macro is used for operating on custom properties.

  ## Examples

  `has_key(e, :meta, "some_property_name")` expands to SQL `has(meta.key, "some_property_name")`
  """
  defmacro has_key(table, meta_column, key) do
    quote do
      fragment(
        "has(?, ?)",
        field(unquote(table), ^meta_key_column(unquote(meta_column))),
        unquote(key)
      )
    end
  end

  @doc """
  Returns value of a key (usually property) under `meta.value` array or similar.

  This macro is used for operating on custom properties.
  Callsites should also check whether key exists first in SQL via `has_key` macro.

  ## Examples

  `get_by_key(e, :meta, "some_property_name")` expands to SQL `meta.value[indexOf(meta.key, "some_property")]`
  """
  defmacro get_by_key(table, meta_column, key) do
    quote do
      fragment(
        "?[indexOf(?, ?)]",
        field(unquote(table), ^meta_value_column(unquote(meta_column))),
        field(unquote(table), ^meta_key_column(unquote(meta_column))),
        unquote(key)
      )
    end
  end

  def meta_key_column(:meta), do: :"meta.key"
  def meta_key_column(:entry_meta), do: :"entry_meta.key"

  def meta_value_column(:meta), do: :"meta.value"
  def meta_value_column(:entry_meta), do: :"entry_meta.value"

  defmacro time_on_page(total_time_on_page, total_time_on_page_visits) do
    quote do
      fragment(
        "if(? > 0, toInt32(round((?) / (?))), NULL)",
        unquote(total_time_on_page_visits),
        unquote(total_time_on_page),
        unquote(total_time_on_page_visits)
      )
    end
  end

  @doc """
  Convenience Ecto macro for wrapping a map passed to select_merge_as such that each
  expression gets wrapped in dynamic and set as selected_as.

  ### Examples

    iex> wrap_alias([t], %{ foo: t.column }) |> expand_macro_once
    "%{foo: dynamic([t], selected_as(t.column, :foo))}"
  """
  defmacro wrap_alias(binding, map_literal) do
    update_literal_map_values(map_literal, fn {key, expr} ->
      key_expr =
        if Macro.quoted_literal?(key) do
          key
        else
          quote(do: ^unquote(key))
        end

      quote(do: dynamic(unquote(binding), selected_as(unquote(expr), unquote(key_expr))))
    end)
  end

  @doc """
  Convenience Ecto macro for wrapping select_merge where each value gets in turn passed to selected_as.

  ### Examples

    iex> select_merge_as(q, [t], %{ foo: t.column }) |> expand_macro_once
    "select_merge(q, [], ^wrap_alias([t], %{foo: t.column}))"
  """
  defmacro select_merge_as(q, binding, map_literal) do
    quote do
      select_merge(unquote(q), [], ^wrap_alias(unquote(binding), unquote(map_literal)))
    end
  end

  @doc """
  Macro that helps join two Ecto queries by selecting fields from either one
  """
  defmacro select_join_fields(q, query, list, table_name) do
    quote do
      Enum.reduce(unquote(list), unquote(q), fn metric_or_dimension, q ->
        key = shortname(unquote(query), metric_or_dimension)

        select_merge_as(q, [e, s], %{
          key => field(unquote(table_name), ^key)
        })
      end)
    end
  end

  defp update_literal_map_values({:%{}, ctx, keyword_list}, mapper_fn) do
    {
      :%{},
      ctx,
      Enum.map(keyword_list, fn {key, expr} ->
        {key, mapper_fn.({key, expr})}
      end)
    }
  end

  defp update_literal_map_values(ast, _), do: ast
end
