defmodule PhoenixActiveLink do
  @moduledoc """
  PhoenixActiveLink provides helpers to add active links in views.

  ## Configuration

  Default options can be customized in the configuration:

  ```elixir
  use Mix.Config

  config :phoenix_active_link, :defaults,
    wrap_tag: :li,
    class_active: "enabled",
    class_inactive: "disabled"
  ```

  ## Integrate in Phoenix

  The simplest way to add the helpers to Phoenix is to `import PhoenixActiveLink`
  either in your `web.ex` under views to have it available under every views,
  or under for example `App.LayoutView` to have it available in your layout.
  """

  use Phoenix.HTML
  import Plug.Conn
  alias Plug.Conn.Query

  @opts ~w(active wrap_tag class_active class_inactive active_disable wrap_tag_opts)a

  @doc """
  `active_link/3` is a wrapper around `Phoenix.HTML.Link.link/2`.

  It generates a link and adds an `active` class depending on the
  desired state. It can be customized using the following options.

  ## Options

    * `:active`         - See `active_path?/2` documentation for more information
    * `:wrap_tag`       - Wraps the link in another tag which will also have the same active class.
        This options is useful for usage with `li` in bootstrap for example.
    * `:class_active`   - The class to add when the link is active. Defaults to `"active"`
    * `:class_inactive` - The class to add when the link is not active. Empty by default.
    * `:active_disable` - Uses a `span` element instead of an anchor when not active.
    * `:aria_current`   - The `aria-current` value to add when the link is active. Empty by default.

  ## Examples

    ```elixir
    <%= active_link(@conn, "Link text", to: "/my/path") %>
    <%= active_link(@conn, "Link text", to: "/my/path", wrap_tag: :li) %>
    <%= active_link(@conn, "Link text", to: "/my/path", active: :exact) %>
    ```
  """
  def active_link(conn, opts, do: contents) when is_list(opts) do
    active_link(conn, contents, opts)
  end

  def active_link(conn, text, opts) do
    opts = Keyword.merge(default_opts(), opts)
    active? = active_path?(conn, opts)
    extra_class = extra_class(active?, opts)
    opts = append_class(opts, extra_class)
    opts = aria_current(opts, active?)
    link = make_link(active?, text, opts)
    cond do
      tag = opts[:wrap_tag] -> content_tag(tag, link, wrap_tag_opts(extra_class, opts))
      true                  -> link
    end
  end

  @doc """
  `active_path?/2` is a helper to determine if the element should be in active state or not.

  The `:opts` should contain the `:to` option and the active detection can be customized
  using by passing `:active` one of the following values.

    * `true`       - Will always return `true`
    * `false`      - Will always return `false`
    * `:inclusive` - Will return `true` if the current path starts with the link path.

      For example, `active_path?(conn, to: "/foo")` will return `true` if the path is `"/foo"` or `"/foobar"`.
    * `:exclusive` - Will return `true` if the current path and the link path are the same,
       but will ignore the trailing slashes

       For example, `active_path?(conn, "/foo")` will return `true`
       when the path is `"/foo/"`
    * `:exact`     - Will return `true` if the current path and the link path are exactly the same,
       including trailing slashes.
    * a `%Regex{}` - Will return `true` if the current path matches the regex.

        Beware that `active?(conn, active: ~r/foo/)` will return `true` if the path is `"/bar/foo"`, so
       you must use `active?(conn, active: ~r/^foo/)` if you want to match the beginning of the path.
    * a `{controller, action}` list - A list of tuples with a controller module and an action symbol.

        Both can be the `:any` symbol to match any controller or action.
    * a `{live_view, action}` list - A list of tuples with a live view module and an action symbol.

        Both can be the `:any` symbol to match any live view module or action.
    * `:exact_with_params`     - Will return `true` if the current path and the link path are exactly the same,
       including trailing slashes and query string as is.

    * `:inclusive_with_params` - Will return `true` if the current path is equal to the link path and the query params of the current path are included to the link path.
        For example, `active_path?(conn, to: "/foo?bar=2")` will return `true` if the path is `"/foo?bar=2"` or `"/foo?baz=2&bar=2"`.
        For example, `active_path?(conn, to: "/foo?bar=2")` will return `false` if the path is `"/foobaz?bar=2"`.

  ## Examples

  ```elixir
  active_path?(conn, to: "/foo")
  active_path?(conn, to: "/foo", active: false)
  active_path?(conn, to: "/foo", active: :exclusive)
  active_path?(conn, to: "/foo", active: ~r(^/foo/[0-9]+))
  active_path?(conn, to: "/foo", active: [{MyController, :index}, {OtherController, :any}])
  active_path?(conn, to: "/foo", active: [{MyLive, :index}, {OtherLive, :any}])
  active_path?(conn, to: "/foo?baz=2", active: :inclusive_with_params)
  ```

  """
  def active_path?(conn, opts) do
    to = Keyword.get(opts, :to, "")
    case Keyword.get(opts, :active, :inclusive) do
      true       -> true
      false      -> false
      :inclusive -> starts_with_path?(conn.request_path, to)
      :exclusive -> String.trim_trailing(conn.request_path, "/") == String.trim_trailing(to, "/")
      :exact     -> conn.request_path == to
      :exact_with_params -> request_path_with_params(conn) == to
      :inclusive_with_params -> compare_path_and_params(conn, to)
      %Regex{} = regex -> Regex.match?(regex, conn.request_path)
      module_actions when is_list(module_actions) ->
        module_actions_active?(conn, module_actions)
      _ -> false
    end
  end

  # NOTE: root path is an exception, otherwise it would be active all the time
  defp starts_with_path?(request_path, "/") when request_path != "/", do: false
  defp starts_with_path?(request_path, to) do
    # Parse both paths to strip any query parameters
    %{path: request_path} = URI.parse(request_path)
    %{path: to_path} = URI.parse(to)

    String.starts_with?(request_path, String.trim_trailing(to_path, "/"))
  end

  defp module_actions_active?(conn, module_actions) do
    {current_module, current_action} =
      case conn.private do
        %{phoenix_controller: module, phoenix_action: action} -> {module, action}
        %{phoenix_live_view: {module, opts}} -> {module, Keyword.get(opts, :action)}
        %{} -> {nil, nil}
      end

    Enum.any? module_actions, fn {module, action} ->
      (module == :any or module == current_module) and
        (action == :any or action == current_action)
    end
  end

  defp request_path_with_params(conn) do
    case conn.query_string do
      "" -> conn.request_path
      query_string -> conn.request_path <> "?" <> query_string
    end
  end

  defp compare_path_and_params(conn, to) do
    %{query_params: request_params} = fetch_query_params(conn)

    with [path, query_params] <- String.split(to, "?"),
         true <- starts_with_path?(conn.request_path, path) do
      decoded_params =
        query_params
        |> Query.decode()

      map_include?(request_params, decoded_params)
    else
      [path] -> conn.request_path == path
      false -> false
    end
  end

  defp map_include?(map, {key, %{} = value}), do: map_include?(map[key], value)
  defp map_include?(map, {key, value}), do: map[key] == value
  defp map_include?(in_map, %{} = map), do: Enum.all?(map, &map_include?(in_map, &1))

  defp wrap_tag_opts(extra_class, opts) do
    Keyword.get(opts, :wrap_tag_opts, [])
    |> append_class(extra_class)
  end

  defp make_link(active?, text, opts) do
    if active? and opts[:active_disable] do
      content_tag(:span, text, span_opts(opts))
    else
      link(text, link_opts(opts))
    end
  end

  defp extra_class(active?, opts) do
    if active? do
      opts[:class_active] || "active"
    else
      opts[:class_inactive] || ""
    end
  end

  defp append_class(opts, class) do
    class =
      opts
      |> Keyword.get(:class, "")
      |> String.split(" ")
      |> List.insert_at(0, class)
      |> Enum.reject(&(&1 == ""))
      |> Enum.join(" ")
    Keyword.put(opts, :class, class)
  end

  defp aria_current(opts, active?) do
    if active? do
      opts
    else
      Keyword.delete(opts, :aria_current)
    end
  end

  defp link_opts(opts) do
    Enum.reject(opts, &(elem(&1, 0) in @opts))
  end

  defp span_opts(opts) do
    opts |> link_opts() |> Enum.reject(&(elem(&1, 0) in ~w(to form method)a))
  end

  defp default_opts do
    Application.get_env(:phoenix_active_link, :defaults, [])
  end
end
