defmodule Plausible.Auth.SSO do
  @moduledoc """
  API for SSO.
  """

  import Ecto.Changeset
  import Ecto.Query

  alias Plausible.Auth
  alias Plausible.Auth.SSO
  alias Plausible.Billing.Subscription
  alias Plausible.Repo
  alias Plausible.Teams

  use Plausible.Auth.SSO.Domain.Status

  require Plausible.Billing.Subscription.Status

  @type policy_attr() ::
          {:sso_default_role, Teams.Policy.sso_member_role()}
          | {:sso_session_timeout_minutes, non_neg_integer()}

  @spec get_integration_for(Teams.Team.t()) :: {:ok, SSO.Integration.t()} | {:error, :not_found}
  def get_integration_for(%Teams.Team{} = team) do
    query = integration_query() |> where([i], i.team_id == ^team.id)

    if integration = Repo.one(query) do
      {:ok, integration}
    else
      {:error, :not_found}
    end
  end

  @spec get_integration(String.t()) :: {:ok, SSO.Integration.t()} | {:error, :not_found}
  def get_integration(identifier) when is_binary(identifier) do
    query = integration_query() |> where([i], i.identifier == ^identifier)

    if integration = Repo.one(query) do
      {:ok, integration}
    else
      {:error, :not_found}
    end
  end

  defp integration_query() do
    from(i in SSO.Integration,
      inner_join: t in assoc(i, :team),
      as: :team,
      left_join: d in assoc(i, :sso_domains),
      as: :sso_domains,
      preload: [team: t, sso_domains: d]
    )
  end

  @spec initiate_saml_integration(Teams.Team.t()) :: SSO.Integration.t()
  def initiate_saml_integration(team) do
    changeset = SSO.Integration.init_changeset(team)

    Repo.insert_with_audit!(
      changeset,
      "saml_integration_initiated",
      %{team_id: team.id},
      on_conflict: [set: [updated_at: NaiveDateTime.utc_now(:second)]],
      conflict_target: :team_id,
      returning: true
    )
  end

  @spec update_integration(SSO.Integration.t(), map()) ::
          {:ok, SSO.Integration.t()} | {:error, Ecto.Changeset.t()}
  def update_integration(integration, params) do
    changeset = SSO.Integration.update_changeset(integration, params)

    case Repo.update_with_audit(changeset, "sso_integration_updated", %{
           team_id: integration.team_id
         }) do
      {:ok, integration} -> {:ok, integration}
      {:error, changeset} -> {:error, changeset.changes.config}
    end
  end

  @spec provision_user(SSO.Identity.t()) ::
          {:ok, :standard | :sso | :integration, Teams.Team.t(), Auth.User.t()}
          | {:error, :integration_not_found | :over_limit}
          | {:error, :multiple_memberships | :active_personal_team, Teams.Team.t(), Auth.User.t()}
  def provision_user(identity) do
    case find_user(identity) do
      {:ok, :standard, user, integration, domain} ->
        provision_standard_user(user, identity, integration, domain)

      {:ok, :sso, user, integration, domain} ->
        provision_sso_user(user, identity, integration, domain)

      {:ok, :integration, _, integration, domain} ->
        provision_identity(identity, integration, domain)

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

  @spec deprovision_user!(Auth.User.t()) :: Auth.User.t()
  def deprovision_user!(%{type: :standard} = user), do: user

  def deprovision_user!(user) do
    user = Repo.preload(user, [:sso_integration, :sso_domain])

    :ok = Auth.UserSessions.revoke_all(user)

    user
    |> Ecto.Changeset.change()
    |> Ecto.Changeset.put_change(:type, :standard)
    |> Ecto.Changeset.put_change(:sso_identity_id, nil)
    |> Ecto.Changeset.put_assoc(:sso_integration, nil)
    |> Ecto.Changeset.put_assoc(:sso_domain, nil)
    |> Repo.update_with_audit!("sso_user_deprovioned", %{team_id: user.sso_integration.team_id})
  end

  @spec update_policy(Teams.Team.t(), [policy_attr()]) ::
          {:ok, Teams.Team.t()} | {:error, Ecto.Changeset.t()}
  def update_policy(team, attrs \\ []) do
    params = Map.new(attrs)

    changeset =
      team
      |> Ecto.Changeset.cast(%{policy: params}, [])
      |> Ecto.Changeset.cast_embed(:policy, with: &Teams.Policy.update_changeset/2)

    case Repo.update_with_audit(changeset, "sso_policy_updated", %{team_id: team.id}) do
      {:ok, integration} -> {:ok, integration}
      {:error, changeset} -> {:error, changeset.changes.policy}
    end
  end

  @spec set_force_sso(Teams.Team.t(), Teams.Policy.force_sso_mode()) ::
          {:ok, Teams.Team.t()}
          | {:error,
             :no_integration
             | :no_domain
             | :no_verified_domain
             | :owner_2fa_disabled
             | :no_sso_user}
  def set_force_sso(team, mode) do
    with :ok <- check_force_sso(team, mode) do
      params = %{policy: %{force_sso: mode}}

      team
      |> Ecto.Changeset.cast(params, [])
      |> Ecto.Changeset.cast_embed(:policy,
        with: &Teams.Policy.force_sso_changeset(&1, &2.force_sso)
      )
      |> Repo.update_with_audit("sso_force_mode_changed", %{team_id: team.id})
    end
  end

  @spec check_force_sso(Teams.Team.t(), Teams.Policy.force_sso_mode()) ::
          :ok
          | {:error,
             :no_integration
             | :no_domain
             | :no_verified_domain
             | :owner_2fa_disabled
             | :no_sso_user}
  def check_force_sso(_team, :none), do: :ok

  def check_force_sso(team, :all_but_owners) do
    with :ok <- check_integration_configured(team),
         :ok <- check_sso_user_present(team) do
      check_owners_2fa_enabled(team)
    end
  end

  @spec check_can_remove_integration(SSO.Integration.t()) ::
          :ok | {:error, :force_sso_enabled | :sso_users_present}
  def check_can_remove_integration(integration) do
    team = Repo.preload(integration, :team).team

    cond do
      team.policy.force_sso != :none ->
        {:error, :force_sso_enabled}

      check_sso_user_present(integration) == :ok ->
        {:error, :sso_users_present}

      true ->
        :ok
    end
  end

  @spec remove_integration(SSO.Integration.t(), Keyword.t()) ::
          :ok | {:error, :force_sso_enabled | :sso_users_present}
  def remove_integration(integration, opts \\ []) do
    force_deprovision? = Keyword.get(opts, :force_deprovision?, false)
    check = check_can_remove_integration(integration)

    case {check, force_deprovision?} do
      {:ok, _} ->
        {:ok, :ok} =
          Repo.transaction(fn ->
            integration = Repo.preload(integration, :sso_domains)
            Enum.each(integration.sso_domains, &SSO.Domains.cancel_verification(&1.domain))

            Repo.delete_with_audit!(integration, "sso_integration_removed", %{
              team_id: integration.team_id
            })

            :ok
          end)

        :ok

      {{:error, :sso_users_present}, true} ->
        {:ok, :ok} =
          Repo.transaction(fn ->
            users = Repo.preload(integration, :users).users
            integration = Repo.preload(integration, :sso_domains)
            Enum.each(users, &deprovision_user!/1)
            Enum.each(integration.sso_domains, &SSO.Domains.cancel_verification(&1.domain))

            Repo.delete_with_audit!(integration, "sso_integration_removed", %{
              team_id: integration.team_id
            })

            :ok
          end)

        :ok

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

  @spec check_ready_to_provision(Auth.User.t(), Teams.Team.t()) ::
          :ok | {:error, :not_a_member | :multiple_memberships | :active_personal_team}
  def check_ready_to_provision(%{type: :sso} = _user, _team), do: :ok

  def check_ready_to_provision(user, team) do
    result =
      with :ok <- ensure_team_member(team, user),
           :ok <- ensure_one_membership(user, team) do
        ensure_empty_personal_team(user, team)
      end

    case result do
      :ok -> :ok
      {:error, :integration_not_found} -> {:error, :not_a_member}
      {:error, :multiple_memberships, _, _} -> {:error, :multiple_memberships}
      {:error, :active_personal_team, _, _} -> {:error, :active_personal_team}
    end
  end

  defp check_integration_configured(team) do
    integrations =
      Repo.all(
        from(
          i in SSO.Integration,
          left_join: d in assoc(i, :sso_domains),
          where: i.team_id == ^team.id,
          preload: [sso_domains: d]
        )
      )

    domains = Enum.flat_map(integrations, & &1.sso_domains)
    no_verified_domains? = Enum.all?(domains, &(&1.status != Status.verified()))

    cond do
      integrations == [] -> {:error, :no_integration}
      domains == [] -> {:error, :no_domain}
      no_verified_domains? -> {:error, :no_verified_domain}
      true -> :ok
    end
  end

  defp check_sso_user_present(%Teams.Team{} = team) do
    sso_user_count =
      Repo.aggregate(
        from(
          tm in Teams.Membership,
          inner_join: u in assoc(tm, :user),
          where: tm.team_id == ^team.id,
          where: tm.role != :guest,
          where: u.type == :sso
        ),
        :count
      )

    if sso_user_count > 0 do
      :ok
    else
      {:error, :no_sso_user}
    end
  end

  defp check_sso_user_present(%SSO.Integration{} = integration) do
    sso_user_count =
      Repo.aggregate(
        from(
          i in SSO.Integration,
          inner_join: u in assoc(i, :users),
          inner_join: tm in assoc(u, :team_memberships),
          on: tm.team_id == i.team_id,
          where: i.id == ^integration.id,
          where: tm.role != :guest,
          where: u.type == :sso
        ),
        :count
      )

    if sso_user_count > 0 do
      :ok
    else
      {:error, :no_sso_user}
    end
  end

  defp check_owners_2fa_enabled(team) do
    disabled_2fa_count =
      Repo.aggregate(
        from(
          tm in Teams.Membership,
          inner_join: u in assoc(tm, :user),
          where: tm.team_id == ^team.id,
          where: tm.role == :owner,
          where: u.totp_enabled == false or is_nil(u.totp_secret)
        ),
        :count
      )

    if disabled_2fa_count == 0 do
      :ok
    else
      {:error, :owner_2fa_disabled}
    end
  end

  defp find_user(identity) do
    case find_user_with_fallback(identity) do
      {:ok, type, user, integration, domain} ->
        {:ok, type, Repo.preload(user, [:sso_integration, :sso_domain]), integration, domain}

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

  defp find_user_with_fallback(identity) do
    with {:ok, integration} <- get_integration(identity.integration_id),
         {:error, :not_found} <- find_by_identity(identity, integration) do
      find_by_email(identity.email, integration)
    end
  end

  defp find_by_identity(identity, integration) do
    if user =
         Repo.get_by(Auth.User,
           sso_integration_id: integration.id,
           sso_identity_id: identity.id,
           type: :sso
         ) do
      with {:ok, sso_domain} <- SSO.Domains.lookup(identity.email),
           :ok <- check_domain_integration_match(sso_domain, integration) do
        user = Repo.preload(user, sso_integration: :team)

        {:ok, user.type, user, user.sso_integration, sso_domain}
      end
    else
      {:error, :not_found}
    end
  end

  defp find_by_email(email, integration) do
    with {:ok, sso_domain} <- SSO.Domains.lookup(email),
         :ok <- check_domain_integration_match(sso_domain, integration) do
      case find_in_team_by_email(sso_domain.sso_integration.team, email) do
        {:ok, user} ->
          {:ok, user.type, user, sso_domain.sso_integration, sso_domain}

        {:error, :not_found} ->
          {:ok, :integration, nil, sso_domain.sso_integration, sso_domain}
      end
    end
  end

  defp find_in_team_by_email(team, email) do
    result =
      Repo.one(
        from(
          u in Auth.User,
          inner_join: tm in assoc(u, :team_memberships),
          where: u.email == ^email,
          where: tm.team_id == ^team.id,
          where: tm.role != :guest
        )
      )

    if result do
      {:ok, result}
    else
      {:error, :not_found}
    end
  end

  defp check_domain_integration_match(sso_domain, integration) do
    if sso_domain.sso_integration_id == integration.id do
      :ok
    else
      {:error, :not_found}
    end
  end

  defp provision_sso_user(user, identity, integration, domain) do
    changeset =
      user
      |> change()
      |> put_change(:email, identity.email)
      |> put_change(:name, identity.name)
      |> put_change(:sso_identity_id, identity.id)
      |> put_change(:last_sso_login, NaiveDateTime.utc_now(:second))
      |> put_assoc(:sso_domain, domain)

    with {:ok, user} <-
           Repo.update_with_audit(changeset, "sso_user_provisioned", %{
             team_id: integration.team_id
           }) do
      {:ok, :sso, integration.team, user}
    end
  end

  defp provision_standard_user(user, identity, integration, domain) do
    changeset =
      user
      |> change()
      |> put_change(:type, :sso)
      |> put_change(:name, identity.name)
      |> put_change(:sso_identity_id, identity.id)
      |> put_change(:last_sso_login, NaiveDateTime.utc_now(:second))
      |> put_assoc(:sso_integration, integration)
      |> put_assoc(:sso_domain, domain)

    with :ok <- ensure_team_member(integration.team, user),
         :ok <- ensure_one_membership(user, integration.team),
         :ok <- ensure_empty_personal_team(user, integration.team),
         :ok <- Auth.UserSessions.revoke_all(user),
         {:ok, user} <-
           Repo.update_with_audit(changeset, "sso_user_provisioned", %{
             team_id: integration.team_id
           }) do
      {:ok, :standard, integration.team, user}
    end
  end

  defp provision_identity(identity, integration, domain) do
    random_password =
      64
      |> :crypto.strong_rand_bytes()
      |> Base.encode64(padding: false)

    params = %{
      email: identity.email,
      name: identity.name,
      password: random_password,
      password_confirmation: random_password
    }

    changeset =
      Auth.User.new(params)
      |> put_change(:email_verified, true)
      |> put_change(:type, :sso)
      |> put_change(:sso_identity_id, identity.id)
      |> put_change(:last_sso_login, NaiveDateTime.utc_now(:second))
      |> put_assoc(:sso_integration, integration)
      |> put_assoc(:sso_domain, domain)

    team = integration.team
    role = integration.team.policy.sso_default_role
    now = NaiveDateTime.utc_now(:second)

    result =
      Repo.transaction(fn ->
        with {:ok, user} <-
               Repo.insert_with_audit(changeset, "sso_user_provisioned", %{team_id: team.id}),
             :ok <- Teams.Invitations.check_team_member_limit(team, role, user.email),
             {:ok, team_membership} <-
               Teams.Invitations.create_team_membership(team, role, user, now) do
          if team_membership.role != :guest do
            {:identity, team, user}
          else
            Repo.rollback(:integration_not_found)
          end
        else
          {:error, %{errors: [email: {_, attrs}]}} ->
            true = {:constraint, :unique} in attrs
            Repo.rollback(:integration_not_found)

          {:error, {:over_limit, _}} ->
            Repo.rollback(:over_limit)
        end
      end)

    case result do
      {:ok, {type, team, user}} ->
        {:ok, type, team, user}

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

  defp ensure_team_member(team, user) do
    case Teams.Memberships.team_role(team, user) do
      {:ok, role} when role != :guest ->
        :ok

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

  defp ensure_one_membership(user, team) do
    if Teams.Users.team_member?(user, except: [team.id], only_setup?: true) do
      {:error, :multiple_memberships, team, user}
    else
      :ok
    end
  end

  defp ensure_empty_personal_team(user, team) do
    case Teams.get_by_owner(user, only_not_setup?: true) do
      {:ok, personal_team} ->
        subscription = Teams.Billing.get_subscription(personal_team)

        no_subscription? =
          is_nil(subscription) or subscription.status == Subscription.Status.deleted()

        zero_sites? = Teams.owned_sites_count(personal_team) == 0

        if no_subscription? and zero_sites? do
          :ok
        else
          {:error, :active_personal_team, team, user}
        end

      {:error, :no_team} ->
        :ok
    end
  end
end
