"""Abstract offering processors for different agent operational modes.

This module provides the core abstract base classes that define the interface
and common functionality for processing Waldur offerings. The processors handle
the integration between Waldur Mastermind and backend systems through different
operational patterns:

- OfferingOrderProcessor: Handles order lifecycle (create, update, terminate)
- OfferingMembershipProcessor: Manages user membership synchronization
- OfferingReportProcessor: Reports usage data from backends to Waldur

Each processor type implements specific aspects of the Waldur-backend integration
while sharing common patterns for error handling, retry logic, and API communication.
"""

from __future__ import annotations

import abc
import datetime
import traceback
from time import sleep
from typing import Any, Optional
from zoneinfo import ZoneInfo

from waldur_api_client.api.backend_resource_requests import (
    backend_resource_requests_retrieve,
    backend_resource_requests_set_done,
    backend_resource_requests_set_erred,
    backend_resource_requests_start_processing,
)
from waldur_api_client.api.backend_resources import backend_resources_create, backend_resources_list
from waldur_api_client.api.component_user_usage_limits import component_user_usage_limits_list
from waldur_api_client.api.marketplace_component_usages import (
    marketplace_component_usages_list,
    marketplace_component_usages_set_usage,
    marketplace_component_usages_set_user_usages,
)
from waldur_api_client.api.marketplace_component_user_usages import (
    marketplace_component_user_usages_list,
)
from waldur_api_client.api.marketplace_offering_users import (
    marketplace_offering_users_list,
)
from waldur_api_client.api.marketplace_orders import (
    marketplace_orders_approve_by_provider,
    marketplace_orders_list,
    marketplace_orders_retrieve,
    marketplace_orders_set_backend_id,
    marketplace_orders_set_state_done,
    marketplace_orders_set_state_erred,
)
from waldur_api_client.api.marketplace_plans import (
    marketplace_plans_retrieve,
)
from waldur_api_client.api.marketplace_provider_offerings import (
    marketplace_provider_offerings_check_unique_backend_id,
    marketplace_provider_offerings_retrieve,
)
from waldur_api_client.api.marketplace_provider_resources import (
    marketplace_provider_resources_list,
    marketplace_provider_resources_refresh_last_sync,
    marketplace_provider_resources_retrieve,
    marketplace_provider_resources_set_as_ok,
    marketplace_provider_resources_set_backend_id,
    marketplace_provider_resources_set_backend_metadata,
    marketplace_provider_resources_set_limits,
    marketplace_provider_resources_team_list,
)
from waldur_api_client.api.marketplace_service_providers import (
    marketplace_service_providers_course_accounts_list,
    marketplace_service_providers_keys_list,
    marketplace_service_providers_list,
    marketplace_service_providers_project_service_accounts_list,
)
from waldur_api_client.api.projects import projects_list
from waldur_api_client.errors import UnexpectedStatus
from waldur_api_client.models import (
    ComponentUsageCreateRequest,
    ComponentUsageItemRequest,
    CourseAccount,
    OfferingUserFieldEnum,
    OrderDetailsFieldEnum,
    OrderState,
    ProjectServiceAccount,
    ProviderOfferingDetailsFieldEnum,
    ResourceFieldEnum,
    ResourceSetLimitsRequest,
    ResourceState,
    ServiceAccountState,
)
from waldur_api_client.models.agent_processor import AgentProcessor
from waldur_api_client.models.agent_service import AgentService
from waldur_api_client.models.backend_resource_request import BackendResourceRequest
from waldur_api_client.models.backend_resource_request_set_erred_request import (
    BackendResourceRequestSetErredRequest,
)
from waldur_api_client.models.check_unique_backend_id_request import CheckUniqueBackendIDRequest
from waldur_api_client.models.component_usage import ComponentUsage
from waldur_api_client.models.component_usage_field_enum import ComponentUsageFieldEnum
from waldur_api_client.models.component_user_usage_bulk_create_request import (
    ComponentUserUsageBulkCreateRequest,
)
from waldur_api_client.models.component_user_usage_create_request import (
    ComponentUserUsageCreateRequest,
)
from waldur_api_client.models.offering_component import OfferingComponent
from waldur_api_client.models.offering_user import OfferingUser
from waldur_api_client.models.offering_user_state import OfferingUserState
from waldur_api_client.models.order_approve_by_provider_request import (
    OrderApproveByProviderRequest,
)
from waldur_api_client.models.order_backend_id_request import OrderBackendIDRequest
from waldur_api_client.models.order_details import (
    OrderDetails,
)
from waldur_api_client.models.order_error_details_request import OrderErrorDetailsRequest
from waldur_api_client.models.project_user import ProjectUser
from waldur_api_client.models.provider_offering_details import ProviderOfferingDetails
from waldur_api_client.models.request_types import RequestTypes
from waldur_api_client.models.resource import Resource as WaldurResource
from waldur_api_client.models.resource_backend_id_request import ResourceBackendIDRequest
from waldur_api_client.models.resource_backend_metadata_request import (
    ResourceBackendMetadataRequest,
)
from waldur_api_client.types import UNSET

from waldur_site_agent.backend import BackendType, logger
from waldur_site_agent.backend import exceptions as backend_exceptions
from waldur_site_agent.backend import utils as backend_utils
from waldur_site_agent.backend.backends import BaseBackend, UnknownUsernameManagementBackend
from waldur_site_agent.backend.exceptions import BackendError
from waldur_site_agent.backend.structures import BackendResourceInfo
from waldur_site_agent.common import agent_identity_management, structures, utils
from waldur_site_agent.common.structures import AccountType


class UsageAnomalyError(Exception):
    """Raised when usage anomaly is detected.

    This exception is raised when the system detects that new usage data
    is lower than previously reported usage for the same billing period,
    which typically indicates a data collection or processing error.
    """


class ObjectNotFoundError(Exception):
    """Raised when a required object cannot be found in Waldur or backend.

    This exception is used throughout the processors when attempting to
    retrieve resources, users, or other objects that should exist but
    cannot be located in either Waldur or the backend system.
    """


class OfferingBaseProcessor(abc.ABC):
    """Abstract base class for all offering processors.

    This class provides the common foundation for all offering processors,
    including Waldur API client setup, backend initialization, and shared
    utility methods. All concrete processor implementations inherit from
    this class to ensure consistent behavior and interface.

    The processor handles:
    - Waldur REST API client configuration and authentication
    - Backend system selection and initialization based on offering configuration
    - Username management backend setup
    - Common error handling and logging patterns

    Attributes:
        BACKEND_TYPE_KEY: Key used to identify which backend to use for this processor
        offering: The offering configuration being processed
        timezone: Timezone for billing period calculations
        waldur_rest_client: Authenticated client for Waldur API access
        resource_backend: Backend implementation for this offering
    """

    BACKEND_TYPE_KEY = "abstract"

    def __init__(
        self,
        offering: structures.Offering,
        waldur_rest_client: utils.AuthenticatedClient,
        timezone: str = "",
        resource_backend: Optional[BaseBackend] = None,
        resource_backend_version: Optional[str] = None,
    ) -> None:
        """Initialize the offering processor.

        Args:
            offering: The offering configuration to process
            waldur_rest_client: HTTP Client for Waldur API
            timezone: Timezone for billing period calculations (defaults to UTC)
            resource_backend: Backend instance for resource operations
                (optional, will be created if not provided)
            resource_backend_version: Version of the resource backend
                (optional, will be determined if not provided)
            to compare usage data with previous usage data

        Raises:
            BackendError: If unable to create a backend for the offering
        """
        self.offering: structures.Offering = offering
        self.timezone: str = timezone
        self.waldur_rest_client = waldur_rest_client
        # Use dependency injection if backend is provided, otherwise create it
        if resource_backend is not None:
            self.resource_backend = resource_backend
            self.resource_backend_version = resource_backend_version or "unknown"
        else:
            self.resource_backend, self.resource_backend_version = utils.get_backend_for_offering(
                offering, self.BACKEND_TYPE_KEY
            )

        self.resource_backend.timezone = timezone

        if self.resource_backend.backend_type == BackendType.UNKNOWN.value:
            raise backend_exceptions.BackendError(
                f"Unable to create backend for {self.offering.name}"
            )

        self._print_current_user()

        self.waldur_offering = marketplace_provider_offerings_retrieve.sync(
            client=self.waldur_rest_client, uuid=self.offering.uuid
        )
        utils.extend_backend_components(self.offering, self.waldur_offering.components)

        service_providers = marketplace_service_providers_list.sync(
            customer_uuid=self.waldur_offering.customer_uuid.hex,
            client=self.waldur_rest_client,
        )

        self.service_provider = service_providers[0]
        self.resource_backend.service_provider_uuid = self.service_provider.uuid.hex

        # Per-cycle cache for offering users (avoids redundant API calls)
        self._offering_users_cache: list[OfferingUser] | None = None

    def _print_current_user(self) -> None:
        """Log information about the current authenticated Waldur user."""
        current_user = utils.get_current_user_from_client(self.waldur_rest_client)
        utils.print_current_user(current_user)

    def _get_cached_offering_users(self) -> list[OfferingUser]:
        """Return cached offering users, fetching from API on first call.

        Uses a per-cycle cache to avoid redundant API calls when multiple
        methods need the full offering users list.
        """
        if self._offering_users_cache is None:
            self._offering_users_cache = marketplace_offering_users_list.sync_all(
                client=self.waldur_rest_client,
                offering_uuid=[self.offering.uuid],
                is_restricted=False,
                field=[
                    OfferingUserFieldEnum.USER_UUID,
                    OfferingUserFieldEnum.USERNAME,
                    OfferingUserFieldEnum.URL,
                    OfferingUserFieldEnum.STATE,
                    OfferingUserFieldEnum.UUID,
                    OfferingUserFieldEnum.USER_USERNAME,
                ],
            )
        return self._offering_users_cache

    def _invalidate_offering_users_cache(self) -> None:
        """Invalidate the offering users cache.

        Call this after modifying offering users (e.g., creating new ones)
        to ensure fresh data on the next access.
        """
        self._offering_users_cache = None

    def _check_backend_id_uniqueness(self, backend_id: str) -> bool:
        """Check if backend_id is unique across offering history.

        Uses backend_settings to configure the uniqueness check:
        - check_backend_id_uniqueness: Enable/disable the check (default: False)
        - check_all_offerings: Check across all customer offerings vs. single offering
          (default: False)

        Args:
            backend_id: The backend ID to check

        Returns:
            True if the backend_id is unique, False if already used
        """
        if not self.offering.backend_settings.get("check_backend_id_uniqueness", False):
            return True  # Skip check if not enabled

        try:
            check_all_offerings = self.offering.backend_settings.get("check_all_offerings", False)
            logger.info(
                "Checking backend_id uniqueness for: %s (check_all_offerings: %s)",
                backend_id,
                check_all_offerings,
            )
            request = CheckUniqueBackendIDRequest(
                backend_id=backend_id, check_all_offerings=check_all_offerings
            )
            response = marketplace_provider_offerings_check_unique_backend_id.sync(
                uuid=self.offering.uuid,
                client=self.waldur_rest_client,
                body=request,
            )

            is_unique = response.is_unique
            if not is_unique:
                logger.warning("Backend ID %s is not unique in offering history", backend_id)
            return is_unique

        except Exception as e:
            logger.warning("Failed to check backend_id uniqueness: %s", e)
            return True  # Continue with creation if check fails

    def _create_resource_with_uniqueness_check(
        self,
        waldur_resource: WaldurResource,
        user_context: dict,
    ) -> BackendResourceInfo:
        """Create resource with uniqueness checking and retry logic.

        This method implements the retry logic with backend_id uniqueness checking
        that was previously embedded in the BaseBackend.create_resource method.

        Args:
            waldur_resource: Waldur resource information
            user_context: User context containing team and offering user data

        Returns:
            Created backend resource information

        Raises:
            BackendError: If resource creation fails after all retries
        """
        # Determine resource name generation strategy
        use_project_slug = (
            waldur_resource.offering_plugin_options.get("account_name_generation_policy")
            == "project_slug"
        )

        resource_base_id = (
            waldur_resource.project_slug if use_project_slug else waldur_resource.slug
        )

        # Use 10 retries if uniqueness checking is enabled or project_slug naming is used
        uniqueness_check_enabled = self.offering.backend_settings.get(
            "check_backend_id_uniqueness", False
        )
        max_retries = 10 if (use_project_slug or uniqueness_check_enabled) else 1

        # Try creating resource with generated IDs
        for retry in range(max_retries):
            logger.info("Attempt %d/%d to create a resource", retry + 1, max_retries)

            resource_backend_id = self.resource_backend._get_resource_backend_id(resource_base_id)

            # Check backend_id uniqueness if enabled
            if not self._check_backend_id_uniqueness(resource_backend_id):
                logger.info(
                    "Backend ID %s is not unique, trying next iteration", resource_backend_id
                )
                if use_project_slug:
                    resource_base_id = f"{waldur_resource.project_slug}-{retry}"
                continue

            # Try to create the resource in backend with this ID
            try:
                return self.resource_backend.create_resource_with_id(
                    waldur_resource, resource_backend_id, user_context
                )
            except backend_exceptions.DuplicateResourceError:
                # Resource already exists, try next iteration
                logger.info(
                    "Resource %s already exists, trying next iteration", resource_backend_id
                )
                resource_base_id = f"{waldur_resource.project_slug}-{retry}"
                continue

        # If we get here, all retries failed
        raise backend_exceptions.BackendError(
            f"Unable to create resource: after {max_retries} attempts, "
            "all generated names already exist or uniqueness check failed"
        )

    @abc.abstractmethod
    def process_offering(self) -> None:
        """Process the offering according to the specific processor type.

        This method must be implemented by concrete processor classes to handle
        their specific responsibilities (order processing, membership sync, or reporting).
        """

    def _update_offering_users(self, offering_users: list[OfferingUser]) -> bool:
        """Generate usernames for offering users and update their state accordingly.

        Args:
            offering_users: List of offering users to process
        """
        result = utils.update_offering_users(
            self.offering, self.waldur_rest_client, offering_users
        )
        if result:
            # Usernames were modified, invalidate the cache
            self._invalidate_offering_users_cache()
        return result

    def register(self, service: AgentService) -> AgentProcessor:
        """Register this processor in Waldur."""
        agent_identity_manager = agent_identity_management.AgentIdentityManager(
            self.offering, self.waldur_rest_client
        )
        processor_name = self.__class__.__name__
        backend_type = (
            self.resource_backend.__class__.__module__
            + "."
            + self.resource_backend.__class__.__name__
        )
        return agent_identity_manager.register_processor(
            service, processor_name, backend_type, self.resource_backend_version
        )

    def _sync_resource_service_accounts(self, waldur_resource: WaldurResource) -> None:
        """Sync project service accounts between Waldur and the backend resource."""
        logger.info(
            "Syncing service accounts for the resource %s (%s)",
            waldur_resource.name,
            waldur_resource.backend_id,
        )
        if self.service_provider is None:
            logger.warning("No service provider configured, skipping service accounts sync")
            return

        service_accounts = marketplace_service_providers_project_service_accounts_list.sync_all(
            service_provider_uuid=self.service_provider.uuid.hex,
            project_uuid=waldur_resource.project_uuid.hex,
            client=self.waldur_rest_client,
        )
        usernames_active = {
            account.username
            for account in service_accounts
            if account.username and account.state == ServiceAccountState.OK
        }
        self.resource_backend.add_users_to_resource(waldur_resource, usernames_active)

        usernames_closed = {
            account.username
            for account in service_accounts
            if account.username and account.state == ServiceAccountState.CLOSED
        }
        self.resource_backend.remove_users_from_resource(waldur_resource, usernames_closed)

    def _sync_resource_course_accounts(self, waldur_resource: WaldurResource) -> None:
        """Sync course accounts between Waldur and the backend resource."""
        logger.info(
            "Syncing course accounts for the resource %s (%s)",
            waldur_resource.name,
            waldur_resource.backend_id,
        )
        if self.service_provider is None:
            logger.warning("No service provider configured, skipping service accounts sync")
            return

        course_accounts = marketplace_service_providers_course_accounts_list.sync_all(
            service_provider_uuid=self.service_provider.uuid.hex,
            project_uuid=waldur_resource.project_uuid.hex,
            client=self.waldur_rest_client,
        )
        usernames_active = {
            account.username
            for account in course_accounts
            if account.username and account.state == ServiceAccountState.OK
        }
        self.resource_backend.add_users_to_resource(waldur_resource, usernames_active)

        usernames_closed = {
            account.username
            for account in course_accounts
            if account.username and account.state == ServiceAccountState.CLOSED
        }
        self.resource_backend.remove_users_from_resource(waldur_resource, usernames_closed)


class OfferingOrderProcessor(OfferingBaseProcessor):
    """Processor for handling Waldur marketplace orders.

    This processor fetches pending and executing orders from Waldur and manages
    their lifecycle by creating, updating, or terminating backend resources.
    It handles the complete order workflow from approval through completion.

    The processor supports three types of orders:
    - CREATE: Creates new resources in the backend system
    - UPDATE: Modifies existing resource limits or configurations
    - TERMINATE: Removes resources from the backend system

    Order processing includes user context management, retry logic for failures,
    and comprehensive error handling with automatic order state updates.
    """

    BACKEND_TYPE_KEY = "order_processing_backend"

    def log_order_processing_error(self, order: OrderDetails, e: Exception) -> None:
        """Log detailed error information for order processing failures.

        Args:
            order: The order that failed to process
            e: The exception that occurred during processing
        """
        name = order.resource_name or "N/A"
        logger.exception(
            "Error while processing order %s (%s), type %s, state %s: %s",
            order.uuid,
            name,
            order.type_,
            order.state,
            e,
        )

    def _fetch_service_provider_ssh_keys(self) -> dict[str, str]:
        """Fetch all SSH keys for the service provider.

        Returns a mapping of key UUID (hex string) to public key text.
        Backends can use this to resolve SSH key UUID references from
        resource attributes without needing direct Waldur API access.
        """
        if self.service_provider is None:
            return {}
        try:
            keys = marketplace_service_providers_keys_list.sync(
                service_provider_uuid=self.service_provider.uuid.hex,
                client=self.waldur_rest_client,
            )
            if not keys:
                return {}
            return {
                str(getattr(key, "uuid", "")): getattr(key, "public_key", "")
                for key in keys
                if getattr(key, "uuid", None) and getattr(key, "public_key", None)
            }
        except Exception as e:
            logger.warning("Failed to fetch service provider SSH keys: %s", e)
            return {}

    def process_offering(self) -> None:
        """Process all pending and executing orders for this offering.

        Fetches orders from Waldur with PENDING_PROVIDER or EXECUTING status
        and processes each order according to its type and current state.
        Includes error handling and logging for individual order failures.
        """
        logger.info(
            "Processing offering %s (%s)",
            self.offering.name,
            self.offering.uuid,
        )
        orders = marketplace_orders_list.sync_all(
            client=self.waldur_rest_client,
            offering_uuid=self.offering.uuid,
            state=[
                OrderState.PENDING_PROVIDER,
                OrderState.EXECUTING,
            ],
        )

        if not orders:
            logger.info("There are no pending or executing orders")
            return
        for order in orders:
            try:
                self.process_order_with_retries(order)
            except Exception as e:
                self.log_order_processing_error(order, e)

    def get_order_info(self, order_uuid: str) -> Optional[OrderDetails]:
        """Retrieve current order information from Waldur API.

        Args:
            order_uuid: UUID of the order to retrieve

        Returns:
            OrderDetails if found, None if retrieval fails
        """
        try:
            return marketplace_orders_retrieve.sync(client=self.waldur_rest_client, uuid=order_uuid)
        except UnexpectedStatus as e:
            logger.error("Failed to get order %s info: %s", order_uuid, e)
            return None

    def process_order_with_retries(
        self, order_info: OrderDetails, retry_count: int = 10, delay: int = 5
    ) -> None:
        """Process an order with automatic retry on failures.

        Args:
            order_info: The order to process (used directly on first attempt)
            retry_count: Maximum number of retry attempts (default: 10)
            delay: Delay in seconds between retry attempts (default: 5)
        """
        for attempt_number in range(retry_count):
            try:
                logger.info("Attempt %s of %s", attempt_number + 1, retry_count)
                if attempt_number == 0:
                    # Use the already-fetched order on the first attempt
                    order = order_info
                else:
                    # Re-fetch on retries for freshness
                    order_fetched: Optional[OrderDetails] = self.get_order_info(
                        order_info.uuid.hex
                    )
                    if order_fetched is None:
                        logger.error("Failed to get order %s info", order_info.uuid)
                        return
                    order = order_fetched
                self.process_order(order)
                break
            except UnexpectedStatus as e:
                self.log_order_processing_error(order, e)
                logger.info("Retrying order %s processing in %s seconds", order_info.uuid, delay)
                sleep(delay)

        if attempt_number == retry_count - 1:
            logger.error(
                "Failed to process order %s after %s retries, skipping to the next one",
                order_info.uuid,
                retry_count,
            )

    def process_order(self, order: OrderDetails) -> None:
        """Process a single order through its complete lifecycle.

        Handles order approval (if needed), determines the order type,
        and delegates to the appropriate processing method. Updates
        order state to DONE on success or ERRED on failure.

        Args:
            order: The order to process
        """
        try:
            logger.info(
                "Processing order %s (%s) type %s, state %s",
                order.resource_name or "N/A",
                order.uuid,
                order.type_,
                order.state,
            )

            if order.state in [OrderState.DONE, OrderState.ERRED]:
                logger.info(
                    "Order %s (%s) is in a terminal state %s, skipping processing",
                    order.resource_name or "N/A",
                    order.uuid,
                    order.state,
                )
                return

            if order.state == OrderState.EXECUTING:
                logger.info("Order is executing already, no need for approval")
            elif order.state == OrderState.PENDING_PROVIDER:
                logger.info("Approving the order")
                marketplace_orders_approve_by_provider.sync_detailed(
                    client=self.waldur_rest_client,
                    uuid=order.uuid.hex,
                    body=OrderApproveByProviderRequest(),
                )
                logger.info("Refreshing the order")
                order = marketplace_orders_retrieve.sync(
                    client=self.waldur_rest_client, uuid=order.uuid.hex
                )
            else:
                logger.warning(
                    "The order %s %s (%s) is in unexpected state %s, skipping processing",
                    order.type_,
                    order.resource_name,
                    order.uuid,
                    order.state,
                )
                return

            order_is_done = False
            if order.type_ == RequestTypes.CREATE:
                order_is_done = self._process_create_order(order)

            if order.type_ == RequestTypes.UPDATE:
                order_is_done = self._process_update_order(order)

            if order.type_ == RequestTypes.TERMINATE:
                order_is_done = self._process_terminate_order(order)
            # TODO: no need for update of orders for marketplace SLURM offerings
            if order_is_done:
                logger.info("Marking order as done")
                waldur_order_refreshed = marketplace_orders_retrieve.sync(
                    client=self.waldur_rest_client,
                    uuid=order.uuid.hex,
                    field=[
                        OrderDetailsFieldEnum.UUID,
                        OrderDetailsFieldEnum.STATE,
                    ],
                )
                if waldur_order_refreshed.state == OrderState.EXECUTING:
                    marketplace_orders_set_state_done.sync_detailed(
                        client=self.waldur_rest_client, uuid=order.uuid.hex
                    )
                else:
                    logger.info(
                        "Order is not in EXECUTING state, "
                        "skipping set_state_done operation"
                    )

                logger.info("The order has been successfully processed")

                self._post_process_order(order)
            else:
                logger.warning("The order processing was not finished, skipping to the next one")

        except Exception as e:
            logger.exception(
                "Error while processing order %s: %s",
                order.uuid,
                e,
            )
            if order.state != OrderState.DONE:
                order_error_details_request = OrderErrorDetailsRequest(
                    error_message=str(e),
                    error_traceback=traceback.format_exc(),
                )
                marketplace_orders_set_state_erred.sync_detailed(
                    client=self.waldur_rest_client,
                    uuid=order.uuid,
                    body=order_error_details_request,
                )

    def _create_resource(
        self,
        waldur_resource: WaldurResource,
        user_context: dict,
    ) -> BackendResourceInfo | None:
        """Create a new resource in the backend system.

        Enriches user_context with pre-resolved data (plan quotas, SSH keys)
        before delegating to the backend. After creation, pushes backend_metadata
        and resource limits to Waldur.

        Args:
            waldur_resource: Waldur resource information
            user_context: User context containing team and offering user data

        Returns:
            Created backend resource or None if creation failed

        Raises:
            BackendError: If resource creation fails
        """
        logger.info("Creating resource %s", waldur_resource.name)

        # Enrich user_context with pre-resolved data for backends
        plan_uuid = getattr(waldur_resource, "plan_uuid", None)
        user_context["plan_quotas"] = self._resolve_plan_limits(
            plan_uuid, waldur_resource.name
        )
        user_context["ssh_keys"] = self._fetch_service_provider_ssh_keys()

        # Use the provided user context for resource creation
        backend_resource_info = self._create_resource_with_uniqueness_check(
            waldur_resource, user_context
        )
        if backend_resource_info.backend_id == "":
            msg = f"Unable to create a backend resource for offering {self.offering}"
            raise backend_exceptions.BackendError(msg)

        logger.info("Updating resource metadata in Waldur")
        marketplace_provider_resources_set_backend_id.sync(
            client=self.waldur_rest_client,
            uuid=waldur_resource.uuid.hex,
            body=ResourceBackendIDRequest(backend_id=backend_resource_info.backend_id),
        )
        logger.info("Resource backend id is set to %s", backend_resource_info.backend_id)

        if backend_resource_info.limits:
            marketplace_provider_resources_set_limits.sync(
                uuid=waldur_resource.uuid.hex,
                body=ResourceSetLimitsRequest(limits=backend_resource_info.limits),
                client=self.waldur_rest_client,
            )
            logger.info("Resource limits are set to %s", backend_resource_info.limits)

        # Push backend metadata if the backend provided any
        if backend_resource_info.backend_metadata:
            try:
                marketplace_provider_resources_set_backend_metadata.sync(
                    uuid=waldur_resource.uuid.hex,
                    client=self.waldur_rest_client,
                    body=ResourceBackendMetadataRequest(
                        backend_metadata=backend_resource_info.backend_metadata
                    ),
                )
                logger.info("Pushed backend metadata to Waldur resource")
            except Exception as e:
                logger.warning("Failed to push backend metadata to Waldur: %s", e)

        return backend_resource_info

    def _fetch_user_context_for_resource(self, resource_uuid: str) -> dict:
        """Fetch comprehensive user context for resource operations.

        Retrieves project team members and offering users, creating mappings
        for efficient user lookup during resource creation and management.

        Args:
            resource_uuid: UUID of the resource to fetch context for

        Returns:
            Dictionary containing:
            - team: List of project team members
            - offering_users: List of offering users with usernames
            - user_mappings: Mapping of user UUIDs to ProjectUser objects
            - offering_user_mappings: Mapping of user UUIDs to OfferingUser objects

        Raises:
            ObjectNotFoundError: If no team members found for the resource
        """
        try:
            logger.info("Fetching user context for resource %s", resource_uuid)
            # Get project team members
            team = marketplace_provider_resources_team_list.sync(
                client=self.waldur_rest_client, uuid=resource_uuid
            )

            if not team:
                raise ObjectNotFoundError(f"No team members found for resource {resource_uuid}")  # noqa: TRY301

            user_uuids = {user.uuid for user in team}

            # Get offering users
            offering_users_all = self._get_cached_offering_users()

            if not offering_users_all:
                logger.warning("No offering users found for offering %s", self.offering.uuid)
                offering_users = []
            else:
                # Filter offering users to only those in the project team
                offering_users = [
                    offering_user
                    for offering_user in offering_users_all
                    if offering_user.user_uuid in user_uuids
                ]

            # Create user mappings for easy lookup
            user_mappings = {user.uuid: user for user in team}
            offering_user_mappings = {
                offering_user.user_uuid: offering_user for offering_user in offering_users
            }
        except Exception as e:
            logger.warning("Failed to fetch user context for resource %s: %s", resource_uuid, e)
            return {
                "team": [],
                "offering_users": [],
                "user_mappings": {},
                "offering_user_mappings": {},
            }
        else:
            return {
                "team": team,
                "offering_users": offering_users,
                "user_mappings": user_mappings,
                "offering_user_mappings": offering_user_mappings,
            }

    def _add_users_to_resource(
        self,
        waldur_resource: WaldurResource,
        user_context: dict,
    ) -> None:
        """Add offering users to the backend resource.

        Updates offering user usernames if needed and adds all users
        with valid usernames to the backend resource.

        Args:
            waldur_resource: The Waldur resource to add users to
            user_context: User context containing offering users and mappings
        """
        logger.info("Adding users to resource")
        offering_users = user_context["offering_users"]

        # Update offering user usernames (only for users with blank usernames)
        if self._update_offering_users(offering_users):
            # Refresh local user_context cache
            user_context_new = self._fetch_user_context_for_resource(waldur_resource.uuid.hex)
            user_context.update(user_context_new)

        logger.info(
            "Using %s (%s) offering users, user count: %s",
            self.offering.name,
            self.offering.uuid,
            len(user_context["offering_users"]),
        )

        # Filter only team members with usernames set and in OK state
        offering_usernames = {
            offering_user.username
            for offering_user in user_context["offering_users"]
            if offering_user.state == OfferingUserState.OK
        }

        if not offering_usernames:
            logger.info("No users to add to resource")
            return

        self.resource_backend.add_users_to_resource(
            waldur_resource,
            offering_usernames,
            homedir_umask=self.offering.backend_settings.get("homedir_umask", "0700"),
        )

    def _process_create_order(self, order: OrderDetails | None) -> bool:
        """Process a CREATE order to establish a new resource.

        Supports both synchronous and asynchronous resource creation.
        When a backend returns a pending_order_id, the source order stays
        EXECUTING until the target order completes (checked on subsequent cycles).

        Args:
            order: The CREATE order to process

        Returns:
            True if processing completed successfully, False otherwise
        """
        if not order:
            logger.error("Error during order processing: Order is None")
            return False

        # Check for pending async order (order.backend_id = target order UUID).
        # Only used by backends that opt in via supports_async_orders flag
        # (e.g., Waldur-to-Waldur federation). External systems may also set
        # order.backend_id for their own purposes, so this check must be skipped
        # for backends that don't use async order tracking.
        if self.resource_backend.supports_async_orders:
            order_backend_id = (
                ""
                if isinstance(order.backend_id, type(UNSET))
                else (order.backend_id or "")
            )
            if order_backend_id:
                is_done = self.resource_backend.check_pending_order(order_backend_id)
                if is_done:
                    logger.info("Target order %s completed successfully", order_backend_id)
                    return True
                logger.info("Target order %s still pending", order_backend_id)
                return False

        waldur_resource = marketplace_provider_resources_retrieve.sync(
            uuid=order.marketplace_resource_uuid.hex, client=self.waldur_rest_client
        )

        create_resource = True
        if waldur_resource.backend_id != "":
            logger.info(
                "Waldur resource backend id is not empty %s, checking backend resource data",
                waldur_resource.backend_id,
            )
            backend_resource_info = self.resource_backend.pull_resource(waldur_resource)
            if backend_resource_info is not None:
                logger.info(
                    "Resource %s (%s) is already created, skipping creation",
                    waldur_resource.name,
                    waldur_resource.backend_id,
                )
                create_resource = False

        # Fetch user context for resource creation
        user_context = self._fetch_user_context_for_resource(order.marketplace_resource_uuid.hex)

        backend_resource_info = None
        if create_resource:
            backend_resource_info = self._create_resource(
                waldur_resource, user_context
            )
            if backend_resource_info is None:
                msg = f"Unable to create the resource {waldur_resource.name}"
                raise backend_exceptions.BackendError(msg)

            # Handle async creation: set order backend_id and stay EXECUTING.
            # Only used by backends with supports_async_orders enabled.
            if (
                backend_resource_info.pending_order_id
                and self.resource_backend.supports_async_orders
            ):
                logger.info(
                    "Async order created: setting order %s backend_id to %s",
                    order.uuid,
                    backend_resource_info.pending_order_id,
                )
                marketplace_orders_set_backend_id.sync(
                    client=self.waldur_rest_client,
                    uuid=order.uuid,
                    body=OrderBackendIDRequest(
                        backend_id=backend_resource_info.pending_order_id
                    ),
                )
                return False  # Order stays EXECUTING

        if backend_resource_info is None:
            return False

        waldur_resource.backend_id = backend_resource_info.backend_id

        return True

    def _process_update_order(self, order: OrderDetails) -> bool:
        """Process an UPDATE order to modify resource limits.

        Args:
            order: The UPDATE order containing new limit specifications

        Returns:
            True if update completed successfully, False otherwise

        Raises:
            ObjectNotFoundError: If the target resource is not found
        """
        logger.info("Updating limits for %s", order.resource_name)
        resource_uuid = order.marketplace_resource_uuid
        waldur_resource = marketplace_provider_resources_retrieve.sync(
            uuid=resource_uuid, client=self.waldur_rest_client
        )
        old_limits = waldur_resource.limits.to_dict() if waldur_resource.limits else {}

        waldur_resource_backend_id = waldur_resource.backend_id
        new_limits = order.limits.to_dict() if order.limits else {}
        if not new_limits:
            # For plan-based updates (FIXED components), the order carries
            # a new plan but no explicit limits.  Resolve the plan's
            # component quotas and use them as the new limits.
            # Use the order's plan (the NEW plan), not the resource's
            # current plan which hasn't been updated yet.
            order_plan_uuid = getattr(order, "plan_uuid", None)
            new_limits = self._resolve_plan_limits(order_plan_uuid, order.resource_name)
        if not new_limits:
            logger.warning(
                "Order %s (resource %s) with type Update does not include "
                "new limits and has no resolvable plan quotas",
                order.uuid,
                waldur_resource.name,
            )
        elif new_limits == old_limits:
            logger.info(
                "Limits for %s are unchanged (%s), skipping backend update",
                waldur_resource.name,
                new_limits,
            )
        else:
            self.resource_backend.set_resource_limits(waldur_resource_backend_id, new_limits)

            logger.info(
                "The limits for %s were updated successfully from %s to %s",
                waldur_resource.name,
                old_limits,
                new_limits,
            )
        return True

    def _resolve_plan_limits(
        self, plan_uuid: object, resource_name: str = ""
    ) -> dict[str, int]:
        """Resolve resource limits from a plan's fixed-component quotas.

        When an offering uses FIXED billing components, the plan's quotas
        define the resource specification.  This method fetches those quotas
        so they can be treated as the effective limits during an update.

        Args:
            plan_uuid: UUID of the plan to fetch quotas from.
            resource_name: Resource name for log messages.

        Returns:
            Mapping of component type to quantity, or empty dict.
        """
        if not plan_uuid:
            return {}
        try:
            plan = marketplace_plans_retrieve.sync(
                uuid=str(plan_uuid), client=self.waldur_rest_client
            )
            if plan and hasattr(plan, "quotas") and plan.quotas:
                raw = (
                    plan.quotas.to_dict()
                    if hasattr(plan.quotas, "to_dict")
                    else plan.quotas
                )
                quotas = {k: int(v) for k, v in raw.items() if v}
                if quotas:
                    logger.info(
                        "Resolved plan '%s' quotas as limits for %s: %s",
                        getattr(plan, "name", plan_uuid),
                        resource_name,
                        quotas,
                    )
                return quotas
        except Exception:
            logger.warning(
                "Failed to resolve plan quotas for %s (plan %s)",
                resource_name,
                plan_uuid,
                exc_info=True,
            )
        return {}

    def _process_terminate_order(self, order: OrderDetails) -> bool:
        """Process a TERMINATE order to remove a resource.

        Args:
            order: The TERMINATE order specifying the resource to remove

        Returns:
            True if termination completed successfully, False otherwise

        Raises:
            ObjectNotFoundError: If the target resource is not found
        """
        logger.info("Terminating resource %s", order.resource_name)
        resource_uuid = order.marketplace_resource_uuid
        waldur_resource = marketplace_provider_resources_retrieve.sync(
            uuid=resource_uuid, client=self.waldur_rest_client
        )
        project_slug = order.project_slug

        self.resource_backend.delete_resource(
            waldur_resource,
            project_slug=project_slug,
        )

        logger.info("Allocation has been terminated successfully")
        return True

    def _post_process_order(self, order: OrderDetails) -> bool:
        if order.type_ == RequestTypes.CREATE:
            logger.info(
                "Running post process order script for %s (%s)",
                order.resource_name or "N/A",
                order.uuid.hex,
            )
            # Get Waldur resource data
            waldur_resource: WaldurResource = marketplace_provider_resources_retrieve.sync(
                uuid=order.marketplace_resource_uuid.hex, client=self.waldur_rest_client
            )

            # Fetch fresh user context for adding users
            user_context = self._fetch_user_context_for_resource(
                order.marketplace_resource_uuid.hex
            )

            # Add users to the backend resource
            self._add_users_to_resource(waldur_resource, user_context)

            # Sync service and course accounts
            self._sync_resource_service_accounts(waldur_resource)
            self._sync_resource_course_accounts(waldur_resource)

        return True


class OfferingMembershipProcessor(OfferingBaseProcessor):
    """Processor for synchronizing user memberships between Waldur and backends.

    This processor handles bidirectional synchronization of user access and
    memberships between Waldur and backend systems. It processes resource
    status changes, user additions/removals, and maintains consistency
    between the systems.

    Key responsibilities:
    - Synchronize user lists between Waldur project teams and backend resources
    - Handle resource status changes (paused, downscaled, restored)
    - Manage user limits and permissions on backend resources
    - Process event-driven user role changes
    - Update resource metadata and sync timestamps in Waldur

    The processor supports both full synchronization and incremental updates
    based on specific events or scheduled operations.
    """

    BACKEND_TYPE_KEY = "membership_sync_backend"

    def __init__(
        self,
        offering: structures.Offering,
        waldur_rest_client: utils.AuthenticatedClient,
        timezone: str = "",
        resource_backend: Optional[BaseBackend] = None,
        resource_backend_version: Optional[str] = None,
    ) -> None:
        """Initialize the membership processor with per-cycle caches."""
        super().__init__(
            offering,
            waldur_rest_client,
            timezone,
            resource_backend=resource_backend,
            resource_backend_version=resource_backend_version,
        )
        # Per-cycle caches to avoid redundant API calls per project
        self._team_cache: dict[str, list[ProjectUser]] = {}
        self._service_accounts_cache: dict[str, list[ProjectServiceAccount]] = {}
        self._course_accounts_cache: dict[str, list[CourseAccount]] = {}

    def _get_waldur_resources(self, project_uuid: Optional[str] = None) -> list[WaldurResource]:
        """Fetch Waldur resources for this offering, optionally filtered by project.

        Args:
            project_uuid: If provided, only return resources from this project

        Returns:
            List of resources that have backend IDs and are in OK or ERRED state
        """
        filters: dict[str, Any] = {
            "offering_uuid": [self.offering.uuid],
            "state": [
                ResourceState.OK,
                ResourceState.ERRED,
            ],
            "field": [
                ResourceFieldEnum.UUID,
                ResourceFieldEnum.BACKEND_ID,
                ResourceFieldEnum.NAME,
                ResourceFieldEnum.SLUG,
                ResourceFieldEnum.STATE,
                ResourceFieldEnum.PROJECT_UUID,
                ResourceFieldEnum.PROJECT_SLUG,
                ResourceFieldEnum.CUSTOMER_SLUG,
                ResourceFieldEnum.RESTRICT_MEMBER_ACCESS,
                ResourceFieldEnum.BACKEND_METADATA,
                ResourceFieldEnum.LIMITS,
                ResourceFieldEnum.PAUSED,
                ResourceFieldEnum.DOWNSCALED,
                ResourceFieldEnum.OFFERING_PLUGIN_OPTIONS,
                ResourceFieldEnum.OFFERING_BACKEND_ID,
            ],
        }

        if project_uuid is not None:
            filters["project_uuid"] = project_uuid
        waldur_resources = marketplace_provider_resources_list.sync_all(
            client=self.waldur_rest_client,
            **filters,
        )

        waldur_resources_filtered = [
            waldur_resource for waldur_resource in waldur_resources if waldur_resource.backend_id
        ]

        logger.info(
            "Fetched %s resources (%s with backend_id set) under %s offering",
            len(waldur_resources),
            len(waldur_resources_filtered),
            self.offering.name,
        )

        return waldur_resources_filtered

    def process_resource_by_uuid(self, resource_uuid: str) -> None:
        """Process a specific resource's status and membership data.

        This method processes a single resource identified by UUID,
        performing full synchronization of its status and user memberships.

        Args:
            resource_uuid: UUID of the resource to process

        Raises:
            ObjectNotFoundError: If the resource is not found in Waldur
        """
        logger.info("Processing resource state and membership data, uuid: %s", resource_uuid)
        logger.info("Fetching resource from Waldur")
        waldur_resource = marketplace_provider_resources_retrieve.sync(
            uuid=resource_uuid, client=self.waldur_rest_client
        )
        if waldur_resource.state not in [
            ResourceState.OK,
            ResourceState.ERRED,
        ]:
            logger.info(
                "Resource %s (%s) is in state %s, skipping processing",
                waldur_resource.name,
                waldur_resource.uuid,
                waldur_resource.state,
            )
            return

        logger.info(
            "Pulling resource %s (%s) from backend",
            waldur_resource.name,
            waldur_resource.backend_id,
        )
        resource_report = self.resource_backend.pull_resources([waldur_resource])
        if not resource_report:
            return
        self._process_resources(resource_report)

    def process_offering(self) -> None:
        """Process all resources in this offering for membership synchronization.

        Fetches all resources for the offering and processes each one to
        synchronize user memberships, resource status, and metadata between
        Waldur and the backend system.
        """
        logger.info(
            "Processing offering %s (%s)",
            self.offering.name,
            self.offering.uuid,
        )

        waldur_resources_info = self._get_waldur_resources()
        resource_report = self.resource_backend.pull_resources(waldur_resources_info)

        self._process_resources(resource_report)

    def _get_user_offering_users(
        self, user_uuid: str, offering_uuid: str | None = None
    ) -> list[OfferingUser]:
        """Fetch offering users for a specific user UUID.

        Args:
            user_uuid: UUID of the user to look up
            offering_uuid: optional UUID of the offering

        Returns:
            List of offering users associated with the specified user and the offering if specified

        Raises:
            ObjectNotFoundError: If no offering users found for the user
        """
        filters: dict[str, Any] = {"user_uuid": user_uuid, "is_restricted": False}
        if offering_uuid is not None:
            filters["offering_uuid"] = [offering_uuid]
        return marketplace_offering_users_list.sync_all(client=self.waldur_rest_client, **filters)

    def process_user_role_changed(self, user_uuid: str, project_uuid: str, granted: bool) -> None:
        """Process a user role change event.

        Handles adding or removing a user from backend resources when their
        project role changes. Respects resource access restrictions.

        Args:
            user_uuid: UUID of the user whose role changed
            project_uuid: UUID of the project where the role changed
            granted: True if access was granted, False if revoked
        """
        offering_users: list[OfferingUser] = self._get_user_offering_users(
            user_uuid, self.offering.uuid
        )
        if len(offering_users) == 0:
            logger.info(
                "User %s is not linked to the offering %s (%s)",
                user_uuid,
                self.offering.name,
                self.offering.uuid,
            )
            return

        if self._update_offering_users(offering_users):
            # Refresh offering users after username generation
            offering_users = self._get_user_offering_users(user_uuid, self.offering.uuid)

        username = offering_users[0].username
        logger.info("Using offering user with username %s", username)
        if not username:
            logger.warning("Username is blank, skipping processing")
            return

        resources: list[WaldurResource] = self._get_waldur_resources(project_uuid=project_uuid)
        resource_report = self.resource_backend.pull_resources(resources)

        for waldur_resource, _ in resource_report.values():
            try:
                if granted:
                    if waldur_resource.restrict_member_access:
                        logger.info("The resource is restricted, skipping new role.")
                        continue
                    self.resource_backend.add_user(waldur_resource, username)
                else:
                    self.resource_backend.remove_user(waldur_resource, username)
            except Exception as exc:
                logger.error(
                    "Unable to add user %s to the resource %s, error: %s",
                    username,
                    waldur_resource.backend_id,
                    exc,
                )

    def _validate_offering_user_configuration(self) -> bool:
        """Validate that the offering is configured to support OfferingUser.

        Returns:
            True if the offering supports OfferingUser, False otherwise.
        """
        offering_details = marketplace_provider_offerings_retrieve.sync(
            client=self.waldur_rest_client,
            uuid=self.offering.uuid,
            field=[ProviderOfferingDetailsFieldEnum.PLUGIN_OPTIONS],
        )
        plugin_options = offering_details.plugin_options
        service_provider_can_create = plugin_options.service_provider_can_create_offering_user

        if isinstance(service_provider_can_create, type(UNSET)) or not service_provider_can_create:
            logger.error(
                "Offering %s (%s) does not have 'service_provider_can_create_offering_user' "
                "set to True in plugin_options. Membership sync requires OfferingUser support.",
                self.offering.name,
                self.offering.uuid,
            )
            return False
        return True

    def _refresh_local_offering_users(self) -> list[OfferingUser]:
        try:
            # Validate offering configuration for OfferingUser support
            if not self._validate_offering_user_configuration():
                return []

            # Fetch offering users
            offering_users: list[OfferingUser] = self._get_waldur_offering_users()

            # Update offering user usernames
            if self._update_offering_users(offering_users):
                # Refresh offering users after username generation
                return self._get_waldur_offering_users()
            return offering_users
        except Exception as exc:
            logger.error("Unable to refresh local offering users: %s", exc)
            return []

    def _sync_user_profiles_to_backend(self, offering_users: list[OfferingUser]) -> None:
        """Sync user profiles via the username management backend (if configured)."""
        username_management_backend, _ = utils.get_username_management_backend(self.offering)
        if isinstance(username_management_backend, UnknownUsernameManagementBackend):
            return
        username_management_backend.sync_user_profiles(offering_users)

    def process_project_user_sync(self, project_uuid: str) -> None:
        """Perform full user synchronization for all resources in a project.

        This method synchronizes all users across all resources within
        a specific project, ensuring consistency between Waldur project
        teams and backend resource access.

        Args:
            project_uuid: UUID of the project to synchronize
        """
        logger.info("Processing sync of all users for project %s", project_uuid)
        resources = self._get_waldur_resources(project_uuid=project_uuid)
        resource_report = self.resource_backend.pull_resources(resources)
        # Fetch offering users
        offering_users = self._refresh_local_offering_users()
        self._sync_user_profiles_to_backend(offering_users)

        # Pull backend-assigned usernames (e.g., from Waldur B offering users)
        if (
            self.offering.username_reconciliation_enabled
            and self.resource_backend.sync_offering_user_usernames(
                self.offering.uuid, self.waldur_rest_client
            )
        ):
            offering_users = self._get_waldur_offering_users()

        for waldur_resource, backend_resource_info in resource_report.values():
            try:
                logger.info(
                    "Syncing users for resource %s (%s)",
                    waldur_resource.name,
                    waldur_resource.backend_id,
                )
                self._sync_resource_users(waldur_resource, backend_resource_info, offering_users)
            except Exception as exc:
                logger.error(
                    "Unable to sync resource %s (%s), error: %s",
                    waldur_resource.name,
                    waldur_resource.backend_id,
                    exc,
                )

    def _get_waldur_offering_users(self) -> list[OfferingUser]:
        """Fetch all offering users for this offering.

        Uses the per-cycle cache and filters to OK and REQUESTED states.

        Returns:
            List of all non-restricted offering users for this offering
        """
        logger.info("Fetching Waldur offering users (OK and Requested)")
        all_offering_users = self._get_cached_offering_users()
        offering_users = [
            ou
            for ou in all_offering_users
            if ou.state in (OfferingUserState.OK, OfferingUserState.REQUESTED)
        ]
        logger.info("Fetched %d offering users", len(offering_users))
        return offering_users

    def _get_waldur_resource_team(self, resource: WaldurResource) -> list[ProjectUser]:
        cache_key = resource.project_uuid.hex
        if cache_key in self._team_cache:
            logger.info("Using cached team for project %s", cache_key)
            return self._team_cache[cache_key]
        logger.info("Fetching Waldur resource team")
        team = marketplace_provider_resources_team_list.sync(
            client=self.waldur_rest_client, uuid=resource.uuid.hex
        )
        self._team_cache[cache_key] = team
        return team

    def _group_resource_usernames(
        self,
        waldur_resource: WaldurResource,
        backend_resource_info: BackendResourceInfo,
        offering_users: list[OfferingUser],
    ) -> tuple[set[str], set[str], set[str], dict[str, str]]:
        logger.info("Fetching new, existing and stale resource users")
        usernames: list[str] = backend_resource_info.users
        local_usernames = set(usernames)
        logger.info(
            "The usernames from the backend (%s): %s",
            len(local_usernames),
            ", ".join(local_usernames),
        )

        # Offering users sync
        # The service fetches offering users from Waldur and pushes them to the cluster
        # If an offering user is not in the team anymore, it will be removed from the backend
        team: list[ProjectUser] = self._get_waldur_resource_team(waldur_resource)

        offering_user_usernames = {
            offering_user.username for offering_user in offering_users if offering_user.username
        }

        resource_usernames = {
            user.offering_user_username for user in team if user.offering_user_username
        }
        logger.info(
            "Resource offering usernames (%s): %s",
            len(resource_usernames),
            ", ".join(str(u) for u in resource_usernames),
        )

        # Build user_roles mapping (username -> Waldur role) for backends that need it
        user_roles = {
            user.offering_user_username: user.role
            for user in team
            if user.offering_user_username and user.role
        }

        logger.info("Number of offering user usernames: %s", len(offering_user_usernames))

        existing_usernames: set[str] = resource_usernames & local_usernames
        logger.info(
            "Resource existing usernames (%s): %s",
            len(existing_usernames),
            ", ".join(existing_usernames),
        )

        new_usernames: set[str] = resource_usernames - local_usernames
        logger.info("Resource new usernames (%s): %s", len(new_usernames), ", ".join(new_usernames))

        stale_usernames: set[str] = (offering_user_usernames & local_usernames) - resource_usernames
        logger.info(
            "Resource stale usernames (%s): %s", len(stale_usernames), ", ".join(stale_usernames)
        )

        return existing_usernames, stale_usernames, new_usernames, user_roles

    def _sync_resource_users(
        self,
        waldur_resource: WaldurResource,
        backend_resource_info: BackendResourceInfo,
        offering_users: list[OfferingUser],
    ) -> set[str]:
        """Sync users for the resource between Waldur and the site.

        return: the actual resource usernames (existing + added)
        """
        logger.info("Syncing user list for resource %s", waldur_resource.name)

        existing_usernames, stale_usernames, new_usernames, user_roles = (
            self._group_resource_usernames(
                waldur_resource, backend_resource_info, offering_users
            )
        )

        if waldur_resource.restrict_member_access:
            # The idea is to remove the existing associations in both sides
            # and avoid creation of new associations
            logger.info(
                "Resource is restricted for members, removing all the existing associations"
            )

            self.resource_backend.remove_users_from_resource(waldur_resource, existing_usernames)
            return set()

        added_usernames = self.resource_backend.add_users_to_resource(
            waldur_resource,
            new_usernames,
            homedir_umask=self.offering.backend_settings.get("homedir_umask", "0700"),
            user_roles=user_roles,
        )

        self.resource_backend.remove_users_from_resource(
            waldur_resource,
            stale_usernames,
        )

        self.resource_backend.process_existing_users(existing_usernames)

        return existing_usernames | added_usernames

    def _sync_resource_status(self, waldur_resource: WaldurResource) -> None:
        """Syncs resource status between Waldur and the backend."""
        logger.info(
            "Syncing resource status for resource %s (%s)",
            waldur_resource.name,
            waldur_resource.backend_id,
        )
        if waldur_resource.paused:
            logger.info("Resource pausing is requested, processing it")
            pausing_done = self.resource_backend.pause_resource(waldur_resource.backend_id)
            if pausing_done:
                logger.info("Pausing is successfully completed")
            else:
                logger.warning("Pausing is not done")
        elif waldur_resource.downscaled:
            logger.info("Resource downscaling is requested, processing it")
            downscaling_done = self.resource_backend.downscale_resource(waldur_resource.backend_id)
            if downscaling_done:
                logger.info("Downscaling is successfully completed")
            else:
                logger.warning("Downscaling is not done")
        else:
            logger.info(
                "The resource is not downscaled or paused, resetting the QoS to the default one"
            )
            restoring_done = self.resource_backend.restore_resource(waldur_resource.backend_id)
            if restoring_done:
                logger.info("Restoring is successfully completed")
            else:
                logger.info("Restoring is skipped")

        resource_metadata = self.resource_backend.get_resource_metadata(waldur_resource.backend_id)
        marketplace_provider_resources_set_backend_metadata.sync(
            uuid=waldur_resource.uuid.hex,
            client=self.waldur_rest_client,
            body=ResourceBackendMetadataRequest(backend_metadata=resource_metadata),
        )

    def _sync_resource_limits(self, waldur_resource: WaldurResource) -> None:
        """Syncs resource limits between Waldur and the backend."""
        utils.sync_waldur_resource_limits(
            self.resource_backend, self.waldur_rest_client, waldur_resource
        )

    # TODO: adapt for RabbitMQ-based processing
    # introduce new event and add support for the event in the agent
    def _sync_resource_user_limits(
        self, waldur_resource: WaldurResource, usernames: set[str]
    ) -> None:
        logger.info(
            "Synching resource user limits for resource %s (%s)",
            waldur_resource.name,
            waldur_resource.backend_id,
        )
        if waldur_resource.restrict_member_access:
            logger.info("Resource is restricted for members, skipping user limits setup")
            return

        backend_user_limits = self.resource_backend.get_resource_user_limits(
            waldur_resource.backend_id
        )

        # Fetch all user limits for the resource in a single API call
        all_user_limits = component_user_usage_limits_list.sync_all(
            client=self.waldur_rest_client,
            resource_uuid=waldur_resource.uuid.hex,
        )

        # Group limits by username (the API model uses `user` for the username string)
        limits_by_username: dict[str, dict[str, int]] = {}
        for user_limit in all_user_limits:
            limit_username = user_limit.user
            if limit_username not in limits_by_username:
                limits_by_username[limit_username] = {}
            limits_by_username[limit_username][user_limit.component_type] = int(
                float(user_limit.limit)
            )

        for username in usernames:
            try:
                user_component_limits = limits_by_username.get(username)
                if user_component_limits is None:
                    existing_user_limits = backend_user_limits.get(username)
                    logger.debug("The limits for user %s are not set in Waldur", username)
                    if not existing_user_limits:
                        continue
                    logger.info("Unsetting the existing limits %s", existing_user_limits)
                    user_component_limits = {}
                self.resource_backend.set_resource_user_limits(
                    waldur_resource.backend_id, username, user_component_limits
                )
            except Exception as exc:
                logger.error(
                    "Unable to set user %s limits for resource %s (%s), reason: %s",
                    username,
                    waldur_resource.name,
                    waldur_resource.backend_id,
                    exc,
                )

    def _sync_resource_service_accounts(self, waldur_resource: WaldurResource) -> None:
        """Sync service accounts with per-project caching."""
        if self.service_provider is None:
            logger.warning("No service provider configured, skipping service accounts sync")
            return

        cache_key = waldur_resource.project_uuid.hex
        if cache_key in self._service_accounts_cache:
            service_accounts = self._service_accounts_cache[cache_key]
        else:
            service_accounts = (
                marketplace_service_providers_project_service_accounts_list.sync_all(
                    service_provider_uuid=self.service_provider.uuid.hex,
                    project_uuid=cache_key,
                    client=self.waldur_rest_client,
                )
            )
            self._service_accounts_cache[cache_key] = service_accounts

        logger.info(
            "Syncing service accounts for the resource %s (%s)",
            waldur_resource.name,
            waldur_resource.backend_id,
        )
        usernames_active = {
            account.username
            for account in service_accounts
            if account.username and account.state == ServiceAccountState.OK
        }
        self.resource_backend.add_users_to_resource(waldur_resource, usernames_active)

        usernames_closed = {
            account.username
            for account in service_accounts
            if account.username and account.state == ServiceAccountState.CLOSED
        }
        self.resource_backend.remove_users_from_resource(waldur_resource, usernames_closed)

    def _sync_resource_course_accounts(self, waldur_resource: WaldurResource) -> None:
        """Sync course accounts with per-project caching."""
        if self.service_provider is None:
            logger.warning("No service provider configured, skipping course accounts sync")
            return

        cache_key = waldur_resource.project_uuid.hex
        if cache_key in self._course_accounts_cache:
            course_accounts = self._course_accounts_cache[cache_key]
        else:
            course_accounts = marketplace_service_providers_course_accounts_list.sync_all(
                service_provider_uuid=self.service_provider.uuid.hex,
                project_uuid=cache_key,
                client=self.waldur_rest_client,
            )
            self._course_accounts_cache[cache_key] = course_accounts

        logger.info(
            "Syncing course accounts for the resource %s (%s)",
            waldur_resource.name,
            waldur_resource.backend_id,
        )
        usernames_active = {
            account.username
            for account in course_accounts
            if account.username and account.state == ServiceAccountState.OK
        }
        self.resource_backend.add_users_to_resource(waldur_resource, usernames_active)

        usernames_closed = {
            account.username
            for account in course_accounts
            if account.username and account.state == ServiceAccountState.CLOSED
        }
        self.resource_backend.remove_users_from_resource(waldur_resource, usernames_closed)

    def _process_resources(
        self,
        resource_report: dict[str, tuple[WaldurResource, BackendResourceInfo]],
    ) -> None:
        """Sync status and membership data for the resource."""
        # Fetch offering users
        offering_users = self._refresh_local_offering_users()
        self._sync_user_profiles_to_backend(offering_users)

        # Pull backend-assigned usernames (e.g., from Waldur B offering users)
        if (
            self.offering.username_reconciliation_enabled
            and self.resource_backend.sync_offering_user_usernames(
                self.offering.uuid, self.waldur_rest_client
            )
        ):
            offering_users = self._get_waldur_offering_users()

        for waldur_resource, backend_resource_info in resource_report.values():
            try:
                resource_usernames = self._sync_resource_users(
                    waldur_resource, backend_resource_info, offering_users
                )
                self._sync_resource_service_accounts(waldur_resource)
                self._sync_resource_course_accounts(waldur_resource)
                self._sync_resource_status(waldur_resource)
                self._sync_resource_limits(waldur_resource)
                self._sync_resource_user_limits(waldur_resource, resource_usernames)

                logger.info(
                    "Refreshing resource %s (%s) last sync",
                    waldur_resource.name,
                    waldur_resource.backend_id,
                )

                marketplace_provider_resources_refresh_last_sync.sync_detailed(
                    uuid=waldur_resource.uuid.hex,
                    client=self.waldur_rest_client,
                )
                if waldur_resource.state == ResourceState.ERRED:
                    logger.info(
                        "Setting resource %s (%s) state to OK",
                        waldur_resource.name,
                        waldur_resource.backend_id,
                    )
                    marketplace_provider_resources_set_as_ok.sync_detailed(
                        uuid=waldur_resource.uuid.hex,
                        client=self.waldur_rest_client,
                    )
            except Exception as e:
                logger.exception(
                    "Error while processing allocation %s: %s",
                    waldur_resource.backend_id,
                    e,
                )
                error_traceback = traceback.format_exc()
                utils.mark_waldur_resources_as_erred(
                    self.waldur_rest_client,
                    [waldur_resource],
                    error_details={
                        "error_message": str(e),
                        "error_traceback": error_traceback,
                    },
                )

    def process_account_creation(self, account_username: str, account_type: AccountType) -> None:
        """Process service or course account creation."""
        params = {
            "service_provider_uuid": self.service_provider.uuid.hex,
            "username": account_username,
            "client": self.waldur_rest_client,
        }
        if account_type == AccountType.SERVICE_ACCOUNT:
            accounts = marketplace_service_providers_project_service_accounts_list.sync_all(
                **params
            )
        else:
            accounts = marketplace_service_providers_course_accounts_list.sync_all(**params)

        if len(accounts) == 0:
            logger.info(
                "No %s accounts found with username %s, skipping processing for offering %s (%s)",
                account_type.value,
                account_username,
                self.offering.uuid,
                self.offering.name,
            )
            return

        account: ProjectServiceAccount | CourseAccount = accounts[0]
        resources = self._get_waldur_resources(account.project_uuid.hex)
        for resource in resources:
            try:
                self.resource_backend.add_users_to_resource(resource, {account_username})
            except BackendError as e:
                logger.error(
                    "Unable to add the account %s to resource %s, reason: %s",
                    account_username,
                    resource.backend_id,
                    e,
                )

    def process_account_removal(self, account_username: str, project_uuid: str) -> None:
        """Process course account removal."""
        resources = self._get_waldur_resources(project_uuid)
        for resource in resources:
            try:
                self.resource_backend.remove_users_from_resource(resource, {account_username})
            except BackendError as e:
                logger.error(
                    "Unable to remove the account %s from resource %s, reason: %s",
                    account_username,
                    resource.backend_id,
                    e,
                )


class OfferingReportProcessor(OfferingBaseProcessor):
    """Processor for collecting and reporting usage data from backends to Waldur.

    This processor handles the collection of resource usage data from backend
    systems and reports it to Waldur for billing and monitoring purposes.
    It processes both total resource usage and per-user usage breakdowns.

    Key responsibilities:
    - Collect usage data from backend systems for all resources
    - Validate usage data for anomalies (decreasing usage patterns)
    - Report total resource usage to Waldur marketplace
    - Report per-user usage breakdowns for detailed billing
    - Handle error cases and mark resources as erred when backend data is missing
    - Implement retry logic for transient failures

    The processor includes anomaly detection to prevent reporting usage data
    that appears to have decreased from previous reports, which typically
    indicates a data collection error.

    Supports multi-period reporting: by default reports both the current month
    and the previous month. Controlled by ``reporting_periods`` (default 1
    in the constructor for backward compatibility; the agent passes the
    configured value, which defaults to 2).
    """

    BACKEND_TYPE_KEY = "reporting_backend"

    def __init__(
        self,
        offering: structures.Offering,
        waldur_rest_client: utils.AuthenticatedClient,
        timezone: str = "",
        resource_backend: Optional[BaseBackend] = None,
        resource_backend_version: Optional[str] = None,
        reporting_periods: int = 1,
    ) -> None:
        """Initialize the report processor.

        Args:
            offering: The offering configuration to process.
            waldur_rest_client: HTTP Client for Waldur API.
            timezone: Timezone for billing period calculations.
            resource_backend: Backend instance for resource operations.
            resource_backend_version: Version of the resource backend.
            reporting_periods: Number of billing periods to report
                (1 = current month only, 2 = current + previous).
        """
        super().__init__(
            offering,
            waldur_rest_client,
            timezone,
            resource_backend=resource_backend,
            resource_backend_version=resource_backend_version,
        )
        self.reporting_periods = reporting_periods

    @staticmethod
    def _compute_reporting_periods(
        current_time: datetime.datetime, num_periods: int
    ) -> list[tuple[int, int, bool]]:
        """Compute the list of (year, month, is_current) billing periods.

        Returns periods oldest-first so that the current month is processed last.

        Args:
            current_time: A datetime with `year` and `month` attributes.
            num_periods: Number of periods to report (1 = current only).

        Returns:
            List of (year, month, is_current_month) tuples, oldest first.
        """
        periods: list[tuple[int, int, bool]] = []
        year = current_time.year
        month = current_time.month

        for i in range(num_periods):
            is_current = i == 0
            periods.append((year, month, is_current))
            # Move to previous month
            month -= 1
            if month < 1:
                month = 12
                year -= 1

        # Reverse so oldest is first, current is last
        periods.reverse()
        return periods

    def _get_reporting_timezone(self) -> ZoneInfo | None:
        """Return the configured timezone as a ZoneInfo, or None."""
        if self.timezone:
            try:
                return ZoneInfo(self.timezone)
            except Exception:
                return None
        return None

    def process_offering(self) -> None:
        """Process all resources in this offering for usage reporting.

        Fetches all OK and ERRED resources for the offering and processes
        each one to collect and report usage data to Waldur. Includes
        error handling for individual resource failures.
        """
        logger.info(
            "Processing offering %s (%s)",
            self.offering.name,
            self.offering.uuid,
        )
        waldur_offering = marketplace_provider_offerings_retrieve.sync(
            client=self.waldur_rest_client, uuid=self.offering.uuid
        )
        waldur_resources = marketplace_provider_resources_list.sync_all(
            client=self.waldur_rest_client,
            offering_uuid=[self.offering.uuid],
            state=[
                ResourceState.OK,
                ResourceState.ERRED,
            ],
            field=[
                ResourceFieldEnum.UUID,
                ResourceFieldEnum.BACKEND_ID,
                ResourceFieldEnum.NAME,
                ResourceFieldEnum.SLUG,
                ResourceFieldEnum.STATE,
                ResourceFieldEnum.PROJECT_UUID,
                ResourceFieldEnum.PROJECT_SLUG,
                ResourceFieldEnum.CUSTOMER_SLUG,
                ResourceFieldEnum.LIMITS,
                ResourceFieldEnum.BACKEND_METADATA,
                ResourceFieldEnum.OFFERING_PLUGIN_OPTIONS,
                ResourceFieldEnum.OFFERING_BACKEND_ID,
            ],
        )
        if not waldur_resources:
            logger.info("No resources to process")
            return

        logger.info(
            "Fetched %s resources under %s offering", len(waldur_resources), self.offering.name
        )

        for waldur_resource in waldur_resources:
            try:
                self._process_resource_with_retries(waldur_resource, waldur_offering)
            except Exception as e:
                logger.exception(
                    "Error while processing allocation %s: %s",
                    waldur_resource.backend_id,
                    e,
                )

    def _process_resource_with_retries(
        self,
        waldur_resource: WaldurResource,
        waldur_offering: ProviderOfferingDetails,
        retry_count: int = 10,
        delay: int = 5,
    ) -> None:
        for attempt_number in range(retry_count):
            try:
                logger.info(
                    "Attempt %s of %s, processing resource usage %s (%s)",
                    attempt_number + 1,
                    retry_count,
                    waldur_resource.name,
                    waldur_resource.backend_id,
                )
                self._process_resource(waldur_resource, waldur_offering)
                break
            except Exception as e:
                logger.warning(
                    "Error while processing resource %s (%s): %s",
                    waldur_resource.name,
                    waldur_resource.backend_id,
                    e,
                )
                logger.info(
                    "Retrying resource usage %s processing in %s seconds",
                    waldur_resource.backend_id,
                    delay,
                )
                if attempt_number == retry_count - 1:
                    # If last attempt failed, raise the exception
                    logger.warning(
                        "Failed to process resource usage %s after %s retries,"
                        "skipping to the next resource",
                        waldur_resource.backend_id,
                        retry_count,
                    )
                    raise
                # If not last attempt, wait and retry
                sleep(delay)

    def _check_usage_anomaly(
        self,
        component_type: str,
        current_usage: float,
        existing_usages: list[ComponentUsage] | None,
    ) -> bool:
        """Check if the current usage is lower than existing usage."""
        if self.resource_backend.supports_decreasing_usage:
            return False
        if not existing_usages:
            return False
        # Find all usage records for this component type
        component_usages = [usage for usage in existing_usages if usage.type_ == component_type]

        if not component_usages:
            return False

        if len(component_usages) > 1:
            logger.error(
                "Found multiple usage records for component %s in the same billing period: %d",
                component_type,
                len(component_usages),
            )
            return True

        component_usage = component_usages[0]
        existing_usage = float(component_usage.usage)
        if current_usage < existing_usage:
            logger.error(
                "Usage anomaly detected for component %s: "
                "Current usage %s is lower than existing usage %s",
                component_type,
                current_usage,
                existing_usage,
            )
            return True

        return False

    def _submit_total_usage_for_resource(
        self,
        waldur_resource: WaldurResource,
        total_usage: dict[str, float],
        waldur_components: list[OfferingComponent],
        report_date: Optional[datetime.datetime] = None,
        billing_period_start: Optional[datetime.date] = None,
    ) -> None:
        """Reports total usage for a backend resource to Waldur.

        Args:
            waldur_resource: The Waldur resource to report usage for.
            total_usage: Dict of component_type -> usage amount.
            waldur_components: List of offering components from Waldur.
            report_date: Datetime to use as the report date. Defaults to current time.
            billing_period_start: Date of the billing period start. Defaults to
                first day of report_date's month.
        """
        logger.info("Setting usages for %s: %s", waldur_resource.backend_id, total_usage)
        resource_uuid = waldur_resource.uuid.hex

        component_types: list[str] = [
            component.type_ for component in waldur_components if component.type_
        ]
        missing_components = set(total_usage) - set(component_types)
        if missing_components:
            logger.warning(
                "The following components are not found in Waldur: %s",
                ", ".join(missing_components),
            )

        if report_date is None:
            report_date = backend_utils.get_current_time_in_timezone(self.timezone)
        if billing_period_start is None:
            billing_period_start = backend_utils.month_start(report_date).date()

        if not self.resource_backend.supports_decreasing_usage:
            existing_usages = marketplace_component_usages_list.sync_all(
                client=self.waldur_rest_client,
                resource_uuid=resource_uuid,
                billing_period=billing_period_start,
                field=[
                    ComponentUsageFieldEnum.UUID,
                    ComponentUsageFieldEnum.TYPE,
                    ComponentUsageFieldEnum.USAGE,
                ],
            )

            # Filter out component usages that have per-user breakdowns;
            # only aggregate records should participate in anomaly detection.
            user_usages = marketplace_component_user_usages_list.sync_all(
                client=self.waldur_rest_client,
                resource_uuid=resource_uuid,
                component_usage_billing_period=billing_period_start,
            )
            user_usage_parent_uuids = set()
            for uu in user_usages:
                if not isinstance(uu.component_usage, type(UNSET)) and uu.component_usage:
                    # component_usage is a URL like ".../marketplace-component-usages/{uuid}/"
                    parent_uuid = uu.component_usage.rstrip("/").split("/")[-1]
                    user_usage_parent_uuids.add(parent_uuid)

            aggregate_usages = [
                u for u in existing_usages
                if str(u.uuid) not in user_usage_parent_uuids
            ] if user_usage_parent_uuids else existing_usages

            for component, amount in total_usage.items():
                if component in component_types and self._check_usage_anomaly(
                    component, amount, aggregate_usages
                ):
                    logger.warning(
                        "Skipping usage update for resource %s due to anomaly detection",
                        waldur_resource.backend_id,
                    )
                    raise UsageAnomalyError(
                        f"Usage anomaly detected for component {component}"
                    )

        usage_objects = [
            ComponentUsageItemRequest(type_=component, amount=str(amount))
            for component, amount in total_usage.items()
            if component in component_types
        ]
        request_body = ComponentUsageCreateRequest(
            usages=usage_objects, resource=resource_uuid, date=report_date
        )
        marketplace_component_usages_set_usage.sync_detailed(
            client=self.waldur_rest_client, body=request_body
        )

    def _submit_bulk_user_usages_for_resource(
        self,
        usages: dict[str, dict[str, float]],
        waldur_component_usages: list[ComponentUsage] | None,
        report_date: Optional[datetime.datetime] = None,
    ) -> None:
        """Reports per-user usage for all users in bulk, one API call per component.

        Args:
            usages: Mapping of username -> {component_type -> usage amount}.
            waldur_component_usages: List of component usage records from Waldur.
            report_date: Datetime to use as the report date. Defaults to current time.
        """
        if not waldur_component_usages:
            logger.warning("No component usages found, skipping per-user usage reporting")
            return

        if report_date is None:
            report_date = backend_utils.get_current_time_in_timezone(self.timezone)

        # Build offering user lookup: username -> offering user URL
        cached_offering_users = self._get_cached_offering_users()
        offering_user_urls: dict[str, str] = {
            ou.username: ou.url
            for ou in cached_offering_users
            if ou.username and ou.url
        }

        for component_usage in waldur_component_usages:
            component_type = component_usage.type_
            items: list[ComponentUserUsageCreateRequest] = []
            for username, user_usage in usages.items():
                usage = user_usage.get(component_type)
                if usage is None:
                    continue
                body = ComponentUserUsageCreateRequest(
                    username=username,
                    usage=usage,
                    date=report_date,
                )
                user_url = offering_user_urls.get(username)
                if user_url:
                    body.user = user_url
                items.append(body)

            if not items:
                continue

            logger.info(
                "Bulk submitting %d user usages for component %s",
                len(items),
                component_type,
            )
            marketplace_component_usages_set_user_usages.sync_detailed(
                uuid=component_usage.uuid,
                client=self.waldur_rest_client,
                body=ComponentUserUsageBulkCreateRequest(usages=items),
            )

    def _process_resource(
        self,
        waldur_resource: WaldurResource,
        waldur_offering: ProviderOfferingDetails,
    ) -> None:
        """Processes usage report for the resource across configured billing periods."""
        resource_backend_id = waldur_resource.backend_id
        logger.info("Pulling resource %s (%s)", waldur_resource.name, resource_backend_id)

        # Record the month BEFORE pulling resource data so we can detect
        # a month-boundary crossing (e.g. pull at 23:59 Mar 31, process at 00:00 Apr 1).
        pre_pull_time = backend_utils.get_current_time_in_timezone(self.timezone)

        backend_resource_info = self.resource_backend.pull_resource(waldur_resource)
        if backend_resource_info is None:
            logger.warning("The resource %s is missing in backend", resource_backend_id)
            return

        # Invalidate cache of Waldur resource
        logger.info(
            "Fetching Waldur resource data for %s (%s)", waldur_resource.name, resource_backend_id
        )
        waldur_resource_info: WaldurResource = marketplace_provider_resources_retrieve.sync(
            client=self.waldur_rest_client, uuid=waldur_resource.uuid.hex
        )

        current_time = backend_utils.get_current_time_in_timezone(self.timezone)

        # If the month changed between pulling usage data and computing billing
        # periods, skip this resource.  The pre-fetched usage belongs to the old
        # month and would be incorrectly submitted into the new billing period.
        # The next reporting cycle (every 30 min) will handle it correctly.
        if (pre_pull_time.year, pre_pull_time.month) != (
            current_time.year,
            current_time.month,
        ):
            logger.warning(
                "Month boundary crossed while processing resource %s "
                "(data fetched for %04d-%02d, now %04d-%02d). "
                "Skipping to avoid misattributing usage across billing periods.",
                resource_backend_id,
                pre_pull_time.year,
                pre_pull_time.month,
                current_time.year,
                current_time.month,
            )
            return

        periods = self._compute_reporting_periods(current_time, self.reporting_periods)

        for year, month, is_current in periods:
            try:
                self._process_resource_period(
                    waldur_resource_info,
                    waldur_offering,
                    resource_backend_id,
                    backend_resource_info,
                    year,
                    month,
                    is_current,
                )
            except UnexpectedStatus as e:
                http_bad_request = 400
                if not is_current and e.status_code == http_bad_request:
                    logger.info(
                        "Past period %04d-%02d rejected (HTTP 400) for resource %s, "
                        "likely usage-based component — skipping",
                        year, month, resource_backend_id,
                    )
                else:
                    raise
            except UsageAnomalyError:
                if is_current:
                    logger.info(
                        "Skipping per-user usage processing due to anomaly in usage reporting"
                    )
                    return
                logger.info(
                    "Skipping past period %04d-%02d for resource %s due to usage anomaly",
                    year, month, resource_backend_id,
                )
            except Exception:
                if is_current:
                    raise
                logger.warning(
                    "Failed to report past period %04d-%02d for resource %s, continuing",
                    year, month, resource_backend_id,
                    exc_info=True,
                )

    def _process_resource_period(
        self,
        waldur_resource_info: WaldurResource,
        waldur_offering: ProviderOfferingDetails,
        resource_backend_id: str,
        backend_resource_info: BackendResourceInfo,
        year: int,
        month: int,
        is_current: bool,
    ) -> None:
        """Process a single billing period for a resource."""
        billing_period_start = datetime.date(year, month, 1)
        # Build a report_date in the target period for the Waldur API,
        # preserving the same timezone as get_current_time_in_timezone uses.
        tz = self._get_reporting_timezone()
        report_date = datetime.datetime(year, month, 1, tzinfo=tz)

        if is_current:
            # Use current-month data already fetched by pull_resource
            usages = dict(backend_resource_info.usage)
        else:
            logger.info(
                "Fetching historical usage for %s, period %04d-%02d",
                resource_backend_id, year, month,
            )
            period_report = self.resource_backend.get_usage_report_for_period(
                [resource_backend_id], year, month
            )
            usages = period_report.get(resource_backend_id, {})
            if not usages:
                logger.info(
                    "No historical data for %s in %04d-%02d, skipping",
                    resource_backend_id, year, month,
                )
                return

        total_usage = usages.pop("TOTAL_ACCOUNT_USAGE", None)
        if total_usage is None:
            logger.warning(
                "No TOTAL_ACCOUNT_USAGE for %s in %04d-%02d, skipping",
                resource_backend_id, year, month,
            )
            return

        self._submit_total_usage_for_resource(
            waldur_resource_info,
            total_usage,
            waldur_offering.components,
            report_date=report_date,
            billing_period_start=billing_period_start,
        )

        # Skip per-user usage if the dict is empty
        if not usages:
            return

        waldur_component_usages = marketplace_component_usages_list.sync_all(
            client=self.waldur_rest_client,
            resource_uuid=waldur_resource_info.uuid.hex,
            billing_period=billing_period_start,
            field=[
                ComponentUsageFieldEnum.UUID,
                ComponentUsageFieldEnum.TYPE,
                ComponentUsageFieldEnum.USAGE,
            ],
        )
        logger.info("Setting per-user usages for period %04d-%02d", year, month)
        self._submit_bulk_user_usages_for_resource(
            usages, waldur_component_usages, report_date=report_date
        )


class OfferingImportableResourcesProcessor(OfferingBaseProcessor):
    """Processes importable resources for the offering and reports them to Waldur."""

    BACKEND_TYPE_KEY = "order_processing_backend"

    def process_offering(self) -> None:
        """This function is blank because the processor operates over backend resource request."""

    def _process_importable_resource(self, local_resource_info: BackendResourceInfo) -> None:
        logger.info("Processing importable resource %s", local_resource_info.backend_id)
        project_prefix = self.offering.backend_settings.get("project_prefix", "")
        parent_id = local_resource_info.parent_id
        if not parent_id.startswith(project_prefix):
            logger.info(
                "The parent_id %s of the resource %s does not have a required prefix, skipping it",
                parent_id,
                local_resource_info.backend_id,
            )
            return

        project_slug = parent_id.removeprefix(project_prefix)
        waldur_projects = projects_list.sync(
            slug=project_slug,
            client=self.waldur_rest_client,
        )
        if not waldur_projects:
            logger.info(
                "No Waldur project found for slug %s, skipping resource %s import",
                project_slug,
                local_resource_info.backend_id,
            )
            return
        waldur_project = waldur_projects[0]
        existing_backend_resources = backend_resources_list.sync_all(
            backend_id=local_resource_info.backend_id,
            project_uuid=waldur_project.uuid,
            offering_uuid=self.offering.uuid,
            client=self.waldur_rest_client,
        )

        if existing_backend_resources:
            logger.info(
                "Backend resource with id %s already exists in Waldur project %s, "
                "skipping submission",
                local_resource_info.backend_id,
                waldur_project.uuid.hex,
            )
            return

        logger.info(
            "Submitting backend resource %s to Waldur project %s",
            local_resource_info.backend_id,
            waldur_project.uuid.hex,
        )
        limits = self.resource_backend.get_resource_limits(local_resource_info.backend_id)
        backend_metadata = {
            "limits": limits,
        }
        payload = BackendResourceRequest(
            name=local_resource_info.backend_id,
            project=waldur_project.uuid,
            offering=self.offering.uuid,
            backend_id=local_resource_info.backend_id,
            backend_metadata=backend_metadata,
        )
        backend_resources_create.sync(
            body=payload,
            client=self.waldur_rest_client,
        )

    def _pre_process_backend_resource_request(self, request_uuid: str) -> None:
        backend_resource_request = backend_resource_requests_retrieve.sync(
            uuid=request_uuid,
            client=self.waldur_rest_client,
        )
        backend_resource_requests_start_processing.sync(
            uuid=backend_resource_request.uuid.hex,
            client=self.waldur_rest_client,
        )

    def _get_waldur_resources(self) -> dict[str, WaldurResource]:
        waldur_resources = marketplace_provider_resources_list.sync_all(
            offering_uuid=[self.offering.uuid],
            state=[
                ResourceState.OK,
                ResourceState.ERRED,
                ResourceState.CREATING,
            ],
            client=self.waldur_rest_client,
        )

        waldur_resources_filtered = {
            resource.backend_id: resource for resource in waldur_resources if resource.backend_id
        }

        logger.info(
            "Fetched %s resources (%s with backend_id set) under %s offering",
            len(waldur_resources),
            len(waldur_resources_filtered),
            self.offering.name,
        )
        return waldur_resources_filtered

    def process_request(self, request_uuid: str) -> None:
        """Process backend resource request.

        List all resource in the backend, compare their backend_ids with ones from Waldur
        and report only ones absent in Waldur.
        """
        try:
            logger.info(
                "Processing backend resource request %s for offering %s",
                request_uuid,
                self.offering.name,
            )

            self._pre_process_backend_resource_request(request_uuid)
            waldur_resources: dict[str, WaldurResource] = self._get_waldur_resources()

            local_resource_list = self.resource_backend.list_resources()
            local_resources = {resource.backend_id: resource for resource in local_resource_list}

            importable_resource_backend_ids = local_resources.keys() - waldur_resources.keys()
            logger.info(
                "Found %d importable resources in the backend",
                len(importable_resource_backend_ids),
            )

            for backend_id in importable_resource_backend_ids:
                try:
                    local_resource = local_resources[backend_id]
                    self._process_importable_resource(local_resource)
                except Exception as e:
                    logger.error(
                        "Unable to process importable resource %s reason: %s", backend_id, e
                    )

            logger.info("Setting backend resource request %s as done", request_uuid)
            backend_resource_requests_set_done.sync(
                uuid=request_uuid,
                client=self.waldur_rest_client,
            )
        except Exception as e:
            logger.info("Unable to process importable resources reason: %s", e)
            payload = BackendResourceRequestSetErredRequest(
                error_message=str(e),
                error_traceback=traceback.format_exc(),
            )
            backend_resource_requests_set_erred.sync(
                uuid=request_uuid,
                client=self.waldur_rest_client,
                body=payload,
            )
