defmodule Mv.Accounts.User do
  @moduledoc """
  The ressource for keeping user-specific data related to the login process. It is used by AshAuthentication to handle the Authentication strategies like SSO.
  """
  use Ash.Resource,
    domain: Mv.Accounts,
    data_layer: AshPostgres.DataLayer,
    extensions: [AshAuthentication],
    authorizers: [Ash.Policy.Authorizer]

  require Ash.Query
  import Ash.Expr

  postgres do
    table "users"
    repo Mv.Repo

    references do
      # When a member is deleted, set the user's member_id to NULL
      # This allows users to continue existing even if their linked member is removed
      reference :member, on_delete: :nilify

      # When a role is deleted, prevent deletion if users are assigned to it
      # This protects critical roles from accidental deletion
      reference :role, on_delete: :restrict
    end
  end

  @doc """
  AshAuthentication specific: Defines the strategies we want to use for authentication.
  Currently password and SSO with Rauthy as OIDC provider
  """
  authentication do
    session_identifier Application.compile_env!(:mv, :session_identifier)

    tokens do
      enabled? true
      token_resource Mv.Accounts.Token

      require_token_presence_for_authentication? Application.compile_env!(
                                                   :mv,
                                                   :require_token_presence_for_authentication
                                                 )

      store_all_tokens? true

      # signing_algorithm "EdDSA" -> https://git.local-it.org/local-it/mitgliederverwaltung/issues/87

      signing_secret fn _, _ ->
        {:ok, Application.get_env(:mv, :token_signing_secret)}
      end
    end

    strategies do
      oidc :rauthy do
        client_id Mv.Secrets
        base_url Mv.Secrets
        redirect_uri Mv.Secrets
        client_secret Mv.Secrets
        auth_method :client_secret_jwt
        code_verifier true

        # Request email and profile scopes from OIDC provider (required for Authentik, Keycloak, etc.)
        authorization_params scope: "openid email profile"

        # id_token_signed_response_alg "EdDSA" #-> https://git.local-it.org/local-it/mitgliederverwaltung/issues/87
      end

      password :password do
        identity_field :email
        hash_provider AshAuthentication.BcryptProvider
        confirmation_required? false

        resettable do
          sender Mv.Accounts.User.Senders.SendPasswordResetEmail
        end
      end
    end
  end

  actions do
    # Default actions for framework/tooling integration:
    # - :read   -> Standard read used across the app and by admin tooling.
    # - :destroy-> Standard delete used by admin tooling and maintenance tasks.
    #
    # NOTE: :create is INTENTIONALLY excluded from defaults!
    #       Using a default :create would bypass email-synchronization logic.
    #       Always use one of these explicit create actions instead:
    #       - :create_user (for manual user creation with optional member link)
    #       - :register_with_password (for password-based registration)
    #       - :register_with_rauthy (for OIDC-based registration)
    defaults [:read]

    destroy :destroy do
      primary? true
      # Required because custom validation (system actor protection) cannot run atomically
      require_atomic? false
    end

    # Primary generic update action:
    # - Selected by AshAdmin's generated "Edit" UI and generic AshPhoenix
    #   helpers that assume a default update action.
    # - Intended for simple attribute updates (e.g., :email) and scenarios
    #   that do NOT need to manage the :member relationship.
    # - For linking/unlinking a member (and the related validations), prefer
    #   the specialized :update_user action below.
    update :update do
      primary? true
      accept [:email]

      # Required because custom validation functions (email validation, member relationship validation)
      # cannot be executed atomically. These validations need to query the database and perform
      # complex checks that are not supported in atomic operations.
      require_atomic? false

      # Sync email changes to linked member (User → Member)
      # Only runs when email is being changed
      change Mv.EmailSync.Changes.SyncUserEmailToMember do
        where [changing(:email)]
      end

      change Mv.Vereinfacht.Changes.SyncLinkedMemberAfterUserChange
    end

    create :create_user do
      description "Creates a new user with optional member relationship. The member relationship is managed through the :member argument."
      # Only accept email directly - member_id is NOT in accept list
      # This prevents direct foreign key manipulation, forcing use of manage_relationship
      accept [:email]
      # Allow member to be passed as argument for relationship management
      argument :member, :map, allow_nil?: true
      upsert? true

      # Note: Default role is automatically assigned via attribute default (see attributes block)

      # Manage the member relationship during user creation
      change manage_relationship(:member, :member,
               # Look up existing member and relate to it
               on_lookup: :relate,
               # Error if member doesn't exist in database
               on_no_match: :error,
               # If member already linked to this user, ignore (shouldn't happen in create)
               on_match: :ignore,
               # If no member provided, that's fine (optional relationship)
               on_missing: :ignore
             )

      # Sync user email to member when linking (User → Member)
      change Mv.EmailSync.Changes.SyncUserEmailToMember

      change Mv.Vereinfacht.Changes.SyncLinkedMemberAfterUserChange
    end

    update :update_user do
      description "Updates a user and manages the optional member relationship. To change an existing member link, first remove it (set member to nil), then add the new one."

      # Accept email and role_id (role_id only used by admins; policy restricts update_user to admins).
      # member_id is NOT in accept list - use argument :member for relationship management.
      accept [:email, :role_id]
      # Allow member to be passed as argument for relationship management
      argument :member, :map, allow_nil?: true

      # Required because custom validation functions (email validation, member relationship validation)
      # cannot be executed atomically. These validations need to query the database and perform
      # complex checks that are not supported in atomic operations.
      require_atomic? false

      # Manage the member relationship during user update
      change manage_relationship(:member, :member,
               # Look up existing member and relate to it
               on_lookup: :relate,
               # Error if member doesn't exist in database
               on_no_match: :error,
               # If same member provided, that's fine (allows updates with same member)
               on_match: :ignore,
               # If no member provided, remove existing relationship (allows member removal)
               on_missing: :unrelate
             )

      # Sync email changes and handle linking (User → Member)
      # Runs when email OR member relationship changes
      change Mv.EmailSync.Changes.SyncUserEmailToMember do
        where any([changing(:email), changing(:member)])
      end

      change Mv.Vereinfacht.Changes.SyncLinkedMemberAfterUserChange
    end

    # Internal update used only by SystemActor/bootstrap and tests to assign role to system user.
    # Not protected by system-user validation so bootstrap can run.
    update :update_internal do
      accept []
      require_atomic? false
    end

    # Internal: set role from OIDC group sync (Mv.OidcRoleSync). Bypass policy when context.private.oidc_role_sync.
    # Same "at least one admin" validation as update_user (see validations where action_is).
    update :set_role_from_oidc_sync do
      accept [:role_id]
      require_atomic? false
    end

    # Admin action for direct password changes in admin panel
    # Uses the official Ash Authentication HashPasswordChange with correct context
    update :admin_set_password do
      accept [:email]
      argument :password, :string, allow_nil?: false, sensitive?: true
      require_atomic? false

      # Set the strategy context that HashPasswordChange expects
      change set_context(%{strategy_name: :password})

      # Use the official Ash Authentication password change
      change AshAuthentication.Strategy.Password.HashPasswordChange

      # Sync email changes to linked member when email is changed (e.g. form changes both)
      change Mv.EmailSync.Changes.SyncUserEmailToMember do
        where [changing(:email)]
      end

      change Mv.Vereinfacht.Changes.SyncLinkedMemberAfterUserChange
    end

    # Action to link an OIDC account to an existing password-only user
    # This is called after the user has verified their password
    update :link_oidc_id do
      description "Links an OIDC ID to an existing user after password verification"
      accept []
      argument :oidc_id, :string, allow_nil?: false
      argument :oidc_user_info, :map, allow_nil?: false
      require_atomic? false

      change fn changeset, _ctx ->
        oidc_id = Ash.Changeset.get_argument(changeset, :oidc_id)
        oidc_user_info = Ash.Changeset.get_argument(changeset, :oidc_user_info)

        # Get the new email from OIDC user_info
        # Support both "email" (standard OIDC) and "preferred_username" (Rauthy)
        new_email =
          Map.get(oidc_user_info, "email") || Map.get(oidc_user_info, "preferred_username")

        changeset
        |> Ash.Changeset.change_attribute(:oidc_id, oidc_id)
        # Update email if it differs from OIDC provider
        # change_attribute/3 already checks if value matches existing value
        |> then(fn cs ->
          if new_email do
            Ash.Changeset.change_attribute(cs, :email, new_email)
          else
            cs
          end
        end)
      end

      # Sync email changes to member if email was updated
      change Mv.EmailSync.Changes.SyncUserEmailToMember do
        where [changing(:email)]
      end

      change Mv.Vereinfacht.Changes.SyncLinkedMemberAfterUserChange
    end

    read :get_by_subject do
      description "Get a user by the subject claim in a JWT"
      argument :subject, :string, allow_nil?: false
      get? true
      prepare AshAuthentication.Preparations.FilterBySubject
    end

    read :sign_in_with_rauthy do
      # Single record expected; required for AshAuthentication OAuth2 strategy (returns list of 0 or 1).
      get? true
      argument :user_info, :map, allow_nil?: false
      argument :oauth_tokens, :map, allow_nil?: false
      prepare AshAuthentication.Strategy.OAuth2.SignInPreparation

      # SECURITY: Filter by oidc_id, NOT by email!
      # This ensures that OIDC sign-in only works for users who have already
      # linked their account via OIDC. Password-only users (oidc_id = nil)
      # cannot be accessed via OIDC login without password verification.
      filter expr(oidc_id == get_path(^arg(:user_info), [:sub]))

      # Sync role from OIDC groups after sign-in (e.g. admin group → Admin role)
      # get? true can return nil, a single %User{}, or a list; normalize to list for Enum.each
      prepare Ash.Resource.Preparation.Builtins.after_action(fn query, result, _context ->
                user_info = Ash.Query.get_argument(query, :user_info) || %{}
                oauth_tokens = Ash.Query.get_argument(query, :oauth_tokens) || %{}

                users =
                  case result do
                    nil -> []
                    u when is_struct(u, User) -> [u]
                    list when is_list(list) -> list
                    _ -> []
                  end

                Enum.each(users, fn user ->
                  Mv.OidcRoleSync.apply_admin_role_from_user_info(user, user_info, oauth_tokens)
                end)

                {:ok, result}
              end)
    end

    create :register_with_rauthy do
      argument :user_info, :map, allow_nil?: false
      argument :oauth_tokens, :map, allow_nil?: false
      upsert? true
      # Upsert based on oidc_id (primary match for existing OIDC users)
      upsert_identity :unique_oidc_id
      # On upsert, only update email - preserve existing role_id
      upsert_fields [:email]

      validate &__MODULE__.validate_oidc_id_present/2

      change AshAuthentication.GenerateTokenChange

      change fn changeset, _ctx ->
        user_info = Ash.Changeset.get_argument(changeset, :user_info)

        # Support both "email" (standard OIDC like Authentik, Keycloak) and "preferred_username" (Rauthy)
        email = user_info["email"] || user_info["preferred_username"]

        changeset
        |> Ash.Changeset.change_attribute(:email, email)
        |> Ash.Changeset.change_attribute(:oidc_id, user_info["sub"] || user_info["id"])
      end

      # Check for email collisions with existing accounts
      # This validation must run AFTER email and oidc_id are set above
      # - Raises PasswordVerificationRequired for password-protected OR passwordless users
      # - The LinkOidcAccountLive will auto-link passwordless users without password prompt
      validate Mv.Accounts.User.Validations.OidcEmailCollision

      # Note: Default role is automatically assigned via attribute default (see attributes block)
      # upsert_fields [:email] ensures existing users' roles are preserved during upserts

      # Sync user email to member when linking (User → Member)
      change Mv.EmailSync.Changes.SyncUserEmailToMember

      change Mv.Vereinfacht.Changes.SyncLinkedMemberAfterUserChange

      # Sync role from OIDC groups (e.g. admin group → Admin role) after user is created/updated
      change fn changeset, _ctx ->
        user_info = Ash.Changeset.get_argument(changeset, :user_info)
        oauth_tokens = Ash.Changeset.get_argument(changeset, :oauth_tokens) || %{}

        Ash.Changeset.after_action(changeset, fn _cs, record ->
          Mv.OidcRoleSync.apply_admin_role_from_user_info(record, user_info, oauth_tokens)
          # Return original record so __metadata__.token (from GenerateTokenChange) is preserved
          {:ok, record}
        end)
      end
    end
  end

  # Authorization Policies
  # Order matters: Most specific policies first, then general permission check
  policies do
    # AshAuthentication bypass (registration/login without actor)
    bypass AshAuthentication.Checks.AshAuthenticationInteraction do
      description "Allow AshAuthentication internal operations (registration, login)"
      authorize_if always()
    end

    # READ bypass for list queries (scope :own via expr)
    bypass action_type(:read) do
      description "Users can always read their own account"
      authorize_if expr(id == ^actor(:id))
    end

    # update_user allows :member argument (link/unlink). Only admins may use it to prevent
    # privilege escalation (own_data could otherwise link to any member and get :linked scope).
    policy action(:update_user) do
      description "Only admins can update user with member link/unlink"
      forbid_unless Mv.Authorization.Checks.ActorIsAdmin
      authorize_if Mv.Authorization.Checks.ActorIsAdmin
    end

    # set_role_from_oidc_sync: internal only (called from Mv.OidcRoleSync on registration/sign-in).
    # Not exposed in code_interface; only allowed when context.private.oidc_role_sync is set.
    bypass action(:set_role_from_oidc_sync) do
      description "Internal: OIDC role sync (server-side only)"
      authorize_if Mv.Authorization.Checks.OidcRoleSyncContext
    end

    # UPDATE/DESTROY via HasPermission (evaluates PermissionSets scope)
    policy action_type([:read, :create, :update, :destroy]) do
      description "Check permissions from user's role and permission set"
      authorize_if Mv.Authorization.Checks.HasPermission
    end

    # Default: Ash implicitly forbids if no policy authorizes (fail-closed)
  end

  # Global validations - applied to all relevant actions
  validations do
    # Password strength policy: minimum 8 characters for all password-related actions
    validate string_length(:password, min: 8),
      where: [action_is([:register_with_password, :admin_set_password])],
      message: "must have length of at least 8"

    # Email uniqueness check for all actions that change the email attribute
    # Validates that user email is not already used by another (unlinked) member
    validate Mv.Accounts.User.Validations.EmailNotUsedByOtherMember

    # Email validation with EctoCommons.EmailValidator (same as Member)
    # This ensures consistency between User and Member email validation
    validate fn changeset, _ ->
      # Get email from attribute (Ash.CiString) and convert to string
      email = Ash.Changeset.get_attribute(changeset, :email)
      email_string = if email, do: to_string(email), else: nil

      # Only validate if email is present
      if email_string do
        changeset2 =
          {%{}, %{email: :string}}
          |> Ecto.Changeset.cast(%{email: email_string}, [:email])
          |> EctoCommons.EmailValidator.validate_email(:email,
            checks: Mv.Constants.email_validator_checks()
          )

        if changeset2.valid? do
          :ok
        else
          {:error, field: :email, message: "is not a valid email"}
        end
      else
        :ok
      end
    end

    # Prevent overwriting existing member relationship
    # This validation ensures race condition safety by requiring explicit two-step process:
    # 1. Remove existing member (set member to nil)
    # 2. Add new member
    # This prevents accidental overwrites when multiple admins work simultaneously
    validate fn changeset, _context ->
      member_arg = Ash.Changeset.get_argument(changeset, :member)
      current_member_id = changeset.data.member_id

      # Only trigger if:
      # - member argument is provided AND has an ID
      # - user currently has a member
      # - the new member ID is different from current member ID
      if member_arg && member_arg[:id] && current_member_id &&
           member_arg[:id] != current_member_id do
        {:error,
         field: :member, message: "User already has a member. Remove existing member first."}
      else
        :ok
      end
    end

    # Last-admin: prevent the only admin from leaving the admin role (at least one admin required).
    # Only block when the user is leaving admin (target role is not admin). Switching between
    # two admin roles (e.g. "Admin" and "Superadmin" both with permission_set_name "admin") is allowed.
    validate fn changeset, _context ->
               if Ash.Changeset.changing_attribute?(changeset, :role_id) do
                 new_role_id = Ash.Changeset.get_attribute(changeset, :role_id)

                 if is_nil(new_role_id) do
                   :ok
                 else
                   current_role_id = changeset.data.role_id

                   current_role =
                     Mv.Authorization.Role
                     |> Ash.get!(current_role_id, authorize?: false)

                   new_role =
                     Mv.Authorization.Role
                     |> Ash.get!(new_role_id, authorize?: false)

                   # Only block when current user is admin and target role is not admin (leaving admin)
                   if current_role.permission_set_name == "admin" and
                        new_role.permission_set_name != "admin" do
                     admin_role_ids =
                       Mv.Authorization.Role
                       |> Ash.Query.for_read(:read)
                       |> Ash.Query.filter(expr(permission_set_name == "admin"))
                       |> Ash.read!(authorize?: false)
                       |> Enum.map(& &1.id)

                     # Count only non-system users with admin role (system user is for internal ops)
                     system_email = Mv.Helpers.SystemActor.system_user_email()

                     count =
                       Mv.Accounts.User
                       |> Ash.Query.for_read(:read)
                       |> Ash.Query.filter(expr(role_id in ^admin_role_ids))
                       |> Ash.Query.filter(expr(email != ^system_email))
                       |> Ash.count!(authorize?: false)

                     if count <= 1 do
                       {:error,
                        field: :role_id, message: "At least one user must keep the Admin role."}
                     else
                       :ok
                     end
                   else
                     :ok
                   end
                 end
               else
                 :ok
               end
             end,
             on: [:update],
             where: [action_is([:update_user, :set_role_from_oidc_sync])]

    # Prevent modification of the system actor user (required for internal operations).
    # Block update/destroy on UI-exposed actions only; :update_internal is used by bootstrap/tests.
    validate fn changeset, _context ->
               if Mv.Helpers.SystemActor.system_user?(changeset.data) do
                 {:error,
                  field: :email,
                  message:
                    "Cannot modify system actor user. This user is required for internal operations."}
               else
                 :ok
               end
             end,
             on: [:update, :destroy],
             where: [action_is([:update, :update_user, :admin_set_password, :destroy])]
  end

  def validate_oidc_id_present(changeset, _context) do
    user_info = Ash.Changeset.get_argument(changeset, :user_info) || %{}

    if is_binary(user_info["sub"]) or is_binary(user_info["id"]) do
      :ok
    else
      {:error, [user_info: "OIDC user_info must contain a non-empty 'sub' or 'id' field"]}
    end
  end

  attributes do
    uuid_primary_key :id

    # IMPORTANT: Email Synchronization
    # When user and member are linked, emails are automatically synced bidirectionally.
    # User.email is the source of truth - when a link is established, member.email
    # is overridden to match user.email. Subsequent changes to either email will
    # sync to the other resource.
    # See: Mv.EmailSync.Changes.SyncUserEmailToMember
    #      Mv.EmailSync.Changes.SyncMemberEmailToUser
    attribute :email, :ci_string do
      allow_nil? false
      public? true
      # Same constraints as Member email for consistency
      constraints min_length: 5, max_length: 254
    end

    attribute :hashed_password, :string, sensitive?: true, allow_nil?: true
    attribute :oidc_id, :string, allow_nil?: true

    # Role assignment: Explicitly defined to enforce default value
    # This ensures every user has a role, regardless of creation path
    # (register_with_password, create_user, seeds, etc.)
    attribute :role_id, :uuid do
      allow_nil? false
      default &__MODULE__.default_role_id/0
      public? false
    end
  end

  relationships do
    # 1:1 relationship - User can optionally belong to one Member
    # This automatically creates a `member_id` attribute in the User table
    # The relationship is optional (allow_nil? true by default)
    belongs_to :member, Mv.Membership.Member

    # 1:1 relationship - User belongs to a Role
    # We define role_id ourselves (above in attributes) to control default value
    # Foreign key constraint: on_delete: :restrict (prevents deleting roles assigned to users)
    belongs_to :role, Mv.Authorization.Role do
      define_attribute? false
      source_attribute :role_id
      allow_nil? false
    end
  end

  identities do
    identity :unique_email, [:email]
    identity :unique_oidc_id, [:oidc_id]
    identity :unique_member, [:member_id]
  end

  # You can customize this if you wish, but this is a safe default that
  # only allows user data to be interacted with via AshAuthentication.
  # policies do
  #   bypass AshAuthentication.Checks.AshAuthenticationInteraction do
  #     authorize_if(always())
  #   end

  #   policy always() do
  #     forbid_if(always())
  #   end
  # end

  @doc """
  Returns the default role ID for new users.

  This function is called automatically when creating a user without an explicit role_id.
  It fetches the "Mitglied" role from the database without authorization checks
  (safe during user creation bootstrap phase).

  The result is cached in the process dictionary to avoid repeated database queries
  during high-volume user creation. The cache is invalidated on application restart.

  ## Bootstrap Safety

  Only non-nil values are cached. If the role doesn't exist yet (e.g., before seeds run),
  `nil` is not cached, allowing subsequent calls to retry after the role is created.
  This prevents bootstrap issues where a process would be permanently stuck with `nil`
  if the first call happens before the role exists.

  ## Performance Note

  This function makes one database query per process (cached in process dictionary).
  For very high-volume scenarios, consider using a fixed UUID from Application config
  instead of querying the database.

  ## Returns

  - UUID of the "Mitglied" role if it exists
  - `nil` if the role doesn't exist (will cause validation error due to `allow_nil? false`)

  ## Examples

      iex> Mv.Accounts.User.default_role_id()
      "019bf2e2-873a-7712-a7ce-a5a1f90c5f4f"
  """
  @spec default_role_id() :: Ecto.UUID.t() | nil
  def default_role_id do
    # Cache in process dictionary to avoid repeated queries
    # IMPORTANT: Only cache non-nil values to avoid bootstrap issues.
    # If the role doesn't exist yet (e.g., before seeds run), we don't cache nil
    # so that subsequent calls can retry after the role is created.
    case Process.get({__MODULE__, :default_role_id}) do
      nil ->
        role_id =
          case Mv.Authorization.Role.get_mitglied_role() do
            {:ok, %Mv.Authorization.Role{id: id}} -> id
            _ -> nil
          end

        # Only cache non-nil values to allow retry if role is created later
        if role_id, do: Process.put({__MODULE__, :default_role_id}, role_id)
        role_id

      cached_role_id ->
        cached_role_id
    end
  end
end
