defmodule Drab.Browser do
  import Drab.Core

  @moduledoc """
  Browser related functions.

  Provides information about connected browser, such as local datetime, user agent.
  """

  @now_js """
  var d = new Date();
  var retval = {
    year: d.getFullYear(),
    month: d.getMonth(),
    day: d.getDate(),
    hour: d.getHours(),
    minute: d.getMinutes(),
    second: d.getSeconds(),
    millisecond: d.getMilliseconds()
  };
  retval
  """

  defp js_to_naive_date(js_object) do
    NaiveDateTime.new(
      js_object["year"],
      # in the world of JS, February is a first month
      js_object["month"] + 1,
      js_object["day"],
      js_object["hour"],
      js_object["minute"],
      js_object["second"],
      {js_object["millisecond"] * 1000, 3}
    )
  end

  @doc """
  Returns string with the browser unique id.

  The id is generated on the client side and stored in local store.

      iex> Drab.Browser.id(socket)
      {:ok, "2bd34ffc-b365-46a9-9479-474b628364ed"}
  """
  @spec id(Phoenix.Socket.t()) :: {:ok | :error, String.t()}
  def id(socket) do
    case socket.assigns[:__client_id] do
      nil -> {:error, "can't get the browser id"}
      id -> {:ok, id}
    end
  end

  @doc """
  Returns string with the browser unique id.

  Bang version of `id/1`.
  """
  @spec id!(Phoenix.Socket.t()) :: String.t() | no_return
  def id!(socket) do
    case id(socket) do
      {:ok, id} -> id
      {:error, message} -> raise message
    end
  end

  @doc """
  Returns local browser time as NaiveDateTime. Timezone information is not included.

  Examples:

      iex> Drab.Browser.now(socket)
      {:ok, ~N[2017-04-01 15:07:57.027000]}
  """
  @spec now(Phoenix.Socket.t()) :: {Drab.Core.status(), NaiveDateTime.t()}
  def now(socket) do
    case exec_js(socket, @now_js) do
      {:ok, %{} = browser_now} -> js_to_naive_date(browser_now)
      other -> other
    end
  end

  @doc """
  Bang version of `now/1`. Raises exception on error.

  Examples:

      iex> Drab.Browser.now!(socket)
      ~N[2017-04-01 15:07:57.027000]
  """
  @spec now!(Phoenix.Socket.t()) :: NaiveDateTime.t()
  def now!(socket) do
    browser_now = exec_js!(socket, @now_js)

    case js_to_naive_date(browser_now) do
      {:ok, now} ->
        now

      _ ->
        raise """
        can't convert JS object to NaiveDateTime.

        #{inspect(browser_now)}
        """
    end
  end

  @offset_js "new Date().getTimezoneOffset()"

  @doc """
  Returns utc offset (the difference between local browser time and UTC time), in seconds.

  Examples:

      iex> Drab.Browser.utc_offset(socket)
      {:ok, 7200} # UTC + 02:00
  """
  @spec utc_offset(Phoenix.Socket.t()) :: Drab.Core.result()
  def utc_offset(socket) do
    case exec_js(socket, @offset_js) do
      {:ok, offset} -> {:ok, -60 * offset}
      other -> other
    end
  end

  @doc """
  Returns utc offset (the difference between local browser time and UTC time), in seconds.
  Raises exception on error.

  Examples:

      iex> Drab.Browser.utc_offset!(socket)
      7200 # UTC + 02:00
  """
  @spec utc_offset!(Phoenix.Socket.t()) :: integer
  def utc_offset!(socket) do
    -60 * exec_js!(socket, @offset_js)
  end

  @agent_js "navigator.userAgent"

  @doc """
  Bang version of `user_agent/1`.

  Examples:

      iex> Drab.Browser.user_agent!(socket)
      "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_4) AppleWebKit/537.36 (KHTML, like Gecko) ..."
  """
  @spec user_agent!(Phoenix.Socket.t()) :: String.t()
  def user_agent!(socket) do
    exec_js!(socket, @agent_js)
  end

  @doc """
  Returns browser information (userAgent).

  Examples:

      iex> Drab.Browser.user_agent(socket)
      {:ok, "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_4) AppleWebKit/537.36 ..."}
  """
  @spec user_agent(Phoenix.Socket.t()) :: Drab.Core.result()
  def user_agent(socket) do
    exec_js(socket, @agent_js)
  end

  @language_js "navigator.language"

  @doc """
  Bang version of `language/1`.

  Example:
      iex> Drab.Browser.language!(socket)
      "en-GB"
  """
  @spec language!(Phoenix.Socket.t()) :: String.t()
  def language!(socket) do
    exec_js!(socket, @language_js)
  end

  @doc """
  Returns browser language.

  Example:
      iex> Drab.Browser.language(socket)
      {:ok, "en-GB"}
  """
  @spec language(Phoenix.Socket.t()) :: Drab.Core.result()
  def language(socket) do
    exec_js(socket, @language_js)
  end

  @languages_js "navigator.languages"

  @doc """
  Bang version of `language/1`.

  Example:
      iex> Drab.Browser.languages!(socket)
      ["en-US", "en", "pl"]

  """
  @spec languages!(Phoenix.Socket.t()) :: list
  def languages!(socket) do
    exec_js!(socket, @languages_js)
  end

  @doc """
  Returns a list of browser supported languages.

  Example:
      iex> Drab.Browser.languages(socket)
      {:ok, ["en-US", "en", "pl"]}

  """
  @spec languages(Phoenix.Socket.t()) :: Drab.Core.result()
  def languages(socket) do
    exec_js(socket, @languages_js)
  end

  @doc """
  Exception raising version of redirect_to/2
  """
  @spec redirect_to!(Phoenix.Socket.t(), String.t()) :: any
  def redirect_to!(socket, url) do
    exec_js!(socket, "window.location = '#{url}'")
  end

  @doc """
  Redirects to the given url.

  WARNING: redirection will disconnect the current websocket, so it should be the last function
  launched in the handler.
  """
  @spec redirect_to(Phoenix.Socket.t(), String.t()) :: Drab.Core.result()
  def redirect_to(socket, url) do
    exec_js(socket, "window.location = '#{url}'")
  end

  @doc """
  Broadcast version of `redirect_to/2`.

  WARNING: redirection will disconnect the current websocket, so it should be the last function
  launched in the handler.
  """
  @spec broadcast_redirect_to(Phoenix.Socket.t(), String.t()) :: any
  def broadcast_redirect_to(socket, url) do
    broadcast_js(socket, "window.location = '#{url}'")
  end

  @doc """
  Exception raising version of console/2
  """
  @spec console!(Phoenix.Socket.t(), String.t()) :: Phoenix.Socket.t()
  def console!(socket, log) do
    exec_js!(socket, "console.log(#{Drab.Core.encode_js(log)})")
  end

  @doc """
  Sends the log to the browser console for debugging.
  """
  @spec console(Phoenix.Socket.t(), String.t()) :: Drab.Core.result()
  def console(socket, log) do
    exec_js(socket, "console.log(#{Drab.Core.encode_js(log)})")
  end

  @doc """
  Broadcasts the log to the browser consoles for debugging/
  """
  @spec broadcast_console(Phoenix.Socket.t(), String.t()) :: Drab.Core.bcast_result()
  def broadcast_console(socket, log) do
    broadcast_js(socket, "console.log(#{Drab.Core.encode_js(log)})")
  end

  @doc """
  Replaces the URL in the browser navigation bar for the given URL.

  The new URL can be absolute or relative to the current path. It must have the same origin as
  the current one.

      iex> Drab.Browser.set_url socket, "/servers/1"
      {:ok, nil}

      iex> Drab.Browser.set_url socket, "http://google.com/"
      {:error,
       "Failed to execute 'pushState' on 'History': A history state object ...'
        cannot be created in a document with origin 'http://localhost:4000' ..."}

  """
  @spec set_url(Phoenix.Socket.t(), String.t()) :: Drab.Core.result()
  def set_url(socket, url) do
    exec_js(socket, """
    window.history.pushState({}, "", #{Drab.Core.encode_js(url)});
    """)
  end

  @doc """
  Like `set_url/2`, but broadcasting the change to all connected browsers.
  """
  @spec broadcast_set_url(Phoenix.Socket.t(), String.t()) :: Drab.Core.bcast_result()
  def broadcast_set_url(socket, url) do
    broadcast_js(socket, """
    window.history.pushState({}, "", #{Drab.Core.encode_js(url)});
    """)
  end

  @doc """
  Exception throwing version of `set_url/2`.

      iex> Drab.Browser.set_url! socket, "/servers/1"
      nil

      iex> Drab.Browser.set_url! socket, "http://google.com/"
      ** (Drab.JSExecutionError) Failed to execute 'pushState' on 'History' ...

  """
  @spec set_url!(Phoenix.Socket.t(), String.t()) :: any
  def set_url!(socket, url) do
    exec_js!(socket, """
    window.history.pushState({}, "", #{Drab.Core.encode_js(url)});
    """)
  end

  @doc """
  Gets cookies from the browser.

  Returns map of `%{name => value}`. Notice that in case of the multiple cookies with the same name,
  returns only the one which browser returns first. It should be a cookie with the longest path.

  Cookie names are decoded using `Drab.Coder.URL`.

  Values are decoded using, by default, `Drab.Coder.URL`. You may change this by giving `:decoder`
  option:

      iex> Drab.Browser.cookies(socket, decoder: Drab.Coder.Cipher)
      {:ok, %{"exp" => 42, "mycookie" => "value"}}
  """
  @spec cookies(Phoenix.Socket.t(), Keyword.t()) :: Drab.Core.result()
  def cookies(socket, options \\ []) do
    decoder = options[:decoder] || Drab.Coder.URL

    case exec_js(socket, "document.cookie") do
      {:ok, cookies} -> {:ok, decode_cookies(cookies, decoder)}
      other -> other
    end
  end

  @doc """
  Exception raising version of `cookies/2`.
  """
  @spec cookies!(Phoenix.Socket.t(), Keyword.t()) :: map | no_return
  def cookies!(socket, options \\ []) do
    Drab.JSExecutionError.result_or_raise(cookies(socket, options))
  end

  @doc """
  Gets a named cookie from the browser.

  Values are decoded using, by default, `Drab.Coder.URL`. You may change this by giving `:decoder`
  option:

      iex> Drab.Browser.set_cookie(socket, "mycookie", "42")
      {:ok, "mycookie=42"}
      iex> Drab.Browser.cookie(socket, "mycookie")
      {:ok, "42"}
  """
  @spec cookie(Phoenix.Socket.t(), String.t(), Keyword.t()) :: Drab.Core.result()
  def cookie(socket, name, options \\ []) do
    case cookies(socket, options) do
      {:ok, cookies} ->
          (c = Map.get(cookies, name)) && {:ok, c} || {:error, "Cookie #{inspect name} not found."}
      other ->
        other
    end
  end

  @doc """
  Exception raising version of `cookie/3`
  """
  @spec cookie!(Phoenix.Socket.t(), String.t(), Keyword.t()) :: any | no_return
  def cookie!(socket, name, options \\ []) do
    socket
    |> cookies!(options)
    |> Map.get(name)
    |> (&(&1) || raise "Cookie #{inspect name} not found.").()
  end

  @doc """
  Delete the named cookie.

      iex> Drab.Browser.set_cookie(socket, "mycookie", "42")
      {:ok, "mycookie=42"}
      iex> Drab.Browser.cookie(socket, "mycookie")
      {:ok, "42"}
      iex> Drab.Browser.delete_cookie(socket, "mycookie")
      {:ok, _}
      iex> Drab.Browser.cookie(socket, "mycookie")
      {:error, "Cookie \"mycookie\" not found."}
  """
  @spec delete_cookie(Phoenix.Socket.t(), String.t()) :: Drab.Core.result()
  def delete_cookie(socket, name) do
    set_cookie(socket, name, "", max_age: -1)
  end

  @doc """
  Exception raising version of `delete_cookie/2`
  """
  @spec delete_cookie!(Phoenix.Socket.t(), String.t()) :: String.t() | no_return
  def delete_cookie!(socket, name) do
    Drab.Browser.set_cookie!(socket, name, "", max_age: -1)
  end

  @spec decode_cookies(String.t(), atom) :: map
  defp decode_cookies("", _), do: %{}

  defp decode_cookies(cookies, decoder) do
    cookies =
      for cookie <- String.split(cookies, ";") do
        m = Regex.named_captures(~r/(?<name>\S+)=(?<value>.*)/, cookie)

        {
          Drab.Coder.URL.decode!(m["name"]),
          case decoder.decode(m["value"]) do
            {:ok, val} -> val
            _ -> m["value"]
          end
        }
      end

    # browser should put the most important cookie (longest path) at the begining of the string
    cookies = Enum.uniq_by(cookies, fn {k, _} -> k end)
    Enum.into(cookies, %{})
  end

  @doc """
  Sets the cookie.

  Cookie names are encoded using `Drab.Coder.URL`.

  Values are encoded using, by default, `Drab.Coder.URL`. You may change this by giving `:encoder`
  option. Notice that some encoders (`Drab.Coder.URL` and `Drab.Coder.Base64`) accept only
  string as an argument. To set the cookie with any term, use `Drab.Coder.String` or
  `Drab.Coder.Cipher`.

  Options:
    * `:encoder` (default `Drab.Coder.URL`) - encode the cookie value with the given coder
    * `:path`
    * `:domain` - must contain two dots, like ".example.org"
    * `:secure` - if true, cookie will be https only
    * `:max_age` - number of seconds for when a cookie will be deleted

  Returns `{:ok, cookie}` where `cookie` is the string returned by the browsers, or
  `{:error, reason}` if something goes wrong.

  To delete a cookie, set the `:max_age` to -1.

  Examples:

      iex> set_cookie(socket, "mycookie", "value")
      {:ok, "mycookie=value"}

      iex> set_cookie(socket, "mycookie", "value", max_age: 10)
      {:ok, "mycookie=value; expires=Thu, 19 Jul 2018 19:47:09 GMT"}

      iex(69)> set_cookie(socket, "mycookie", %{any: "term"}, max_age: 10, encoder: Drab.Coder.Cipher)
      {:ok, "mycookie=QTEyO...Yinr17vXhHQ; expires=Thu, 19 Jul 2018 19:48:53 GMT"}
  """
  @spec set_cookie(Phoenix.Socket.t(), String.t(), term, Keyword.t()) :: Drab.Core.result()
  def set_cookie(socket, name, value, options \\ []) do
    encoder = options[:encoder] || Drab.Coder.URL
    path = if options[:path], do: "; path=#{options[:path]}", else: ""
    domain = if options[:domain], do: "; domain=#{options[:domain]}", else: ""
    secure = if options[:secure], do: "; secure", else: ""
    seconds = options[:max_age] || false
    name = Drab.Coder.URL.encode!(name)
    value = encoder.encode!(value)

    js = """
    var t = "#{name}=#{value}";
    var seconds = #{seconds};
    if (typeof seconds === "number") {
        const today = new Date();
        today.setTime(today.getTime() + (seconds * 1000));
        t += "; expires=" + today.toGMTString();
    }
    t += "#{path}#{domain}#{secure}";
    document.cookie = t;
    """

    exec_js(socket, js)
  end

  @doc """
  Exception raising version of `set_cookie/4`.
  """
  @spec set_cookie!(Phoenix.Socket.t(), String.t(), term, Keyword.t()) :: String.t() | no_return
  def set_cookie!(socket, name, value, options \\ []) do
    Drab.JSExecutionError.result_or_raise(set_cookie(socket, name, value, options))
  end
end
