defmodule PlausibleWeb.Api.ExternalStatsController do
  use PlausibleWeb, :controller
  use Plausible.Repo
  use PlausibleWeb.Plugs.ErrorHandler
  alias Plausible.Stats.{Query, Metrics, Filters}

  def realtime_visitors(conn, _params) do
    site = conn.assigns.site
    json(conn, Plausible.Stats.current_visitors(site))
  end

  def aggregate(conn, params) do
    site = Repo.preload(conn.assigns.site, :owners)

    params = Map.put(params, "property", nil)

    with :ok <- validate_period(params),
         :ok <- validate_date(params),
         query <- Query.from(site, params, debug_metadata(conn)),
         :ok <- validate_filters(site, query.filters),
         {:ok, metrics} <- parse_and_validate_metrics(params, query),
         :ok <- ensure_custom_props_access(site, query) do
      %{results: results, meta: meta} = Plausible.Stats.aggregate(site, query, metrics)

      payload = maybe_add_warning(%{results: results}, meta)

      json(conn, payload)
    else
      err_tuple -> send_json_error_response(conn, err_tuple)
    end
  end

  def breakdown(conn, params) do
    site = Repo.preload(conn.assigns.site, :owners)

    with :ok <- validate_period(params),
         :ok <- validate_date(params),
         :ok <- validate_property(params),
         query <- Query.from(site, params, debug_metadata(conn)),
         :ok <- validate_filters(site, query.filters),
         {:ok, metrics} <- parse_and_validate_metrics(params, query),
         {:ok, limit} <- validate_or_default_limit(params),
         :ok <- ensure_custom_props_access(site, query) do
      page = String.to_integer(Map.get(params, "page", "1"))

      %{results: results, meta: meta} =
        Plausible.Stats.breakdown(site, query, metrics, {limit, page})

      payload = maybe_add_warning(%{results: results}, meta)

      json(conn, payload)
    else
      err_tuple -> send_json_error_response(conn, err_tuple)
    end
  end

  defp validate_property(%{"property" => property}) do
    cond do
      property == "event:hostname" ->
        {:error,
         "Property 'event:hostname' is currently not supported for breakdowns.  Please provide a valid property for the breakdown endpoint: https://plausible.io/docs/stats-api#properties"}

      Plausible.Stats.Legacy.Dimensions.valid?(property) ->
        :ok

      true ->
        {:error,
         "Invalid property '#{property}'. Please provide a valid property for the breakdown endpoint: https://plausible.io/docs/stats-api#properties"}
    end
  end

  defp validate_property(_) do
    {:error,
     "The `property` parameter is required. Please provide at least one property to show a breakdown by."}
  end

  @max_breakdown_limit 1000
  defp validate_or_default_limit(%{"limit" => limit}) do
    with {limit, ""} when limit > 0 and limit <= @max_breakdown_limit <- Integer.parse(limit) do
      {:ok, limit}
    else
      _ ->
        {:error, "Please provide limit as a number between 1 and #{@max_breakdown_limit}."}
    end
  end

  @default_breakdown_limit 100
  defp validate_or_default_limit(_), do: {:ok, @default_breakdown_limit}

  defp parse_and_validate_metrics(params, query) do
    metrics =
      Map.get(params, "metrics", "visitors")
      |> String.split(",")

    case validate_metrics(metrics, query) do
      {:error, reason} ->
        {:error, reason}

      metrics ->
        {:ok, Enum.map(metrics, &Metrics.from_string!/1)}
    end
  end

  @spec ensure_custom_props_access(Plausible.Site.t(), Query.t()) ::
          :ok | {:error, {402, String.t()}}
  defp ensure_custom_props_access(site, query) do
    allowed_props = Plausible.Props.allowed_for(site, bypass_setup?: true)
    prop_filter = Filters.get_toplevel_filter(query, "event:props:")

    query_allowed? =
      case {prop_filter, query.dimensions, allowed_props} do
        {_, _, :all} ->
          true

        {[_, "event:props:" <> prop | _], _property, allowed_props} ->
          prop in allowed_props

        {_filter, ["event:props:" <> prop], allowed_props} ->
          prop in allowed_props

        _ ->
          true
      end

    if query_allowed? do
      :ok
    else
      msg = "The owner of this site does not have access to the custom properties feature"
      {:error, {402, msg}}
    end
  end

  defp validate_metrics(metrics, query) do
    if length(metrics) == length(Enum.uniq(metrics)) do
      validate_each_metric(metrics, query)
    else
      {:error, "Metrics cannot be queried multiple times."}
    end
  end

  defp validate_each_metric(metrics, query) do
    Enum.reduce_while(metrics, [], fn metric, acc ->
      case validate_metric(metric, query) do
        {:ok, metric} -> {:cont, acc ++ [metric]}
        {:error, reason} -> {:halt, {:error, reason}}
      end
    end)
  end

  defp validate_metric("time_on_page" = metric, query) do
    cond do
      Filters.filtering_on_dimension?(query, "event:goal") ->
        {:error, "Metric `#{metric}` cannot be queried when filtering by `event:goal`"}

      Filters.filtering_on_dimension?(query, "event:name") ->
        {:error, "Metric `#{metric}` cannot be queried when filtering by `event:name`"}

      query.dimensions == ["event:page"] ->
        {:ok, metric}

      not Enum.empty?(query.dimensions) ->
        {:error,
         "Metric `#{metric}` is not supported in breakdown queries (except `event:page` breakdown)"}

      Filters.filtering_on_dimension?(query, "event:page") ->
        {:ok, metric}

      true ->
        {:error,
         "Metric `#{metric}` can only be queried in a page breakdown or with a page filter."}
    end
  end

  defp validate_metric("conversion_rate" = metric, query) do
    cond do
      query.dimensions == ["event:goal"] ->
        {:ok, metric}

      Filters.filtering_on_dimension?(query, "event:goal") ->
        {:ok, metric}

      true ->
        {:error,
         "Metric `#{metric}` can only be queried in a goal breakdown or with a goal filter"}
    end
  end

  defp validate_metric(metric, _) when metric in ["visitors", "pageviews", "events"] do
    {:ok, metric}
  end

  defp validate_metric("views_per_visit" = metric, query) do
    cond do
      Filters.filtering_on_dimension?(query, "event:page") ->
        {:error, "Metric `#{metric}` cannot be queried with a filter on `event:page`."}

      not Enum.empty?(query.dimensions) ->
        {:error, "Metric `#{metric}` is not supported in breakdown queries."}

      true ->
        validate_session_metric(metric, query)
    end
  end

  defp validate_metric(metric, query)
       when metric in ["visits", "bounce_rate", "visit_duration"] do
    validate_session_metric(metric, query)
  end

  defp validate_metric(metric, _) do
    {:error,
     "The metric `#{metric}` is not recognized. Find valid metrics from the documentation: https://plausible.io/docs/stats-api#metrics"}
  end

  defp validate_session_metric(metric, query) do
    cond do
      length(query.dimensions) == 1 and event_only_property?(hd(query.dimensions)) ->
        {:error,
         "Session metric `#{metric}` cannot be queried for breakdown by `#{query.dimensions}`."}

      event_only_filter = find_event_only_filter(query) ->
        {:error,
         "Session metric `#{metric}` cannot be queried when using a filter on `#{event_only_filter}`."}

      true ->
        {:ok, metric}
    end
  end

  defp find_event_only_filter(query) do
    query.filters
    |> Enum.map(fn [_op, prop | _] -> prop end)
    |> Enum.find(&event_only_property?/1)
  end

  defp event_only_property?("event:name"), do: true
  defp event_only_property?("event:goal"), do: true
  defp event_only_property?("event:props:" <> _), do: true
  defp event_only_property?(_), do: false

  def timeseries(conn, params) do
    site = Repo.preload(conn.assigns.site, :owners)

    params = Map.put(params, "property", nil)

    params =
      if Map.get(params, "interval") == "date" do
        %{params | "interval" => "day"}
      else
        params
      end

    with :ok <- validate_period(params),
         :ok <- validate_date(params),
         :ok <- validate_interval(params),
         query <- Query.from(site, params, debug_metadata(conn)),
         :ok <- validate_filters(site, query.filters),
         {:ok, metrics} <- parse_and_validate_metrics(params, query),
         :ok <- ensure_custom_props_access(site, query) do
      {results, _, meta} = Plausible.Stats.timeseries(site, query, metrics)

      payload =
        case meta[:imports_warning] do
          nil -> %{results: results}
          warning -> %{results: results, warning: warning}
        end

      json(conn, payload)
    else
      err_tuple -> send_json_error_response(conn, err_tuple)
    end
  end

  defp validate_date(%{"period" => "custom"} = params) do
    with {:ok, date} <- Map.fetch(params, "date"),
         [from, to] <- String.split(date, ","),
         {:ok, _from} <- Date.from_iso8601(String.trim(from)),
         {:ok, _to} <- Date.from_iso8601(String.trim(to)) do
      :ok
    else
      :error ->
        {:error,
         "The `date` parameter is required when using a custom period. See https://plausible.io/docs/stats-api#time-periods"}

      _ ->
        {:error,
         "Invalid format for `date` parameter. When using a custom period, please include two ISO-8601 formatted dates joined by a comma. See https://plausible.io/docs/stats-api#time-periods"}
    end
  end

  defp validate_date(%{"date" => date}) do
    case Date.from_iso8601(date) do
      {:ok, _date} ->
        :ok

      {:error, msg} ->
        {:error,
         "Error parsing `date` parameter: #{msg}. Please specify a valid date in ISO-8601 format."}
    end
  end

  defp validate_date(_), do: :ok

  defp validate_period(%{"period" => period}) do
    if period in ["day", "7d", "30d", "month", "6mo", "12mo", "custom"] do
      :ok
    else
      {:error,
       "Error parsing `period` parameter: invalid period `#{period}`. Please find accepted values in our docs: https://plausible.io/docs/stats-api#time-periods"}
    end
  end

  defp validate_period(_), do: :ok

  @valid_intervals ["day", "month"]
  @valid_intervals_str Enum.map(@valid_intervals, &("`" <> &1 <> "`")) |> Enum.join(", ")

  defp validate_interval(%{"interval" => interval}) do
    if interval in @valid_intervals do
      :ok
    else
      {:error,
       "Error parsing `interval` parameter: invalid interval `#{interval}`. Valid intervals are #{@valid_intervals_str}"}
    end
  end

  defp validate_interval(_), do: :ok

  defp validate_filters(site, filters) do
    Enum.reduce_while(filters, :ok, fn filter, _ ->
      case validate_filter(site, filter) do
        :ok -> {:cont, :ok}
        {:error, reason} -> {:halt, {:error, reason}}
      end
    end)
  end

  defp validate_filter(site, [_type, "event:goal", goal_filter | _rest]) do
    configured_goals =
      site
      |> Plausible.Goals.for_site()
      |> Enum.map(& &1.display_name)

    goals_in_filter = List.wrap(goal_filter)

    if found = Enum.find(goals_in_filter, &(&1 not in configured_goals)) do
      msg =
        goal_not_configured_message(found) <>
          "Find out how to configure goals here: https://plausible.io/docs/stats-api#filtering-by-goals"

      {:error, msg}
    else
      :ok
    end
  end

  defp validate_filter(_site, [_, property | _]) do
    if Plausible.Stats.Legacy.Dimensions.valid?(property) do
      :ok
    else
      {:error,
       "Invalid filter property '#{property}'. Please provide a valid filter property: https://plausible.io/docs/stats-api#properties"}
    end
  end

  defp goal_not_configured_message("Visit " <> page_path) do
    "The pageview goal for the pathname `#{page_path}` is not configured for this site. "
  end

  defp goal_not_configured_message(goal) do
    "The goal `#{goal}` is not configured for this site. "
  end

  @imported_query_unsupported_warning "Imported stats are not included in the results because query parameters are not supported. For more information, see: https://plausible.io/docs/stats-api#filtering-imported-stats"

  defp maybe_add_warning(payload, %Jason.OrderedObject{} = meta) do
    case meta[:imports_skip_reason] do
      :unsupported_query -> Map.put(payload, :warning, @imported_query_unsupported_warning)
      _ -> payload
    end
  end

  defp send_json_error_response(conn, {:error, {status, msg}}) do
    conn
    |> put_status(status)
    |> json(%{error: msg})
  end

  defp send_json_error_response(conn, {:error, msg}) do
    conn
    |> put_status(400)
    |> json(%{error: msg})
  end
end
