defmodule Mv.OidcRoleSync do
  @moduledoc """
  Syncs user role from OIDC user_info (e.g. groups claim → Admin role).

  Used after OIDC registration (register_with_oidc) and on sign-in so that
  users in the configured admin group get the Admin role; others get Mitglied.
  Configure via OIDC_ADMIN_GROUP_NAME and OIDC_GROUPS_CLAIM (see OidcRoleSyncConfig).

  Groups are read from user_info (ID token claims) first; if missing or empty,
  the access_token from oauth_tokens is decoded as JWT and the groups claim is
  read from there (e.g. Rauthy puts groups in the access token when scope
  includes "groups").

  ## JWT access token (security)

  The access_token payload is read without signature verification (peek only).
  We rely on the fact that `oauth_tokens` is only ever passed from the
  verified OIDC callback (Assent/AshAuthentication after provider token
  exchange). If callers passed untrusted or tampered tokens, group claims
  could be forged and a user could be assigned the Admin role. Therefore:
  do not call this module with user-supplied tokens; it is intended only
  for the internal flow from the OIDC callback.
  """
  alias Mv.Accounts.User
  alias Mv.Authorization.Role
  alias Mv.OidcRoleSyncConfig

  @doc """
  Applies Admin or Mitglied role to the user based on OIDC groups claim.

  - If OIDC_ADMIN_GROUP_NAME is not configured: no-op, returns :ok without changing the user.
  - If groups (from user_info or access_token) contain the configured admin group: assigns Admin role.
  - Otherwise: assigns Mitglied role (downgrade if user was Admin).

  user_info is a map (e.g. from ID token claims); oauth_tokens is optional and may
  contain "access_token" (JWT) from which the groups claim is read when not in user_info.
  """
  @spec apply_admin_role_from_user_info(User.t(), map(), map() | nil) :: :ok
  def apply_admin_role_from_user_info(user, user_info, oauth_tokens \\ nil)
      when is_map(user_info) do
    admin_group = OidcRoleSyncConfig.oidc_admin_group_name()

    if is_nil(admin_group) or admin_group == "" do
      :ok
    else
      claim = OidcRoleSyncConfig.oidc_groups_claim()
      groups = groups_from_user_info(user_info, claim)

      groups =
        if Enum.empty?(groups), do: groups_from_access_token(oauth_tokens, claim), else: groups

      target_role = if admin_group in groups, do: :admin, else: :mitglied
      set_user_role(user, target_role)
    end
  end

  defp groups_from_user_info(user_info, claim) do
    value = user_info[claim] || user_info[String.to_existing_atom(claim)]
    normalize_groups(value)
  rescue
    ArgumentError -> normalize_groups(user_info[claim])
  end

  defp groups_from_access_token(nil, _claim), do: []
  defp groups_from_access_token(oauth_tokens, _claim) when not is_map(oauth_tokens), do: []

  defp groups_from_access_token(oauth_tokens, claim) do
    access_token = oauth_tokens["access_token"] || oauth_tokens[:access_token]

    if is_binary(access_token) do
      case peek_jwt_claims(access_token) do
        {:ok, claims} ->
          value = claims[claim] || safe_get_atom(claims, claim)
          normalize_groups(value)

        _ ->
          []
      end
    else
      []
    end
  end

  defp safe_get_atom(map, key) when is_binary(key) do
    try do
      Map.get(map, String.to_existing_atom(key))
    rescue
      ArgumentError -> nil
    end
  end

  defp safe_get_atom(_map, _key), do: nil

  defp peek_jwt_claims(token) do
    parts = String.split(token, ".")

    if length(parts) == 3 do
      [_h, payload_b64, _sig] = parts

      case Base.url_decode64(payload_b64, padding: false) do
        {:ok, payload} -> Jason.decode(payload)
        _ -> :error
      end
    else
      :error
    end
  end

  defp normalize_groups(nil), do: []
  defp normalize_groups(list) when is_list(list), do: Enum.map(list, &to_string/1)
  defp normalize_groups(single) when is_binary(single), do: [single]
  defp normalize_groups(_), do: []

  defp set_user_role(user, :admin) do
    case Role.get_admin_role() do
      {:ok, %Role{} = role} ->
        do_set_role(user, role)

      _ ->
        :ok
    end
  end

  defp set_user_role(user, :mitglied) do
    case Role.get_mitglied_role() do
      {:ok, %Role{} = role} ->
        do_set_role(user, role)

      _ ->
        :ok
    end
  end

  defp do_set_role(user, role) do
    if user.role_id == role.id do
      :ok
    else
      user
      |> Ash.Changeset.for_update(:set_role_from_oidc_sync, %{role_id: role.id})
      |> Ash.Changeset.set_context(%{private: %{oidc_role_sync: true}})
      |> Ash.update(domain: Mv.Accounts, context: %{private: %{oidc_role_sync: true}})
      |> case do
        {:ok, _} -> :ok
        {:error, _} -> :ok
      end
    end
  end
end
