defmodule Glific.Users.User do
  @moduledoc false
  use Ecto.Schema
  use Pow.Ecto.Schema, user_id_field: :phone

  alias __MODULE__

  alias Glific.{
    AccessControl.Role,
    Contacts.Contact,
    Enums.UserRoles,
    Groups.Group,
    Partners.Organization,
    Settings.Language
  }

  alias Ecto.Changeset
  import Pow.Ecto.Schema.Changeset, only: [password_changeset: 3, current_password_changeset: 3]
  import Ecto.Changeset

  @type t() :: %__MODULE__{
          __meta__: Ecto.Schema.Metadata.t(),
          id: non_neg_integer | nil,
          name: String.t() | nil,
          phone: String.t() | nil,
          email: String.t() | nil,
          password_hash: String.t() | nil,
          fingerprint: String.t() | nil,
          contact_id: non_neg_integer | nil,
          contact: Contact.t() | Ecto.Association.NotLoaded.t() | nil,
          password: String.t() | nil,
          current_password: String.t() | nil,
          password_hash: String.t() | nil,
          language_id: non_neg_integer | nil,
          language: Language.t() | Ecto.Association.NotLoaded.t() | nil,
          organization_id: non_neg_integer | nil,
          organization: Organization.t() | Ecto.Association.NotLoaded.t() | nil,
          roles: [String.t() | atom()] | nil,
          groups: list() | Ecto.Association.NotLoaded.t() | nil,
          is_restricted: boolean(),
          inserted_at: :utc_datetime | nil,
          updated_at: :utc_datetime | nil,
          last_login_at: :utc_datetime | nil,
          last_login_from: String.t() | nil,
          upload_contacts: boolean() | false,
          consent_for_updates: boolean(),
          confirmed_at: :utc_datetime | nil
        }

  @required_fields [:phone, :name, :password, :contact_id, :organization_id]
  @optional_fields [
    :name,
    :roles,
    :is_restricted,
    :last_login_from,
    :last_login_at,
    :language_id,
    :upload_contacts,
    :confirmed_at,
    :email,
    :consent_for_updates
  ]

  @password_opts [
    length: [min: 10, max: 30, messages: [too_short: "Password is too short!"]],
    character_set: [
      # at least one lower case letter
      lower_case: 1,
      # at least one upper case letter
      upper_case: 1,
      # at least one number
      numbers: 1,
      # at least one special character
      special: 1
    ]
  ]

  schema "users" do
    field(:name, :string)
    field(:email, :string)
    field(:roles, {:array, UserRoles}, default: [:none])
    field(:consent_for_updates, :boolean, default: false)

    # is this user restricted to contacts only in groups that they are part of
    field(:is_restricted, :boolean, default: false)

    # we are lazy, so we use the fingerprint generated by pow as out unique token
    # to identify the same user from different browsers and/or machines
    field(:fingerprint, :string, virtual: true)

    field(:upload_contacts, :boolean, default: false)

    field(:last_login_from, :string, default: nil)
    field(:last_login_at, :utc_datetime)
    field(:confirmed_at, :utc_datetime)

    belongs_to(:contact, Contact)
    belongs_to(:language, Language)
    belongs_to(:organization, Organization)

    pow_user_fields()

    many_to_many(:groups, Group, join_through: "users_groups", on_replace: :delete)
    many_to_many(:access_roles, Role, join_through: "user_roles", on_replace: :delete)

    timestamps()
  end

  @doc """
  Overriding the changeset for PoW and switch phone and email. At some later point, we will
  send an SMS message to the user with a new code to change their password
  """
  @spec changeset(User.t(), map()) :: Ecto.Changeset.t()
  def changeset(user_or_changeset, attrs) do
    user_or_changeset
    |> Changeset.cast(attrs, @required_fields ++ @optional_fields)
    |> Changeset.validate_required(@required_fields)
    |> Changeset.validate_format(:email, ~r/^[^\s]+@[^\s]+$/)
    |> glific_phone_field_changeset(attrs, @pow_config)
    |> current_password_changeset(attrs, @pow_config)
    |> password_changeset(attrs, @pow_config)
    |> PasswordValidator.validate(:password, @password_opts)
    |> Changeset.unique_constraint(:contact_id)
  end

  @doc """
  Simple changeset for phone. We will add phone validation over a period of time
  """
  @spec glific_phone_field_changeset(Ecto.Schema.t() | Changeset.t(), map(), Pow.Config.t()) ::
          Changeset.t()
  def glific_phone_field_changeset(user_or_changeset, params, _config) do
    user_or_changeset
    |> Changeset.cast(params, [:phone])
    |> Changeset.update_change(:phone, &maybe_normalize_user_id_field_value/1)
    |> Changeset.validate_required([:phone])
    |> Changeset.unique_constraint([:phone, :organization_id])
  end

  @doc """
  Simple changeset for update name, roles and is_restricted
  """
  @spec update_fields_changeset(Ecto.Schema.t() | Changeset.t(), map()) ::
          Changeset.t()
  def update_fields_changeset(user_or_changeset, params) do
    user_or_changeset
    |> Changeset.cast(params, [
      :name,
      :roles,
      :password,
      :is_restricted,
      :last_login_at,
      :last_login_from,
      :language_id,
      :email
    ])
    |> Changeset.validate_required([:name, :roles])
    |> Changeset.validate_format(:email, ~r/^[^\s]+@[^\s]+$/)
    |> password_changeset(params, @pow_config)
    |> Changeset.unique_constraint(:contact_id)
  end

  defp maybe_normalize_user_id_field_value(value) when is_binary(value),
    do: Pow.Ecto.Schema.normalize_user_id_field_value(value)

  @doc false
  @spec registration_changeset(
          Glific.Users.User.t(),
          %{optional(:__struct__) => none, optional(atom | binary) => any},
          keyword
        ) :: Ecto.Changeset.t()
  def registration_changeset(user, attrs, opts \\ []) do
    user
    |> changeset(attrs)
    |> validate_password(opts)
  end

  defp validate_password(changeset, opts) do
    changeset
    |> validate_required([:password])
    |> PasswordValidator.validate(:password, @password_opts)
    |> maybe_hash_password(opts)
  end

  defp maybe_hash_password(changeset, opts) do
    hash_password? = Keyword.get(opts, :hash_password, true)
    password = get_change(changeset, :password)

    if hash_password? && password && changeset.valid? do
      changeset
      |> put_change(:password_hash, Pbkdf2.hash_pwd_salt(password, rounds: 100_000))
      |> delete_change(:password)
    else
      changeset
    end
  end

  @doc """
  A user changeset for changing the email.

  It requires the email to change otherwise an error is added.
  """
  @spec email_changeset(
          {map, map}
          | %{
              :__struct__ => atom | %{:__changeset__ => map, optional(any) => any},
              optional(atom) => any
            },
          :invalid | %{optional(:__struct__) => none, optional(atom | binary) => any}
        ) :: Ecto.Changeset.t()
  def email_changeset(user, attrs) do
    user
    |> cast(attrs, [:phone])
    |> case do
      %{changes: %{phone: _}} = changeset -> changeset
      %{} = changeset -> add_error(changeset, :phone, "did not change")
    end
  end

  @doc """
  Confirms the account by setting `confirmed_at`.
  """
  @spec confirm_changeset(
          {map, map}
          | %{
              :__struct__ => atom | %{:__changeset__ => any, optional(any) => any},
              optional(atom) => any
            }
        ) :: Ecto.Changeset.t()
  def confirm_changeset(user) do
    now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second)
    change(user, confirmed_at: now)
  end

  @doc """
  Verifies the password.

  If there is no user or the user doesn't have a password, we call
  `Pbkdf2.no_user_verify/0` to avoid timing attacks.
  """
  @spec valid_password?(any(), any()) :: boolean()
  def valid_password?(%Glific.Users.User{password_hash: hashed_password}, password)
      when is_binary(hashed_password) and byte_size(password) > 0 do
    Pbkdf2.verify_pass(password, hashed_password)
  end

  def valid_password?(_, _) do
    Pbkdf2.no_user_verify()
    false
  end

  @doc """
  Validates the current password otherwise adds an error to the changeset.
  """
  @spec validate_current_password(atom | %{:data => any, optional(any) => any}, any) ::
          atom | %{:data => any, optional(any) => any}
  def validate_current_password(changeset, password) do
    if valid_password?(changeset.data, password) do
      changeset
    else
      add_error(changeset, :current_password, "is not valid")
    end
  end

  @doc """
  A user changeset for changing the password.

  ## Options

    * `:hash_password` - Hashes the password so it can be stored securely
      in the database and ensures the password field is cleared to prevent
      leaks in the logs. If password hashing is not needed and clearing the
      password field is not desired (like when using this changeset for
      validations on a LiveView form), this option can be set to `false`.
      Defaults to `true`.
  """
  @spec password_changeset_v2(
          {map, map}
          | %{
              :__struct__ => atom | %{:__changeset__ => map, optional(any) => any},
              optional(atom) => any
            },
          :invalid | %{optional(:__struct__) => none, optional(atom | binary) => any},
          keyword
        ) :: Ecto.Changeset.t()
  def password_changeset_v2(user, attrs, opts \\ []) do
    user
    |> cast(attrs, [:password])
    |> validate_confirmation(:password, message: "does not match password")
    |> validate_password(opts)
  end
end
