defmodule PlausibleWeb.UserAuth do
  @moduledoc """
  Functions for user session management.
  """

  use Plausible

  alias Plausible.Auth
  alias PlausibleWeb.TwoFactor

  alias PlausibleWeb.Router.Helpers, as: Routes

  require Logger

  on_ee do
    @type login_subject() :: Auth.User.t() | Auth.SSO.Identity.t()
  else
    @type login_subject() :: Auth.User.t()
  end

  @spec log_in_user(Plug.Conn.t(), login_subject(), String.t() | nil) ::
          Plug.Conn.t()
  def log_in_user(conn, subject, redirect_path \\ nil)

  def log_in_user(conn, %Auth.User{} = user, redirect_path) do
    redirect_to = login_redirect_path(conn, redirect_path)
    device_name = get_device_name(conn)
    session = Auth.UserSessions.create!(user, device_name)

    conn
    |> set_user_token(session.token)
    |> set_logged_in_cookie()
    |> Phoenix.Controller.redirect(to: redirect_to)
  end

  on_ee do
    def log_in_user(conn, %Auth.SSO.Identity{} = identity, redirect_path) do
      case Auth.SSO.provision_user(identity) do
        {:ok, _provisioning_from, team, user} ->
          redirect_to = login_redirect_path(conn, redirect_path)
          device_name = get_device_name(conn)
          session = Auth.UserSessions.create!(user, device_name, timeout_at: identity.expires_at)

          conn
          |> set_user_token(session.token)
          |> Plug.Conn.put_session("current_team_id", team.identifier)
          |> PlausibleWeb.LoginPreference.set_sso()
          |> set_logged_in_cookie()
          |> Phoenix.Controller.redirect(to: redirect_to)

        {:error, :integration_not_found} ->
          conn
          |> log_out_user()
          |> Phoenix.Controller.put_flash(:login_error, "Wrong email.")
          |> Phoenix.Controller.redirect(
            to: Routes.sso_path(conn, :login_form, return_to: redirect_path)
          )

        {:error, :over_limit} ->
          error = "Team can't accept more members. Please contact the owner."

          conn
          |> log_out_user()
          |> Phoenix.Controller.put_flash(:login_error, error)
          |> Phoenix.Controller.redirect(
            to: Routes.sso_path(conn, :login_form, return_to: redirect_path)
          )

        {:error, reason, _team, _user}
        when reason in [:multiple_memberships, :active_personal_team] ->
          issue = to_string(reason) <> "_noforce"

          conn
          |> log_out_user()
          |> Phoenix.Controller.redirect(
            to: Routes.sso_path(conn, :provision_issue, issue: issue)
          )
      end
    end
  end

  @spec log_out_user(Plug.Conn.t()) :: Plug.Conn.t()
  def log_out_user(conn) do
    case get_user_token(conn) do
      {:ok, token} -> Auth.UserSessions.remove_by_token(token)
      {:error, _} -> :pass
    end

    if live_socket_id = Plug.Conn.get_session(conn, :live_socket_id) do
      Auth.UserSessions.disconnect_by_token(live_socket_id)
    end

    conn
    |> renew_session()
    |> clear_logged_in_cookie()
  end

  @spec get_user_session(Plug.Conn.t() | map()) ::
          {:ok, Auth.UserSession.t()}
          | {:error, :no_valid_token | :session_not_found}
          | {:error, :session_expired, Auth.UserSession.t()}
  def get_user_session(%Plug.Conn{assigns: %{current_user_session: user_session}}) do
    {:ok, user_session}
  end

  def get_user_session(conn_or_session) do
    with {:ok, token} <- get_user_token(conn_or_session) do
      case Auth.UserSessions.get_by_token(token) do
        {:ok, session} -> {:ok, session}
        {:error, :not_found} -> {:error, :session_not_found}
        {:error, :expired, user_session} -> {:error, :session_expired, user_session}
      end
    end
  end

  @doc """
  Sets the `logged_in` cookie share with the static site for determining
  whether client is authenticated.

  As it's a separate cookie, there's a chance it might fall out of sync
  with session cookie state due to manual deletion or premature expiration.
  """
  @spec set_logged_in_cookie(Plug.Conn.t()) :: Plug.Conn.t()
  def set_logged_in_cookie(conn) do
    Plug.Conn.put_resp_cookie(conn, "logged_in", "true",
      http_only: false,
      max_age: 60 * 60 * 24 * 365 * 5000
    )
  end

  defp set_user_token(conn, token) do
    conn
    |> renew_session()
    |> TwoFactor.Session.clear_2fa_user()
    |> put_token_in_session(token)
  end

  defp login_redirect_path(conn, redirect_path) do
    if String.starts_with?(redirect_path || "", "/") do
      redirect_path
    else
      Routes.site_path(conn, :index)
    end
  end

  defp renew_session(conn) do
    Phoenix.Controller.delete_csrf_token()

    conn
    |> Plug.Conn.configure_session(renew: true)
    |> Plug.Conn.clear_session()
  end

  defp clear_logged_in_cookie(conn) do
    Plug.Conn.delete_resp_cookie(conn, "logged_in")
  end

  defp put_token_in_session(conn, token) do
    conn
    |> Plug.Conn.put_session(:user_token, token)
    |> Plug.Conn.put_session(:live_socket_id, Auth.UserSessions.socket_id(token))
  end

  defp get_user_token(%Plug.Conn{} = conn) do
    conn
    |> Plug.Conn.get_session()
    |> get_user_token()
  end

  defp get_user_token(%{"user_token" => token}) when is_binary(token) do
    {:ok, token}
  end

  defp get_user_token(_) do
    {:error, :no_valid_token}
  end

  @unknown_label "Unknown"

  defp get_device_name(%Plug.Conn{} = conn) do
    conn
    |> Plug.Conn.get_req_header("user-agent")
    |> List.first()
    |> get_device_name()
  end

  defp get_device_name(user_agent) when is_binary(user_agent) do
    case UAInspector.parse(user_agent) do
      %UAInspector.Result{client: %UAInspector.Result.Client{name: "Headless Chrome"}} ->
        "Headless Chrome"

      %UAInspector.Result.Bot{name: name} when is_binary(name) ->
        name

      %UAInspector.Result{} = ua ->
        browser = browser_name(ua)

        if os = os_name(ua) do
          browser <> " (#{os})"
        else
          browser
        end

      _ ->
        @unknown_label
    end
  end

  defp get_device_name(_), do: @unknown_label

  defp browser_name(ua) do
    case ua.client do
      :unknown -> @unknown_label
      %UAInspector.Result.Client{name: "Mobile Safari"} -> "Safari"
      %UAInspector.Result.Client{name: "Chrome Mobile"} -> "Chrome"
      %UAInspector.Result.Client{name: "Chrome Mobile iOS"} -> "Chrome"
      %UAInspector.Result.Client{name: "Firefox Mobile"} -> "Firefox"
      %UAInspector.Result.Client{name: "Firefox Mobile iOS"} -> "Firefox"
      %UAInspector.Result.Client{name: "Opera Mobile"} -> "Opera"
      %UAInspector.Result.Client{name: "Opera Mini"} -> "Opera"
      %UAInspector.Result.Client{name: "Opera Mini iOS"} -> "Opera"
      %UAInspector.Result.Client{name: "Yandex Browser Lite"} -> "Yandex Browser"
      %UAInspector.Result.Client{name: "Chrome Webview"} -> "Mobile App"
      %UAInspector.Result.Client{type: "mobile app"} -> "Mobile App"
      client -> client.name || @unknown_label
    end
  end

  defp os_name(ua) do
    case ua.os do
      :unknown -> nil
      os -> os.name
    end
  end
end
