IdentityUtils.java

package com.bonitasoft.processbuilder.extension;

import org.bonitasoft.engine.api.IdentityAPI;
import org.bonitasoft.engine.identity.ContactData;
import org.bonitasoft.engine.identity.User;
import org.bonitasoft.engine.identity.UserSearchDescriptor;
import org.bonitasoft.engine.search.SearchOptionsBuilder;
import org.bonitasoft.engine.search.SearchResult;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;

import com.bonitasoft.processbuilder.records.UserRecord;

import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.function.Supplier;
import java.util.stream.Collectors;

/**
 * Utility class for common operations with Bonita Identity API.
 * <p>
 * Provides methods to retrieve user information, managers, and users by memberships
 * without depending on BDM objects, ensuring functional independence and portability.
 * </p>
 *
 * @author Bonitasoft
 * @since 1.0
 * @version 2.0.0 - Fixed membership handling for group-only or role-only memberships
 */
public final class IdentityUtils {

    private static final Logger LOGGER = LoggerFactory.getLogger(IdentityUtils.class);
    private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();

    private IdentityUtils() {
        throw new UnsupportedOperationException("This is a " + this.getClass().getSimpleName() + " class and cannot be instantiated.");
    }

    /**
     * Retrieves a Bonita User object by its ID.
     *
     * @param userId      The ID of the user to retrieve
     * @param identityAPI The Bonita Identity API instance
     * @return The User object, or {@code null} if not found, invalid ID, or on error
     */
    public static User getUser(Long userId, IdentityAPI identityAPI) {
        if (!isValidId(userId)) {
            LOGGER.warn("Invalid userId provided: {}", userId);
            return null;
        }

        try {
            LOGGER.debug("Retrieving user with ID: {}", userId);

            User user = identityAPI.getUser(userId);
            if (user == null) {
                LOGGER.warn("User not found for ID: {}", userId);
                return null;
            }

            LOGGER.debug("Found user '{}' for ID {}", user.getUserName(), userId);
            return user;

        } catch (Exception e) {
            LOGGER.error("Error retrieving user for ID {}: {}", userId, e.getMessage(), e);
            return null;
        }
    }

    /**
     * Retrieves a UserRecord by user ID.
     * <p>
     * Converts the Bonita User object into a lightweight {@link UserRecord} containing
     * the essential fields: id, userName, fullName, firstName, lastName, and email.
     * The email is retrieved from the user's professional contact data.
     * </p>
     *
     * @param userId      The ID of the user to retrieve
     * @param identityAPI The Bonita Identity API instance
     * @return A {@link UserRecord} with user data, or {@code null} if user not found or on error
     */
    public static UserRecord getUserRecord(Long userId, IdentityAPI identityAPI) {
        User user = getUser(userId, identityAPI);
        if (user == null) {
            return null;
        }

        String firstName = user.getFirstName();
        String lastName = user.getLastName();
        String fullName = buildFullName(firstName, lastName);

        String email = null;
        try {
            ContactData contactData = identityAPI.getUserContactData(userId, false);
            if (contactData != null) {
                email = contactData.getEmail();
            }
        } catch (Exception e) {
            LOGGER.warn("Could not retrieve contact data for user ID {}: {}", userId, e.getMessage());
        }

        return new UserRecord(userId, user.getUserName(), fullName, firstName, lastName, email);
    }

    /**
     * Builds a full name from first and last name components.
     *
     * @param firstName First name (can be null)
     * @param lastName  Last name (can be null)
     * @return Full name string, or empty string if both are null/blank
     */
    private static String buildFullName(String firstName, String lastName) {
        StringBuilder sb = new StringBuilder();
        if (firstName != null && !firstName.isBlank()) {
            sb.append(firstName);
        }
        if (lastName != null && !lastName.isBlank()) {
            if (sb.length() > 0) {
                sb.append(" ");
            }
            sb.append(lastName);
        }
        return sb.toString();
    }

    /**
     * Gets the manager ID of a given user.
     *
     * @param userId      The ID of the user whose manager is to be retrieved
     * @param identityAPI The Bonita Identity API instance
     * @return The manager's user ID, or {@code null} if not found or on error
     */
    public static Long getUserManager(Long userId, IdentityAPI identityAPI) {
        User user = getUser(userId, identityAPI);
        if (user == null) {
            return null;
        }

        Long managerId = user.getManagerUserId();

        if (!isValidId(managerId)) {
            LOGGER.debug("User ID {} has no manager assigned (managerId: {})", userId, managerId);
            return null;
        }

        LOGGER.debug("Found manager ID {} for user ID {}", managerId, userId);
        return managerId;
    }

    /**
     * Gets all users matching the given memberships (groups/roles).
     * <p>
     * This method accepts a list of objects that contain group and role information.
     * Each object must have {@code getGroupId()} and {@code getRoleId()} methods.
     * </p>
     * <p>
     * <b>IMPORTANT:</b> This method correctly handles memberships that have:
     * </p>
     * <ul>
     *   <li>Both groupId AND roleId defined</li>
     *   <li>Only groupId defined (roleId is null or 0)</li>
     *   <li>Only roleId defined (groupId is null or 0)</li>
     * </ul>
     *
     * @param membershipList List of objects containing group and role IDs
     * @param identityAPI    The Bonita Identity API instance
     * @return Set of user IDs matching the memberships, empty set if no users found
     */
    public static Set<Long> getUsersByMemberships(List<?> membershipList, IdentityAPI identityAPI) {
        Set<Long> userIds = new HashSet<>();

        if (membershipList == null || membershipList.isEmpty()) {
            LOGGER.debug("Empty membership list provided, returning empty user set");
            return userIds;
        }

        try {
            SearchOptionsBuilder searchBuilder = new SearchOptionsBuilder(0, Integer.MAX_VALUE);
            searchBuilder.filter(UserSearchDescriptor.ENABLED, true);

            // Track if we have any valid membership conditions
            boolean hasValidConditions = false;
            boolean isFirstCondition = true;

            // Start the OR group for membership conditions
            searchBuilder.and();
            searchBuilder.leftParenthesis();

            for (final Object membershipObj : membershipList) {
                final Long groupId = extractLongValue(membershipObj, "getGroupId");
                final Long roleId = extractLongValue(membershipObj, "getRoleId");

                // Use helper method that validates > 0, not just != null
                boolean hasValidGroup = isValidId(groupId);
                boolean hasValidRole = isValidId(roleId);

                // Skip if both groupId and roleId are invalid
                if (!hasValidGroup && !hasValidRole) {
                    LOGGER.warn("Skipping membership object with no valid groupId or roleId (groupId={}, roleId={})",
                            groupId, roleId);
                    continue;
                }

                // Add OR condition after the first valid membership
                if (!isFirstCondition) {
                    searchBuilder.or();
                }

                searchBuilder.leftParenthesis();

                // Only include valid IDs in the query
                if (hasValidGroup && hasValidRole) {
                    // Both group and role - require exact match
                    LOGGER.debug("Adding filter: GROUP_ID={} AND ROLE_ID={}", groupId, roleId);
                    searchBuilder.filter(UserSearchDescriptor.GROUP_ID, groupId);
                    searchBuilder.and();
                    searchBuilder.filter(UserSearchDescriptor.ROLE_ID, roleId);
                } else if (hasValidGroup) {
                    // Only group - search users in this group (any role)
                    LOGGER.debug("Adding filter: GROUP_ID={} (no role constraint)", groupId);
                    searchBuilder.filter(UserSearchDescriptor.GROUP_ID, groupId);
                } else {
                    // Only role - search users with this role (any group)
                    LOGGER.debug("Adding filter: ROLE_ID={} (no group constraint)", roleId);
                    searchBuilder.filter(UserSearchDescriptor.ROLE_ID, roleId);
                }

                searchBuilder.rightParenthesis();
                isFirstCondition = false;
                hasValidConditions = true;
            }

            searchBuilder.rightParenthesis();

            // If no valid conditions were added, return empty set
            if (!hasValidConditions) {
                LOGGER.warn("No valid membership conditions found, returning empty user set");
                return userIds;
            }

            final SearchResult<User> searchResult = identityAPI.searchUsers(searchBuilder.done());
            userIds = searchResult.getResult().stream()
                    .map(User::getId)
                    .collect(Collectors.toSet());

            LOGGER.info("Found {} users from {} memberships", userIds.size(), membershipList.size());

        } catch (final Exception e) {
            LOGGER.error("An error occurred during user search by membership: {}", e.getMessage(), e);
        }

        return userIds;
    }

    /**
     * Checks if an ID is valid (not null and greater than 0).
     *
     * @param id The ID to validate
     * @return true if the ID is valid, false otherwise
     */
    private static boolean isValidId(Long id) {
        return id != null && id > 0;
    }

    /**
     * Extracts a Long value from an object using reflection by calling a getter method.
     * <p>
     * This method handles multiple return types: Long, Integer, Number, and String.
     * Returns {@code null} for invalid values (null, 0, negative, or non-parseable).
     * </p>
     *
     * @param obj        The object from which to extract the value
     * @param methodName The name of the getter method to invoke (e.g., "getGroupId")
     * @return The Long value (greater than 0), or {@code null} if not valid
     */
    static Long extractLongValue(Object obj, String methodName) {
        if (obj == null) {
            return null;
        }

        try {
            Method method = obj.getClass().getMethod(methodName);
            Object result = method.invoke(obj);

            if (result == null) {
                return null;
            }

            Long value = null;

            if (result instanceof Long) {
                value = (Long) result;
            } else if (result instanceof Integer) {
                value = ((Integer) result).longValue();
            } else if (result instanceof Number) {
                value = ((Number) result).longValue();
            } else if (result instanceof String) {
                String str = ((String) result).trim();
                if (!str.isEmpty()) {
                    try {
                        value = Long.parseLong(str);
                    } catch (NumberFormatException nfe) {
                        LOGGER.warn("Method {} returned non-parseable String: '{}'", methodName, str);
                        return null;
                    }
                }
            } else {
                LOGGER.warn("Method {} returned unexpected type: {}", methodName, result.getClass().getName());
                return null;
            }

            // Return null if value is 0 or negative (invalid ID)
            if (value == null || value <= 0) {
                LOGGER.trace("Method {} returned invalid ID value: {}", methodName, value);
                return null;
            }

            return value;

        } catch (NoSuchMethodException e) {
            LOGGER.warn("Method {} not found on object of type {}", methodName, obj.getClass().getName());
            return null;
        } catch (Exception e) {
            LOGGER.warn("Error invoking method {} on object: {}", methodName, e.getMessage());
            return null;
        }
    }

    /**
     * Extracts a user ID from a BDM object using reflection.
     * <p>
     * This method attempts to call a getter method on the provided object to extract
     * a user ID. It supports various method names commonly used in BDM objects such as
     * {@code getUserId()}, {@code getStepUser()}, or any custom getter name.
     * </p>
     * <p>
     * The method handles null objects, null return values, and invalid ID values (0 or negative).
     * </p>
     *
     * @param bdmObject The BDM object from which to extract the user ID (can be null)
     * @param methodName The name of the getter method to invoke (e.g., "getUserId", "getStepUser")
     * @return The user ID if valid (greater than 0), or {@code null} if object is null,
     *         method not found, or ID is invalid
     */
    public static Long getUserIdFromObject(Object bdmObject, String methodName) {
        if (bdmObject == null) {
            LOGGER.debug("Cannot extract user ID: BDM object is null");
            return null;
        }

        if (methodName == null || methodName.trim().isEmpty()) {
            LOGGER.warn("Cannot extract user ID: method name is null or empty");
            return null;
        }

        Long userId = extractLongValue(bdmObject, methodName);

        if (userId != null && userId > 0) {
            LOGGER.debug("Extracted user ID {} from {} using method {}",
                    userId, bdmObject.getClass().getSimpleName(), methodName);
            return userId;
        }

        LOGGER.debug("No valid user ID found in {} using method {} (value: {})",
                bdmObject.getClass().getSimpleName(), methodName, userId);
        return null;
    }

    /**
     * Builds a set of candidate user IDs for task assignation based on step user, manager, and memberships.
     * <p>
     * This method implements the common actor filter logic:
     * </p>
     * <ol>
     *   <li>If stepUserId is valid, add it to the candidates</li>
     *   <li>If includeManager is true and stepUserId has a manager, add the manager ID</li>
     *   <li>If membershipList is provided, add all users from those memberships</li>
     * </ol>
     * <p>
     * This method is designed to be used by Groovy scripts to reduce code duplication
     * when implementing actor filters.
     * </p>
     *
     * @param stepUserId The user ID from a previous step (can be null)
     * @param includeManager Whether to include the step user's manager
     * @param membershipList List of membership objects with getGroupId/getRoleId methods (can be null)
     * @param identityAPI The Bonita Identity API instance
     * @return A set of candidate user IDs (never null, may be empty)
     */
    public static Set<Long> buildCandidateUsers(
            Long stepUserId,
            boolean includeManager,
            List<?> membershipList,
            IdentityAPI identityAPI) {

        Set<Long> candidates = new HashSet<>();

        // Add the step user if valid
        if (isValidId(stepUserId)) {
            candidates.add(stepUserId);
            LOGGER.debug("Added step user ID {} to candidates", stepUserId);

            // Add the manager if requested
            if (includeManager) {
                Long managerId = getUserManager(stepUserId, identityAPI);
                if (managerId != null) {
                    candidates.add(managerId);
                    LOGGER.debug("Added manager ID {} to candidates", managerId);
                }
            }
        }

        // Add users from memberships
        if (membershipList != null && !membershipList.isEmpty()) {
            Set<Long> membershipUsers = getUsersByMemberships(membershipList, identityAPI);
            candidates.addAll(membershipUsers);
            LOGGER.debug("Added {} users from memberships to candidates", membershipUsers.size());
        }

        LOGGER.info("Built candidate user set with {} total users", candidates.size());
        return candidates;
    }

    /**
     * Filters assignable users based on candidate user IDs.
     * <p>
     * This method takes a set of candidate user IDs and returns only those that
     * are present in the provided collection of assignable user IDs. This is the
     * final step in actor filter logic where candidates are intersected with
     * users who are actually assignable to the task.
     * </p>
     *
     * @param candidateUserIds Set of candidate user IDs to filter
     * @param assignableUserIds Collection of user IDs that are assignable to the task
     * @return A set containing only the user IDs that are both candidates AND assignable
     */
    public static Set<Long> filterAssignableUsers(
            Set<Long> candidateUserIds,
            Collection<Long> assignableUserIds) {

        if (candidateUserIds == null || candidateUserIds.isEmpty()) {
            LOGGER.debug("No candidate users to filter");
            return Collections.emptySet();
        }

        if (assignableUserIds == null || assignableUserIds.isEmpty()) {
            LOGGER.debug("No assignable users provided, returning empty set");
            return Collections.emptySet();
        }

        Set<Long> assignableSet = new HashSet<>(assignableUserIds);
        Set<Long> filteredUsers = candidateUserIds.stream()
                .filter(assignableSet::contains)
                .collect(Collectors.toSet());

        LOGGER.info("Filtered {} candidate users down to {} assignable users",
                candidateUserIds.size(), filteredUsers.size());

        return filteredUsers;
    }

    /**
     * Convenience method that combines building candidate users and filtering in one call.
     * <p>
     * This method performs the complete actor filter logic:
     * </p>
     * <ol>
     *   <li>Builds the candidate user set from step user, manager, and memberships</li>
     *   <li>Filters the candidates against the assignable users</li>
     * </ol>
     * <p>
     * Example usage in Groovy:
     * </p>
     * <pre>{@code
     * Set<Long> filteredUsers = IdentityUtils.getFilteredAssignableUsers(
     *     stepUserId,
     *     true,  // include manager
     *     membershipList,
     *     assignableUserIds,
     *     identityAPI
     * )
     * return filteredUsers.toList()
     * }</pre>
     *
     * @param stepUserId The user ID from a previous step (can be null)
     * @param includeManager Whether to include the step user's manager
     * @param membershipList List of membership objects with getGroupId/getRoleId methods (can be null)
     * @param assignableUserIds Collection of user IDs that are assignable to the task
     * @param identityAPI The Bonita Identity API instance
     * @return A set of user IDs that are both candidates AND assignable (never null)
     */
    public static Set<Long> getFilteredAssignableUsers(
            Long stepUserId,
            boolean includeManager,
            List<?> membershipList,
            Collection<Long> assignableUserIds,
            IdentityAPI identityAPI) {

        Set<Long> candidates = buildCandidateUsers(stepUserId, includeManager, membershipList, identityAPI);
        return filterAssignableUsers(candidates, assignableUserIds);
    }

    /**
     * Extracts user IDs from a list of BDM user objects.
     * <p>
     * This method iterates over a list of BDM objects and extracts user IDs using
     * the specified getter method. It filters out null and invalid IDs (0 or negative).
     * </p>
     * <p>
     * This is useful for extracting user IDs from BDM objects like PBUserList where
     * each object has a user ID field.
     * </p>
     *
     * @param userObjects List of BDM objects containing user IDs
     * @param userIdMethodName The getter method name to extract user ID (e.g., "getUserId")
     * @return A set of valid user IDs extracted from the objects (never null)
     */
    public static Set<Long> extractUserIdsFromObjects(List<?> userObjects, String userIdMethodName) {
        if (userObjects == null || userObjects.isEmpty()) {
            LOGGER.debug("No user objects provided for extraction");
            return Collections.emptySet();
        }

        if (userIdMethodName == null || userIdMethodName.trim().isEmpty()) {
            LOGGER.warn("Cannot extract user IDs: method name is null or empty");
            return Collections.emptySet();
        }

        Set<Long> userIds = new HashSet<>();

        for (Object userObject : userObjects) {
            Long userId = getUserIdFromObject(userObject, userIdMethodName);
            if (userId != null) {
                userIds.add(userId);
            }
        }

        LOGGER.debug("Extracted {} valid user IDs from {} objects", userIds.size(), userObjects.size());
        return userIds;
    }

    // ========================================================================
    // JSON Parsing Utilities
    // ========================================================================

    /**
     * Parses a JSON string and returns the root JsonNode.
     *
     * @param jsonContent JSON string to parse
     * @param logger      Logger for error reporting (nullable)
     * @return Optional containing the root JsonNode, or empty if parsing fails
     */
    public static Optional<JsonNode> parseJson(String jsonContent, Logger logger) {
        if (jsonContent == null || jsonContent.isBlank()) {
            logWarn(logger, "JSON content is null or blank");
            return Optional.empty();
        }

        try {
            JsonNode rootNode = OBJECT_MAPPER.readTree(jsonContent);
            return Optional.ofNullable(rootNode);
        } catch (JsonProcessingException e) {
            logWarn(logger, "Failed to parse JSON content: {}", e.getMessage());
            return Optional.empty();
        }
    }

    /**
     * Parses JSON and extracts a specific node by key.
     *
     * @param jsonContent JSON string to parse
     * @param nodeKey     Key of the node to extract
     * @param logger      Logger for error reporting (nullable)
     * @return Optional containing the extracted JsonNode, or empty if not found
     */
    public static Optional<JsonNode> parseJsonAndGetNode(String jsonContent, String nodeKey, Logger logger) {
        return parseJson(jsonContent, logger)
                .flatMap(root -> getJsonNode(root, nodeKey, logger));
    }

    /**
     * Extracts a child node from a parent JsonNode.
     *
     * @param parentNode Parent node to search in
     * @param nodeKey    Key of the child node
     * @param logger     Logger for error reporting (nullable)
     * @return Optional containing the child JsonNode, or empty if not found
     */
    public static Optional<JsonNode> getJsonNode(JsonNode parentNode, String nodeKey, Logger logger) {
        if (parentNode == null) {
            logWarn(logger, "Parent node is null when searching for key '{}'", nodeKey);
            return Optional.empty();
        }

        JsonNode childNode = parentNode.get(nodeKey);
        if (childNode == null || childNode.isNull()) {
            logWarn(logger, "Key '{}' not found in JSON node", nodeKey);
            return Optional.empty();
        }

        return Optional.of(childNode);
    }

    /**
     * Extracts a non-empty text value from a JsonNode.
     *
     * @param node   JsonNode to extract text from
     * @param logger Logger for debug reporting (nullable)
     * @return Optional containing the trimmed text, or empty if null/blank
     */
    public static Optional<String> getNodeText(JsonNode node, Logger logger) {
        if (node == null || node.isNull()) {
            return Optional.empty();
        }

        String text = node.asText();
        if (text == null || text.isBlank()) {
            logDebug(logger, "Node text is null or blank");
            return Optional.empty();
        }

        return Optional.of(text.trim());
    }

    /**
     * Extracts a list of strings from a JsonNode array.
     *
     * @param arrayNode JsonNode that should be an array
     * @param logger    Logger for error reporting (nullable)
     * @return List of strings (empty if node is not an array or is empty)
     */
    public static List<String> getNodeArrayAsStringList(JsonNode arrayNode, Logger logger) {
        if (arrayNode == null || !arrayNode.isArray() || arrayNode.isEmpty()) {
            logDebug(logger, "Node is null, not an array, or empty");
            return Collections.emptyList();
        }

        List<String> result = new ArrayList<>(arrayNode.size());
        arrayNode.forEach(node -> {
            String text = node.asText();
            if (text != null && !text.isBlank()) {
                result.add(text.trim());
            }
        });

        return result;
    }

    // ========================================================================
    // Membership Utilities with Functional Interface
    // ========================================================================

    /**
     * Gets user IDs from memberships using a supplier for data access.
     * The supplier should return a collection of objects that have group/role information.
     *
     * @param membershipDataSupplier Supplier that provides membership data objects
     * @param groupIdExtractor       Function to extract group ID from membership object
     * @param roleIdExtractor        Function to extract role ID from membership object
     * @param identityAPI            Bonita Identity API
     * @param logger                 Logger for reporting (nullable)
     * @param <T>                    Type of membership data object
     * @return Set of user IDs (empty if none found)
     */
    public static <T> Set<Long> getUsersByMemberships(
            Supplier<List<T>> membershipDataSupplier,
            java.util.function.Function<T, Long> groupIdExtractor,
            java.util.function.Function<T, Long> roleIdExtractor,
            IdentityAPI identityAPI,
            Logger logger) {

        if (membershipDataSupplier == null || identityAPI == null) {
            return Collections.emptySet();
        }

        try {
            List<T> membershipData = membershipDataSupplier.get();
            if (membershipData == null || membershipData.isEmpty()) {
                logDebug(logger, "No membership data provided");
                return Collections.emptySet();
            }

            Set<Long> userIds = new HashSet<>();

            for (T data : membershipData) {
                Long groupId = groupIdExtractor.apply(data);
                Long roleId = roleIdExtractor.apply(data);

                Set<Long> users = getUsersForMembership(groupId, roleId, identityAPI, logger);
                userIds.addAll(users);
            }

            logDebug(logger, "Found {} unique users from {} memberships", userIds.size(), membershipData.size());
            return userIds;

        } catch (Exception e) {
            logWarn(logger, "Error getting users by memberships: {}", e.getMessage());
            return Collections.emptySet();
        }
    }

    /**
     * Gets user IDs for a specific group/role combination.
     * Handles three cases: group+role, group-only, role-only.
     *
     * @param groupId     Group ID (nullable)
     * @param roleId      Role ID (nullable)
     * @param identityAPI Bonita Identity API
     * @param logger      Logger for reporting (nullable)
     * @return Set of user IDs matching the membership criteria
     */
    public static Set<Long> getUsersForMembership(
            Long groupId,
            Long roleId,
            IdentityAPI identityAPI,
            Logger logger) {

        if (identityAPI == null) {
            return Collections.emptySet();
        }

        boolean hasGroup = groupId != null && groupId > 0;
        boolean hasRole = roleId != null && roleId > 0;

        if (!hasGroup && !hasRole) {
            logDebug(logger, "Both groupId and roleId are invalid");
            return Collections.emptySet();
        }

        try {
            SearchOptionsBuilder searchBuilder = new SearchOptionsBuilder(0, Integer.MAX_VALUE);
            searchBuilder.filter(UserSearchDescriptor.ENABLED, true);

            if (hasGroup && hasRole) {
                // Full membership: group + role
                logDebug(logger, "Searching users with groupId={} and roleId={}", groupId, roleId);
                searchBuilder.filter(UserSearchDescriptor.GROUP_ID, groupId);
                searchBuilder.and();
                searchBuilder.filter(UserSearchDescriptor.ROLE_ID, roleId);

            } else if (hasGroup) {
                // Group-only membership
                logDebug(logger, "Searching users in groupId={}", groupId);
                searchBuilder.filter(UserSearchDescriptor.GROUP_ID, groupId);

            } else {
                // Role-only membership
                logDebug(logger, "Searching users with roleId={}", roleId);
                searchBuilder.filter(UserSearchDescriptor.ROLE_ID, roleId);
            }

            SearchResult<User> result = identityAPI.searchUsers(searchBuilder.done());
            return result.getResult().stream()
                    .map(User::getId)
                    .collect(Collectors.toSet());

        } catch (Exception e) {
            logWarn(logger, "Error getting users for membership (group={}, role={}): {}",
                    groupId, roleId, e.getMessage());
            return Collections.emptySet();
        }
    }

    // ========================================================================
    // Step Instance Utilities
    // ========================================================================

    /**
     * Finds the most recent instance from a supplier result.
     * Generic method that works with any step instance type.
     *
     * @param instanceSupplier Supplier that executes the DAO query and returns a list
     * @param typeName         Name of the type for logging purposes
     * @param logger           Logger for reporting (nullable)
     * @param <T>              Type of the step instance
     * @return The first (most recent) instance, or null if none found
     */
    public static <T> T findMostRecentInstance(
            Supplier<List<T>> instanceSupplier,
            String typeName,
            Logger logger) {

        if (instanceSupplier == null) {
            return null;
        }

        try {
            List<T> instances = instanceSupplier.get();

            if (instances == null || instances.isEmpty()) {
                logDebug(logger, "No {} instances found", typeName);
                return null;
            }

            T mostRecent = instances.get(0);
            logDebug(logger, "Found most recent {} instance", typeName);
            return mostRecent;

        } catch (Exception e) {
            logWarn(logger, "Error finding most recent {}: {}", typeName, e.getMessage());
            return null;
        }
    }

    /**
     * Extracts a field value from a JSON string.
     *
     * @param jsonString JSON string to parse
     * @param fieldName  Field name to extract
     * @param logger     Logger for reporting (nullable)
     * @return Optional containing the field value as string, or empty if not found
     */
    public static Optional<String> extractFieldFromJson(String jsonString, String fieldName, Logger logger) {
        return parseJson(jsonString, logger)
                .flatMap(root -> getJsonNode(root, fieldName, logger))
                .flatMap(node -> getNodeText(node, logger));
    }

    // ========================================================================
    // Private Logging Helpers
    // ========================================================================

    private static void logDebug(Logger logger, String message, Object... args) {
        if (logger != null) {
            logger.debug(message, args);
        }
    }

    private static void logWarn(Logger logger, String message, Object... args) {
        if (logger != null) {
            logger.warn(message, args);
        }
    }
}