defmodule Mv.Authorization.Actor do
  @moduledoc """
  Helper functions for ensuring User actors have required data loaded
  and for querying actor capabilities (e.g. admin, permission set).

  ## Actor Invariant

  Authorization policies (especially HasPermission) require that the User actor
  has their `:role` relationship loaded. This module provides helpers to
  ensure this invariant is maintained across all entry points:

  - LiveView on_mount hooks
  - Plug pipelines
  - Background jobs
  - Tests

  ## Scope

  This module ONLY handles `Mv.Accounts.User` resources. Other resources with
  a `:role` field are ignored (returned as-is). This prevents accidental
  authorization bypasses and keeps the logic focused.

  ## Usage

      # In LiveView on_mount
      def ensure_user_role_loaded(_name, socket) do
        user = Actor.ensure_loaded(socket.assigns[:current_user])
        assign(socket, :current_user, user)
      end

      # Check if actor is admin (policy checks, validations)
      if Actor.admin?(actor), do: ...

      # Get permission set name (string or nil)
      ps_name = Actor.permission_set_name(actor)

  ## Security Note

  `ensure_loaded/1` loads the role with `authorize?: false` to avoid circular
  dependency (actor needs role loaded to be authorized, but loading role requires
  authorization). This is safe because:

  - The actor (User) is loading their OWN role (user.role relationship)
  - This load is needed FOR authorization checks to work
  - The role itself contains no sensitive data (just permission_set reference)
  - The actor is already authenticated (passed auth boundary)

  Alternative would be to denormalize permission_set_name on User, but that
  adds complexity and potential for inconsistency.
  """

  require Logger

  alias Mv.Helpers.SystemActor

  @doc """
  Ensures the actor (User) has their `:role` relationship loaded.

  - If actor is nil, returns nil
  - If role is already loaded, returns actor as-is
  - If role is %Ash.NotLoaded{}, loads it and returns updated actor
  - If actor is not a User, returns as-is (no-op)

  ## Examples

      iex> Actor.ensure_loaded(nil)
      nil

      iex> Actor.ensure_loaded(%User{role: %Role{}})
      %User{role: %Role{}}

      iex> Actor.ensure_loaded(%User{role: %Ash.NotLoaded{}})
      %User{role: %Role{}}  # role loaded
  """
  def ensure_loaded(nil), do: nil

  # Only handle Mv.Accounts.User - clear intention, no accidental other resources
  def ensure_loaded(%Mv.Accounts.User{role: %Ash.NotLoaded{}} = user) do
    load_role(user)
  end

  def ensure_loaded(actor), do: actor

  defp load_role(actor) do
    # SECURITY: We skip authorization here because this is a bootstrap scenario:
    # - The actor is loading their OWN role (actor.role relationship)
    # - This load is needed FOR authorization checks to work (circular dependency)
    # - The role itself contains no sensitive data (just permission_set reference)
    # - The actor is already authenticated (passed auth boundary)
    # Alternative would be to denormalize permission_set_name on User.
    case Ash.load(actor, :role, domain: Mv.Accounts, authorize?: false) do
      {:ok, loaded_actor} ->
        loaded_actor

      {:error, error} ->
        # Log error but don't crash - fail-closed for authorization
        Logger.warning(
          "Failed to load actor role: #{inspect(error)}. " <>
            "Authorization may fail if role is required."
        )

        actor
    end
  end

  @doc """
  Returns the actor's permission set name (string or atom) from their role, or nil.

  Ensures role is loaded (including when role is nil). Supports both atom and
  string keys for session/socket assigns. Use for capability checks consistent
  with `ActorIsAdmin` and `HasPermission`.
  """
  @spec permission_set_name(Mv.Accounts.User.t() | map() | nil) :: String.t() | atom() | nil
  def permission_set_name(nil), do: nil

  def permission_set_name(actor) do
    actor = actor |> ensure_loaded() |> maybe_load_role()

    get_in(actor, [Access.key(:role), Access.key(:permission_set_name)]) ||
      get_in(actor, [Access.key("role"), Access.key("permission_set_name")])
  end

  @doc """
  Returns true if the actor is the system user or has the admin permission set.

  Use for validations and policy checks that require admin capability (e.g.
  changing a linked member's email). Consistent with `ActorIsAdmin` policy check.
  """
  @spec admin?(Mv.Accounts.User.t() | map() | nil) :: boolean()
  def admin?(nil), do: false

  def admin?(actor) do
    SystemActor.system_user?(actor) or permission_set_name(actor) in ["admin", :admin]
  end

  # Load role only when it is nil (e.g. actor from session without role). ensure_loaded/1
  # already handles %Ash.NotLoaded{}, so we do not double-load in the normal Ash path.
  defp maybe_load_role(%Mv.Accounts.User{role: nil} = user) do
    case Ash.load(user, :role, domain: Mv.Accounts, authorize?: false) do
      {:ok, loaded} -> loaded
      _ -> user
    end
  end

  defp maybe_load_role(actor), do: actor
end
