defmodule Mobilizon.Service.Auth.Applications do
  @moduledoc """
  Module to handle applications management
  """
  alias Mobilizon.Applications
  alias Mobilizon.Applications.{Application, ApplicationDeviceActivation, ApplicationToken}
  alias Mobilizon.GraphQL.Authorization.AppScope
  alias Mobilizon.Service.Auth.Authenticator
  alias Mobilizon.Users.User
  alias Mobilizon.Web.Auth.Guardian
  alias Mobilizon.Web.Router.Helpers, as: Routes
  require Logger

  @app_access_tokens_ttl {8, :hour}
  @app_refresh_tokens_ttl {26, :week}

  @device_code_expires_in 900
  @device_code_interval 5

  @authorization_code_lifetime 60
  @application_device_activation_lifetime @device_code_expires_in * 2

  @type access_token_details :: %{
          required(:access_token) => String.t(),
          required(:expires_in) => pos_integer(),
          required(:refresh_token) => String.t(),
          required(:refresh_token_expires_in) => pos_integer(),
          required(:scope) => nil,
          required(:token_type) => String.t()
        }

  @spec create(String.t(), list(String.t()), String.t(), String.t() | nil) ::
          {:ok, Application.t()} | {:error, Ecto.Changeset.t()} | {:error, :invalid_scope}
  def create(name, redirect_uris, scope, website \\ nil) do
    if AppScope.scopes_valid?(scope) do
      client_id = :crypto.strong_rand_bytes(42) |> Base.encode64() |> binary_part(0, 42)
      client_secret = :crypto.strong_rand_bytes(42) |> Base.encode64() |> binary_part(0, 42)

      Applications.create_application(%{
        name: name,
        redirect_uris: redirect_uris,
        scope: scope,
        website: website,
        client_id: client_id,
        client_secret: client_secret
      })
    else
      {:error, :invalid_scope}
    end
  end

  @spec autorize(String.t(), String.t(), String.t(), integer()) ::
          {:ok, ApplicationToken.t()}
          | {:error, :application_not_found}
          | {:error, :redirect_uri_not_in_allowed}
          | {:error, Ecto.Changeset.t()}
  def autorize(client_id, redirect_uri, scope, user_id) do
    with %Application{redirect_uris: redirect_uris, id: app_id} <-
           Applications.get_application_by_client_id(client_id),
         {:redirect_uri, true} <-
           {:redirect_uri, redirect_uri in redirect_uris},
         code <- :crypto.strong_rand_bytes(16) |> Base.encode64() |> binary_part(0, 16) do
      Applications.create_application_token(%{
        user_id: user_id,
        application_id: app_id,
        authorization_code: code,
        scope: scope,
        status: :pending
      })
    else
      nil ->
        {:error, :application_not_found}

      {:redirect_uri, _} ->
        {:error, :redirect_uri_not_in_allowed}
    end
  end

  @spec autorize_device_application(String.t(), String.t()) ::
          {:ok, ApplicationDeviceActivation.t()}
          | {:error, Ecto.Changeset.t()}
          | {:error, :expired}
          | {:error, :access_denied}
          | {:error, :not_found}
  def autorize_device_application(client_id, user_code) do
    Logger.debug(
      "Authorizing device application client_id: #{client_id}, user_code: #{user_code}"
    )

    case Applications.get_application_device_activation_by_user_code(user_code) do
      %ApplicationDeviceActivation{
        status: :confirmed,
        application: %Application{client_id: ^client_id}
      } = app_device_activation ->
        if device_activation_expired?(app_device_activation) do
          {:error, :expired}
        else
          Applications.update_application_device_activation(app_device_activation, %{
            status: :success
          })
        end

      # The device activation is confirmed, but does not match the given app client_id, so we say it's not found
      %ApplicationDeviceActivation{status: :confirmed} ->
        {:error, :not_found}

      %ApplicationDeviceActivation{} ->
        {:error, :not_confirmed}

      nil ->
        {:error, :not_found}
    end
  end

  @spec generate_access_token(String.t(), String.t(), String.t(), String.t(), String.t()) ::
          {:ok, access_token_details()}
          | {:error,
             :application_not_found
             | :redirect_uri_not_in_allowed
             | :provided_code_does_not_match
             | :invalid_client_secret
             | :invalid_or_expired
             | :scope_not_included
             | any()}
  def generate_access_token(client_id, client_secret, code, redirect_uri, scope) do
    with {:application,
          %Application{
            id: application_id,
            client_secret: app_client_secret,
            redirect_uris: redirect_uris,
            scope: app_scope
          }} <-
           {:application, Applications.get_application_by_client_id(client_id)},
         {:scope_included, true} <- {:scope_included, request_scope_valid?(app_scope, scope)},
         {:redirect_uri, true} <-
           {:redirect_uri, redirect_uri in redirect_uris},
         {:app_token, %ApplicationToken{} = app_token} <-
           {:app_token, Applications.get_application_token_by_authorization_code(code)},
         {:expired, false} <- {:expired, authorization_code_expired?(app_token)},
         {:ok, %ApplicationToken{application_id: application_id_from_token} = app_token} <-
           Applications.update_application_token(app_token, %{
             authorization_code: nil,
             status: :success
           }),
         {:same_app, true} <- {:same_app, application_id === application_id_from_token},
         {:same_client_secret, true} <- {:same_client_secret, app_client_secret == client_secret},
         {:ok, access_token} <-
           Authenticator.generate_access_token(app_token, @app_access_tokens_ttl),
         {:ok, refresh_token} <-
           Authenticator.generate_refresh_token(app_token, @app_refresh_tokens_ttl) do
      {:ok,
       %{
         access_token: access_token,
         expires_in: ttl_to_seconds(@app_access_tokens_ttl),
         refresh_token: refresh_token,
         refresh_token_expires_in: ttl_to_seconds(@app_refresh_tokens_ttl),
         scope: scope,
         token_type: "bearer"
       }}
    else
      {:application, nil} ->
        {:error, :application_not_found}

      {:same_app, false} ->
        {:error, :provided_code_does_not_match}

      {:same_client_secret, _} ->
        {:error, :invalid_client_secret}

      {:redirect_uri, _} ->
        {:error, :redirect_uri_not_in_allowed}

      {:app_token, _} ->
        {:error, :invalid_or_expired}

      {:expired, true} ->
        {:error, :invalid_or_expired}

      {:scope_included, false} ->
        {:error, :scope_not_included}

      {:error, err} ->
        {:error, err}
    end
  end

  def generate_access_token_for_device_flow(client_id, device_code) do
    Logger.debug("Generating access token for application device with",
      client_id: client_id,
      device_code: device_code
    )

    case Applications.get_application_device_activation_by_device_code(client_id, device_code) do
      %ApplicationDeviceActivation{status: :success, scope: scope, user_id: user_id} =
          app_device_activation ->
        if device_activation_expired?(app_device_activation) do
          {:error, :expired}
        else
          %Application{id: app_id} = Applications.get_application_by_client_id(client_id)

          {:ok, %ApplicationToken{} = app_token} =
            Applications.create_application_token(%{
              user_id: user_id,
              application_id: app_id,
              authorization_code: nil,
              scope: scope,
              status: :success
            })

          {:ok, access_token} =
            Authenticator.generate_access_token(app_token, @app_access_tokens_ttl)

          {:ok, refresh_token} =
            Authenticator.generate_refresh_token(app_token, @app_refresh_tokens_ttl)

          {:ok,
           %{
             access_token: access_token,
             expires_in: ttl_to_seconds(@app_access_tokens_ttl),
             refresh_token: refresh_token,
             refresh_token_expires_in: ttl_to_seconds(@app_refresh_tokens_ttl),
             scope: scope,
             token_type: "bearer"
           }}
        end

      %ApplicationDeviceActivation{status: :incorrect_device_code} ->
        {:error, :incorrect_device_code}

      %ApplicationDeviceActivation{status: :access_denied} ->
        {:error, :access_denied}

      %ApplicationDeviceActivation{status: :pending} ->
        {:error, :pending, @device_code_interval}

      nil ->
        {:error, :incorrect_device_code}

      err ->
        Logger.error(inspect(err))
        {:error, :incorrect_device_code}
    end
  end

  @chars "ABCDEFGHIJKLMNOPQRSTUVWXYZ" |> String.split("", trim: true)

  defp string_of_length(length) do
    1..length
    |> Enum.reduce([], fn _i, acc ->
      [Enum.random(@chars) | acc]
    end)
    |> Enum.join("")
  end

  @spec register_device_code(String.t(), String.t() | nil) ::
          {:ok, ApplicationDeviceActivation.t()}
          | {:error, :application_not_found}
          | {:error, :scope_not_included}
          | {:error, Ecto.Changeset.t()}
  def register_device_code(client_id, scope) do
    with {:app, %Application{scope: app_scope} = application} <-
           {:app, Applications.get_application_by_client_id(client_id)},
         {device_code, user_code, verification_uri} <-
           {string_of_length(40), string_of_length(8),
            Routes.page_url(Mobilizon.Web.Endpoint, :auth_device)},
         {:scope_included, true} <- {:scope_included, request_scope_valid?(app_scope, scope)},
         {:ok, %ApplicationDeviceActivation{} = application_device_activation} <-
           Applications.create_application_device_activation(%{
             device_code: device_code,
             user_code: user_code,
             expires_in: @device_code_expires_in,
             application_id: application.id,
             scope: scope
           }) do
      {:ok,
       application_device_activation
       |> Map.from_struct()
       |> Map.take([:device_code, :user_code, :expires_in])
       |> Map.update!(:user_code, &user_code_displayed/1)
       |> Map.merge(%{
         interval: @device_code_interval,
         verification_uri: verification_uri
       })}
    else
      {:error, %Ecto.Changeset{} = err} ->
        {:error, err}

      {:app, nil} ->
        {:error, :application_not_found}

      {:scope_included, false} ->
        {:error, :scope_not_included}
    end
  end

  @spec activate_device(String.t(), User.t()) ::
          {:ok, ApplicationDeviceActivation.t()}
          | {:error, Ecto.Changeset.t()}
          | {:error, :not_found}
          | {:error, :expired}
  def activate_device(user_code, user) do
    case Applications.get_application_device_activation_by_user_code(user_code) do
      %ApplicationDeviceActivation{} = app_device_activation ->
        if device_activation_expired?(app_device_activation) do
          {:error, :expired}
        else
          Applications.update_application_device_activation(app_device_activation, %{
            status: :confirmed,
            user_id: user.id
          })
        end

      _ ->
        {:error, :not_found}
    end
  end

  @spec refresh_tokens(String.t(), String.t(), String.t()) ::
          {:ok, access_token_details()}
          | {:error, :invalid_client_credentials}
          | {:error, :invalid_refresh_token}
          | {:error, any()}
  def refresh_tokens(refresh_token, user_client_id, user_client_secret) do
    with {:resource_from_token,
          {:ok,
           %ApplicationToken{
             application: %Application{client_id: app_client_id, client_secret: app_client_secret},
             scope: scope
           } = app_token,
           _claims}} <- {:resource_from_token, Guardian.resource_from_token(refresh_token)},
         {:valid_client_credentials, true} <-
           {:valid_client_credentials,
            app_client_id == user_client_id and app_client_secret == user_client_secret},
         {:ok, _old, {exchanged_token, _claims}} <-
           Guardian.exchange(refresh_token, "refresh", "access", ttl: @app_access_tokens_ttl),
         {:ok, new_refresh_token} <-
           Authenticator.generate_refresh_token(app_token, @app_refresh_tokens_ttl),
         {:ok, _claims} <- Guardian.revoke(refresh_token) do
      {:ok,
       %{
         access_token: exchanged_token,
         expires_in: ttl_to_seconds(@app_access_tokens_ttl),
         refresh_token: new_refresh_token,
         refresh_token_expires_in: ttl_to_seconds(@app_refresh_tokens_ttl),
         scope: scope,
         token_type: "bearer"
       }}
    else
      {:valid_client_credentials, false} ->
        {:error, :invalid_client_credentials}

      {:resource_from_token, _} ->
        {:error, :invalid_refresh_token}
    end
  end

  defp user_code_displayed(user_code) do
    String.slice(user_code, 0..3) <> "-" <> String.slice(user_code, 4..7)
  end

  def revoke_application_token(%ApplicationToken{} = app_token) do
    Applications.revoke_application_token(app_token)
  end

  @spec ttl_to_seconds({pos_integer(), :second | :minute | :hour | :week}) :: pos_integer()
  defp ttl_to_seconds({value, :second}), do: value
  defp ttl_to_seconds({value, :minute}), do: value * 60
  defp ttl_to_seconds({value, :hour}), do: value * 3600
  defp ttl_to_seconds({value, :week}), do: value * 604_800

  @spec device_activation_expired?(ApplicationDeviceActivation.t()) :: boolean()
  defp device_activation_expired?(%ApplicationDeviceActivation{
         inserted_at: inserted_at,
         expires_in: expires_in
       }) do
    NaiveDateTime.compare(NaiveDateTime.add(inserted_at, expires_in), NaiveDateTime.utc_now()) ==
      :lt
  end

  def prune_old_tokens do
    Applications.prune_old_application_tokens(@authorization_code_lifetime)
  end

  def prune_old_application_device_activations do
    Applications.prune_old_application_device_activations(@application_device_activation_lifetime)
  end

  @spec revoke_token(String.t()) ::
          {:ok, map()}
          | {:error, any(), any(), any()}
          | {:error, :token_not_found}
  def revoke_token(token) do
    case Guardian.resource_from_token(token) do
      {:ok, %ApplicationToken{} = app_token, _claims} ->
        Guardian.revoke(token)
        revoke_application_token(app_token)

      {:error, _err} ->
        {:error, :token_not_found}
    end
  end

  defp authorization_code_expired?(%ApplicationToken{inserted_at: inserted_at}) do
    NaiveDateTime.compare(NaiveDateTime.add(inserted_at, 60), NaiveDateTime.utc_now()) ==
      :lt
  end

  defp request_scope_valid?(app_scope, request_scope) do
    app_scopes = app_scope |> String.split(" ") |> MapSet.new()
    request_scopes = request_scope |> String.split(" ") |> MapSet.new()
    MapSet.subset?(request_scopes, app_scopes) and AppScope.scopes_valid?(request_scope)
  end
end
