from __future__ import annotations

from typing import TYPE_CHECKING, List, Optional, Callable, Any, Tuple, Set
from sqlalchemy import and_, or_
from sqlalchemy.orm import Session, joinedload
from ..schema import CreatePermissionSchema
from ..models import (
    Role,
    RolePermission,
    UserRole,
    Permission,
    UserPermission
)
from abs_utils.logger import setup_logger

logger = setup_logger(__name__)
from abs_exception_core.exceptions import (
    DuplicatedError,
    NotFoundError,
    PermissionDeniedError
)

from .cache import PermissionCache, PermissionCacheEntry
from .notifier import CacheInvalidationNotifier, CacheInvalidationNotifierConfig

if TYPE_CHECKING:
    from .cache_protocol import CacheStore
    from .redis_cache import RedisCacheConfig


class RBACService:
    def __init__(
        self,
        session: Callable[..., Session],
        servicebus_config: Optional[CacheInvalidationNotifierConfig] = None,
        cache_ttl: float = 300.0,
        cache_max_size: int = 10_000,
        cache_store: Optional[CacheStore] = None,
        redis_config: Optional[RedisCacheConfig] = None,
        **kwargs,
    ):
        """
        Service for managing RBAC with pluggable permission cache.

        Args:
            session: Callable that returns a SQLAlchemy Session context manager
            servicebus_config: Optional config for Azure Service Bus cache invalidation
            cache_ttl: Cache entry TTL in seconds (default 5 min)
            cache_max_size: Max users in cache before LRU eviction (default 10,000)
            cache_store: Explicit cache backend (takes priority over all other cache options)
            redis_config: Config to use Redis as the cache backend
            **kwargs: Accepted for backward compatibility (e.g. old redis_config)
        """
        self.db = session

        if cache_store is not None:
            self._cache = cache_store
        elif redis_config is not None:
            from .redis_cache import RedisCache
            self._cache = RedisCache(redis_config)
        else:
            self._cache = PermissionCache(ttl=cache_ttl, max_size=cache_max_size)
        self._notifier = None
        self._using_shared_cache = redis_config is not None or cache_store is not None

        if servicebus_config and self._using_shared_cache:
            logger.warning(
                "Both a shared cache (Redis/custom) and servicebus_config were provided. "
                "Service Bus invalidation is redundant when all servers share the same cache. "
                "Initializing notifier anyway — remove servicebus_config to suppress this warning."
            )

        if servicebus_config:
            try:
                self._notifier = CacheInvalidationNotifier(servicebus_config)
                self._notifier.set_callback(self._on_invalidation)
                self._notifier.start()
                logger.info("Cache invalidation notifier initialized successfully")
            except Exception as e:
                logger.error(f"Failed to initialize cache invalidation notifier: {e}")
                self._notifier = None

    # =========================================================================
    # CACHE LOADING
    # =========================================================================

    def _load_user_permissions(self, user_uuid: str) -> PermissionCacheEntry:
        """Load all permissions for a user from DB and cache them."""
        with self.db() as session:
            # Check if user has super_admin role
            user_roles = (
                session.query(Role)
                .join(
                    UserRole,
                    and_(
                        UserRole.role_uuid == Role.uuid,
                        UserRole.user_uuid == user_uuid,
                    ),
                )
                .options(joinedload(Role.permissions))
                .all()
            )

            is_super_admin = any(role.name == "super_admin" for role in user_roles)

            # Collect role-based permissions
            role_permissions: Set[Tuple[str, str, str]] = set()
            for role in user_roles:
                for perm in role.permissions:
                    role_permissions.add((perm.resource, perm.action, perm.module))

            # Collect direct user permissions
            direct_permissions: Set[Tuple[str, str, str]] = set()
            user_perms = (
                session.query(UserPermission)
                .options(joinedload(UserPermission.permission))
                .filter(UserPermission.user_uuid == user_uuid)
                .all()
            )
            for up in user_perms:
                if up.permission:
                    direct_permissions.add(
                        (up.permission.resource, up.permission.action, up.permission.module)
                    )

            entry = PermissionCacheEntry(
                role_permissions=role_permissions,
                direct_permissions=direct_permissions,
                is_super_admin=is_super_admin,
                ttl=self._cache.ttl,
            )
            self._cache.set_user(user_uuid, entry)
            return entry

    def _get_user_entry(self, user_uuid: str) -> PermissionCacheEntry:
        """Get cached entry or load from DB."""
        entry = self._cache.get_user(user_uuid)
        if entry is None:
            entry = self._load_user_permissions(user_uuid)
        return entry

    def debug_user_cache(self, user_uuid: str) -> dict:
        """Return cache state and fresh DB load for debugging."""
        cached = self._cache.get_user(user_uuid)
        fresh = self._load_user_permissions(user_uuid)
        return {
            "user_uuid": user_uuid,
            "was_cached": cached is not None,
            "cached_role_perms": sorted(cached.role_permissions) if cached else None,
            "cached_direct_perms": sorted(cached.direct_permissions) if cached else None,
            "cached_is_super_admin": cached.is_super_admin if cached else None,
            "db_role_perms": sorted(fresh.role_permissions),
            "db_direct_perms": sorted(fresh.direct_permissions),
            "db_is_super_admin": fresh.is_super_admin,
        }

    def _get_users_for_role(self, role_uuid: str) -> List[str]:
        """Get all user UUIDs assigned to a role."""
        with self.db() as session:
            user_roles = (
                session.query(UserRole.user_uuid)
                .filter(UserRole.role_uuid == role_uuid)
                .all()
            )
            return [ur.user_uuid for ur in user_roles]

    def _refresh_users(self, user_uuids: List[str]) -> None:
        """Eagerly reload permissions for multiple users into cache."""
        for uid in user_uuids:
            self._load_user_permissions(uid)

    def _invalidate_role_users(self, role_uuid: str, affected_users: List[str] = None) -> List[str]:
        """Refresh cache for all users with a given role and notify.

        Args:
            affected_users: Pre-fetched user UUIDs. When provided, skips the
                            extra DB session that _get_users_for_role opens.
        """
        if affected_users is None:
            affected_users = self._get_users_for_role(role_uuid)
        if affected_users:
            self._refresh_users(affected_users)
        if self._notifier and affected_users and not self._using_shared_cache:
            self._notifier.publish_role_invalidation(role_uuid, affected_users)
        return affected_users

    def _invalidate_user(self, user_uuid: str) -> None:
        """Eagerly reload a user's permissions into cache and notify.

        When using a shared cache (Redis/custom), the eager reload is
        already visible to all servers so no Service Bus message is needed.
        """
        self._load_user_permissions(user_uuid)
        if self._notifier and not self._using_shared_cache:
            self._notifier.publish_user_invalidation(user_uuid)

    def _invalidate_all(self) -> None:
        """Invalidate entire cache and notify."""
        self._cache.invalidate_all()
        if self._notifier and not self._using_shared_cache:
            self._notifier.publish_all_invalidation()

    # =========================================================================
    # INVALIDATION CALLBACK (from Service Bus)
    # =========================================================================

    def _on_invalidation(self, data: dict) -> None:
        """Callback when a cache invalidation message arrives from another server.

        When using a shared cache (Redis), the server that made the change
        already wrote fresh data — so we skip the delete to avoid removing it.
        """
        # Handle old Casbin watcher format (has 'method' instead of 'type').
        # These come from server instances still running the pre-v0.6.0 library.
        if "method" in data:
            self._on_legacy_invalidation(data)
            return

        msg_type = data.get("type", "unknown")
        logger.info(f"[RBAC] Received cache invalidation notification: type={msg_type}, data={data}")

        if self._using_shared_cache:
            logger.info(
                f"[RBAC] Skipping invalidation — shared cache already has fresh data "
                f"(type={msg_type})"
            )
            return

        if msg_type == "user":
            user_uuid = data.get("user_uuid", "")
            if user_uuid:
                self._cache.invalidate_user(user_uuid)
                logger.info(f"[RBAC] Cache updated: invalidated user {user_uuid}")
        elif msg_type == "role":
            user_uuids = data.get("user_uuids", [])
            if user_uuids:
                self._cache.invalidate_users(user_uuids)
                logger.info(
                    f"[RBAC] Cache updated: invalidated {len(user_uuids)} users "
                    f"(role {data.get('role_uuid', 'unknown')})"
                )
        elif msg_type == "all":
            self._cache.invalidate_all()
            logger.info("[RBAC] Cache updated: full invalidation")
        else:
            logger.warning(f"[RBAC] Unknown invalidation type: {msg_type}, data={data}")

    def _on_legacy_invalidation(self, data: dict) -> None:
        """Handle old Casbin watcher messages (pre-v0.6.0 servers).

        Old format examples:
          {'method': 'Update', 'id': 'user-service'}
          {'method': 'UpdateForRemoveFilteredPolicy', 'id': 'user-service',
           'sec': 'p', 'ptype': 'p', 'params': '0 <role_uuid>'}
          {'method': 'UpdateForAddPolicies', 'id': 'user-service',
           'sec': 'p', 'ptype': 'p', 'params': [[['<role_uuid>', ...], ...]]}

        Strategy: extract the role UUID from params when possible and
        invalidate only that role's users. Otherwise fall back to full
        cache invalidation.
        """
        if self._using_shared_cache:
            logger.info("[RBAC] Skipping legacy invalidation — shared cache already has fresh data")
            return

        method = data.get("method", "")
        params = data.get("params")

        role_uuid = self._extract_role_uuid_from_legacy(method, params)

        if role_uuid:
            self._invalidate_role_users(role_uuid)
        else:
            self._cache.invalidate_all()

    @staticmethod
    def _extract_role_uuid_from_legacy(method: str, params) -> Optional[str]:
        """Try to extract a role UUID from old Casbin watcher params."""
        try:
            if method == "UpdateForRemoveFilteredPolicy" and isinstance(params, str):
                # params = '0 <role_uuid>'  (field_index role_uuid)
                parts = params.split()
                if len(parts) >= 2:
                    return parts[1]

            if method == "UpdateForAddPolicies" and isinstance(params, list):
                # params = [[[role_uuid, perm_id, action, module], ...]]
                policies = params[0] if params and isinstance(params[0], list) else params
                if policies and isinstance(policies[0], list) and len(policies[0]) >= 1:
                    return policies[0][0]
        except (IndexError, TypeError):
            pass
        return None

    # =========================================================================
    # PERMISSION CHECKS (cache-first, SQL on miss)
    # =========================================================================

    def check_permission(self, user_uuid: str, resource: str, action: str, module: str) -> bool:
        """Check if user has permission via roles (cache-first)."""
        entry = self._get_user_entry(user_uuid)
        if entry.is_super_admin:
            return True
        return (resource, action, module) in entry.role_permissions

    def check_permission_by_user(self, user_uuid: str, resource: str, action: str, module: str) -> bool:
        """Check if user has direct permission (cache-first)."""
        entry = self._get_user_entry(user_uuid)
        if entry.is_super_admin:
            return True
        return (resource, action, module) in entry.direct_permissions

    def check_permission_by_role(self, role_uuid: str, resource: str, action: str, module: str) -> bool:
        """Check if a specific role has a permission (direct SQL query)."""
        with self.db() as session:
            count = (
                session.query(RolePermission)
                .join(Permission, RolePermission.permission_uuid == Permission.uuid)
                .filter(
                    RolePermission.role_uuid == role_uuid,
                    Permission.resource == resource,
                    Permission.action == action,
                    Permission.module == module,
                )
                .count()
            )
            return count > 0

    # =========================================================================
    # PERMISSION CRUD
    # =========================================================================

    async def bulk_create_permissions(self, permissions: List[CreatePermissionSchema]):
        with self.db() as session:
            try:
                if not permissions:
                    return []
                if hasattr(permissions[0], 'model_dump'):
                    add_permissions = [Permission(**permission.model_dump()) for permission in permissions]
                else:
                    add_permissions = [Permission(**permission) for permission in permissions]
                session.bulk_save_objects(add_permissions)
                session.commit()
                return add_permissions
            except Exception:
                raise

    def build_filter(self, cond: dict):
        if "and" in cond:
            return and_(*[self.build_filter(c) for c in cond["and"]])
        elif "or" in cond:
            return or_(*[self.build_filter(c) for c in cond["or"]])
        else:
            return and_(*[
                getattr(Permission, field) == value
                for field, value in cond.items()
            ])

    async def get_permissions_by_condition(self, condition: dict):
        with self.db() as session:
            try:
                query = session.query(Permission).filter(self.build_filter(condition))
                return query.all()
            except Exception:
                raise

    async def delete_permission_by_uuids(self, permission_uuids: List[str]):
        with self.db() as session:
            try:
                session.query(UserPermission).filter(
                    UserPermission.permission_uuid.in_(permission_uuids)
                ).delete(synchronize_session=False)
                session.query(RolePermission).filter(
                    RolePermission.permission_uuid.in_(permission_uuids)
                ).delete(synchronize_session=False)
                session.query(Permission).filter(
                    Permission.uuid.in_(permission_uuids)
                ).delete(synchronize_session=False)
                session.commit()
                self._invalidate_all()
                return True
            except Exception:
                raise

    # =========================================================================
    # USER-PERMISSION OPERATIONS
    # =========================================================================

    def assign_permissions_to_user(self, user_uuid: str, permission_uuids: List[str]):
        with self.db() as session:
            try:
                current_permissions = session.query(UserPermission).filter(
                    UserPermission.user_uuid == user_uuid
                ).all()
                current_permission_uuids = [p.permission_uuid for p in current_permissions]
                remove_permissions = set(current_permission_uuids) - set(permission_uuids)
                add_permissions = set(permission_uuids) - set(current_permission_uuids)

                if remove_permissions:
                    session.query(UserPermission).filter(
                        UserPermission.user_uuid == user_uuid,
                        UserPermission.permission_uuid.in_(remove_permissions)
                    ).delete(synchronize_session=False)

                if add_permissions:
                    session.bulk_insert_mappings(UserPermission, [
                        {"user_uuid": user_uuid, "permission_uuid": pid}
                        for pid in add_permissions
                    ])

                session.commit()
                self._invalidate_user(user_uuid)
                return self.get_user_only_permissions(user_uuid, session)
            except Exception:
                raise

    def attach_permissions_to_user(self, user_uuid: str, permission_uuids: List[str]):
        with self.db() as session:
            try:
                user_permissions_data = [
                    {"user_uuid": user_uuid, "permission_uuid": permission_uuid}
                    for permission_uuid in permission_uuids
                ]
                session.bulk_insert_mappings(UserPermission, user_permissions_data)
                session.commit()
                self._invalidate_user(user_uuid)
                return self.get_user_only_permissions(user_uuid, session)
            except Exception:
                raise

    def revoke_user_permissions(self, user_uuid: str, permission_uuids: List[str]):
        with self.db() as session:
            try:
                session.query(UserPermission).filter(
                    UserPermission.user_uuid == user_uuid,
                    UserPermission.permission_uuid.in_(permission_uuids)
                ).delete(synchronize_session=False)
                session.commit()
                self._invalidate_user(user_uuid)
                return self.get_user_only_permissions(user_uuid, session)
            except Exception:
                raise

    def revoke_all_user_access(self, user_uuid: str) -> dict:
        with self.db() as session:
            try:
                if not session.is_active:
                    session.begin()
                user_roles = (
                    session.query(UserRole)
                    .options(joinedload(UserRole.role))
                    .filter(UserRole.user_uuid == user_uuid)
                    .all()
                )
                role_uuids = [ur.role.uuid for ur in user_roles] if user_roles else []
                user_permissions = (
                    session.query(UserPermission)
                    .options(joinedload(UserPermission.permission))
                    .filter(UserPermission.user_uuid == user_uuid)
                    .all()
                )
                permission_uuids = [up.permission.uuid for up in user_permissions] if user_permissions else []
                if role_uuids:
                    session.query(UserRole).filter(
                        UserRole.user_uuid == user_uuid,
                        UserRole.role_uuid.in_(role_uuids)
                    ).delete(synchronize_session=False)
                if permission_uuids:
                    session.query(UserPermission).filter(
                        UserPermission.user_uuid == user_uuid,
                        UserPermission.permission_uuid.in_(permission_uuids)
                    ).delete(synchronize_session=False)
                session.commit()
                self._invalidate_user(user_uuid)
                return {
                    "user_uuid": user_uuid,
                    "roles_revoked": len(role_uuids),
                    "permissions_revoked": len(permission_uuids),
                    "role_uuids": role_uuids,
                    "permission_uuids": permission_uuids
                }
            except Exception:
                raise

    # =========================================================================
    # ROLE CRUD
    # =========================================================================

    def list_roles(self) -> Any:
        with self.db() as session:
            try:
                total = session.query(Role).count()
                roles = session.query(Role).all()
                return {"roles": roles, "total": total}
            except Exception:
                raise

    def create_role(
        self,
        name: str,
        description: Optional[str] = None,
        permission_ids: List[str] = None,
    ) -> Any:
        with self.db() as session:
            try:
                existing_role = session.query(Role).filter(Role.name == name).first()
                if existing_role:
                    raise DuplicatedError(detail="Role already exists")
                role = Role(name=name, description=description)
                session.add(role)
                session.flush()
                if permission_ids:
                    permission_count = (
                        session.query(Permission)
                        .filter(Permission.uuid.in_(permission_ids))
                        .count()
                    )
                    if permission_count != len(permission_ids):
                        existing_permissions = (
                            session.query(Permission)
                            .filter(Permission.uuid.in_(permission_ids))
                            .all()
                        )
                        found_permission_ids = {p.uuid for p in existing_permissions}
                        missing_ids = set(permission_ids) - found_permission_ids
                        raise NotFoundError(
                            detail=f"Permissions with UUIDs '{', '.join(missing_ids)}' not found"
                        )
                    role_permissions = [
                        {"role_uuid": role.uuid, "permission_uuid": permission_uuid}
                        for permission_uuid in permission_ids
                    ]
                    session.bulk_insert_mappings(RolePermission, role_permissions)
                session.commit()
                session.refresh(role)
                # No invalidation needed - new role has no users assigned yet
                return role
            except Exception:
                raise

    def get_role_with_permissions(self, role_uuid: str) -> Any:
        with self.db() as session:
            role = (
                session.query(Role)
                .options(joinedload(Role.permissions))
                .filter(Role.uuid == role_uuid)
                .first()
            )
            if not role:
                raise NotFoundError(detail="Requested role does not exist")
            return role

    def update_role_permissions(
        self,
        role_uuid: str,
        permissions: Optional[List[str]] = None,
        name: Optional[str] = None,
        description: Optional[str] = None,
    ) -> Any:
        with self.db() as session:
            try:
                if not session.is_active:
                    session.begin()
                # No joinedload — we are replacing permissions, not reading them
                role = session.query(Role).filter(Role.uuid == role_uuid).first()
                if not role:
                    raise NotFoundError(detail="Requested role does not exist")
                if name is not None or description is not None:
                    if name:
                        # Column-only existence check
                        name_taken = (
                            session.query(Role.uuid)
                            .filter(Role.name == name, Role.uuid != role_uuid)
                            .first()
                        )
                        if name_taken:
                            raise DuplicatedError(detail="Role already exists")
                        if role.name != "super_admin":
                            role.name = name
                    if description is not None:
                        role.description = description
                if permissions is not None:
                    session.query(RolePermission).filter(
                        RolePermission.role_uuid == role_uuid
                    ).delete(synchronize_session=False)
                    if permissions:
                        # Column-only validation — don't load full Permission objects
                        found_uuids = {
                            row[0] for row in
                            session.query(Permission.uuid)
                            .filter(Permission.uuid.in_(permissions))
                            .all()
                        }
                        missing = set(permissions) - found_uuids
                        if missing:
                            raise NotFoundError(
                                detail=f"Permissions with UUIDs '{', '.join(missing)}' not found"
                            )
                        session.bulk_insert_mappings(RolePermission, [
                            {"role_uuid": role_uuid, "permission_uuid": pid}
                            for pid in permissions
                        ])
                # Fetch affected users in the same session (no second session)
                affected_users = [
                    row[0] for row in
                    session.query(UserRole.user_uuid)
                    .filter(UserRole.role_uuid == role_uuid)
                    .all()
                ]
                session.commit()
                # Single re-query with joinedload for the return value
                role = (
                    session.query(Role)
                    .options(joinedload(Role.permissions))
                    .filter(Role.uuid == role_uuid)
                    .first()
                )
                self._invalidate_role_users(role_uuid, affected_users)
                return role
            except Exception:
                raise

    def delete_role(self, role_uuid: str, exception_roles: List[str] = None):
        with self.db() as session:
            try:
                if not session.is_active:
                    session.begin()
                role = (
                    session.query(Role)
                    .options(joinedload(Role.permissions))
                    .filter(Role.uuid == role_uuid)
                    .first()
                )
                if not role:
                    raise NotFoundError(detail="Requested role does not exist")
                if exception_roles and len(exception_roles) > 0 and role.name in exception_roles:
                    raise PermissionDeniedError(detail="You are not allowed to delete the requested role.")
                # Fetch affected users in same session BEFORE delete
                affected_users = [
                    row[0] for row in
                    session.query(UserRole.user_uuid)
                    .filter(UserRole.role_uuid == role_uuid)
                    .all()
                ]
                session.delete(role)
                session.commit()
                self._invalidate_role_users(role_uuid, affected_users)
            except Exception:
                raise

    # =========================================================================
    # PERMISSION LISTING
    # =========================================================================

    def list_permissions(self) -> List[Any]:
        with self.db() as session:
            return session.query(Permission).all()

    def list_module_permissions(self, module: str) -> List[Any]:
        with self.db() as session:
            return session.query(Permission).filter(Permission.module == module).all()

    def get_user_only_permissions(self, user_uuid: str, session: Optional[Session] = None) -> List[Any]:
        def query_permissions(session: Session) -> List[Any]:
            user_permissions = (
                session.query(UserPermission)
                .filter(UserPermission.user_uuid == user_uuid)
                .options(joinedload(UserPermission.permission))
                .all()
            )
            result = []
            for user_permission in user_permissions:
                result.append(
                    {
                        "permission_id": user_permission.permission.uuid,
                        "created_at": user_permission.permission.created_at,
                        "updated_at": user_permission.permission.updated_at,
                        "name": user_permission.permission.name,
                        "resource": user_permission.permission.resource,
                        "action": user_permission.permission.action,
                        "module": user_permission.permission.module
                    }
                )
            return result
        if session:
            return query_permissions(session)
        else:
            with self.db() as new_session:
                return query_permissions(new_session)

    def get_user_permissions(self, user_uuid: str) -> List[Any]:
        with self.db() as session:
            user_roles = (
                session.query(UserRole)
                .join(Role, UserRole.role_uuid == Role.uuid)
                .options(
                    joinedload(UserRole.role).joinedload(Role.permissions)
                )
                .filter(UserRole.user_uuid == user_uuid)
                .all()
            )
            if not user_roles:
                return []
            result = []
            for user_role in user_roles:
                role = user_role.role
                for permission in role.permissions:
                    result.append(
                        {
                            "permission_id": permission.uuid,
                            "created_at": permission.created_at,
                            "role_id": role.uuid,
                            "updated_at": permission.updated_at,
                            "role_name": role.name,
                            "name": permission.name,
                            "resource": permission.resource,
                            "action": permission.action,
                            "module": permission.module
                        }
                    )
            return result

    # =========================================================================
    # ROLE-PERMISSION OPERATIONS
    # =========================================================================

    def bulk_revoke_permissions(self, role_uuid: str, permission_uuids: List[str]) -> Any:
        with self.db() as session:
            try:
                if not session.is_active:
                    session.begin()
                role = (
                    session.query(Role)
                    .options(joinedload(Role.permissions))
                    .filter(Role.uuid == role_uuid)
                    .first()
                )
                if not role:
                    raise NotFoundError(detail="Requested role does not exist")
                uuids_set = set(permission_uuids)
                permission_uuids_to_revoke = [
                    p.uuid for p in role.permissions if p.uuid in uuids_set
                ]
                if not permission_uuids_to_revoke:
                    return role
                session.query(RolePermission).filter(
                    and_(
                        RolePermission.role_uuid == role_uuid,
                        RolePermission.permission_uuid.in_(permission_uuids_to_revoke),
                    )
                ).delete(synchronize_session=False)
                affected_users = [
                    row[0] for row in
                    session.query(UserRole.user_uuid)
                    .filter(UserRole.role_uuid == role_uuid)
                    .all()
                ]
                session.commit()
                role = (
                    session.query(Role)
                    .options(joinedload(Role.permissions))
                    .filter(Role.uuid == role_uuid)
                    .first()
                )
                self._invalidate_role_users(role_uuid, affected_users)
                return role
            except Exception:
                raise

    def bulk_attach_permissions(self, role_uuid: str, permission_uuids: List[str]) -> Any:
        with self.db() as session:
            try:
                if not session.is_active:
                    session.begin()
                role = (
                    session.query(Role)
                    .options(joinedload(Role.permissions))
                    .filter(Role.uuid == role_uuid)
                    .first()
                )
                if not role:
                    raise NotFoundError(detail="Requested role does not exist")
                existing_permission_uuids = {p.uuid for p in role.permissions}
                new_permission_uuids = set(permission_uuids) - existing_permission_uuids
                if not new_permission_uuids:
                    return role
                # Column-only validation
                found_uuids = {
                    row[0] for row in
                    session.query(Permission.uuid)
                    .filter(Permission.uuid.in_(new_permission_uuids))
                    .all()
                }
                missing = new_permission_uuids - found_uuids
                if missing:
                    raise NotFoundError(
                        detail=f"Permissions with UUIDs '{', '.join(missing)}' not found"
                    )
                session.bulk_insert_mappings(RolePermission, [
                    {"role_uuid": role_uuid, "permission_uuid": pid}
                    for pid in new_permission_uuids
                ])
                affected_users = [
                    row[0] for row in
                    session.query(UserRole.user_uuid)
                    .filter(UserRole.role_uuid == role_uuid)
                    .all()
                ]
                session.commit()
                role = (
                    session.query(Role)
                    .options(joinedload(Role.permissions))
                    .filter(Role.uuid == role_uuid)
                    .first()
                )
                self._invalidate_role_users(role_uuid, affected_users)
                return role
            except Exception:
                raise

    # =========================================================================
    # USER-ROLE OPERATIONS
    # =========================================================================

    def get_user_roles(self, user_uuid: str, session: Optional[Session] = None) -> List[Any]:
        def query_roles(session: Session) -> List[Any]:
            return (
                session.query(Role)
                .join(
                    UserRole,
                    and_(
                        UserRole.role_uuid == Role.uuid,
                        UserRole.user_uuid == user_uuid
                    )
                )
                .options(joinedload(Role.permissions))
                .all()
            )
        if session:
            return query_roles(session)
        else:
            with self.db() as new_session:
                return query_roles(new_session)

    def bulk_assign_roles_to_user(self, user_uuid: str, role_uuids: List[str]) -> List[Any]:
        with self.db() as session:
            try:
                if not session.is_active:
                    session.begin()
                current_roles = (
                    session.query(UserRole)
                    .options(joinedload(UserRole.role))
                    .filter(UserRole.user_uuid == user_uuid)
                    .all()
                )
                current_role_uuids = {role.role.uuid for role in current_roles}
                new_role_uuids = set(role_uuids) - current_role_uuids
                roles_to_remove = current_role_uuids - set(role_uuids)
                if roles_to_remove:
                    session.query(UserRole).filter(
                        and_(
                            UserRole.user_uuid == user_uuid,
                            UserRole.role_uuid.in_(roles_to_remove),
                        )
                    ).delete(synchronize_session=False)
                if new_role_uuids:
                    new_roles = (
                        session.query(Role).filter(Role.uuid.in_(new_role_uuids)).all()
                    )
                    if len(new_roles) != len(new_role_uuids):
                        raise NotFoundError(detail="One or more roles not found")
                    user_roles = [
                        UserRole(user_uuid=user_uuid, role_uuid=role.uuid)
                        for role in new_roles
                    ]
                    session.bulk_save_objects(user_roles)
                session.commit()
                self._invalidate_user(user_uuid)
                return self.get_user_roles(user_uuid, session)
            except Exception:
                raise

    def bulk_revoke_roles_from_user(self, user_uuid: str, role_uuids: List[str]) -> List[Any]:
        with self.db() as session:
            try:
                if not session.is_active:
                    session.begin()
                current_roles = (
                    session.query(UserRole)
                    .options(joinedload(UserRole.role))
                    .filter(UserRole.user_uuid == user_uuid)
                    .filter(UserRole.role_uuid.in_(role_uuids))
                    .all()
                )
                if not current_roles:
                    return self.get_user_roles(user_uuid, session)
                role_uuids_to_revoke = {role.role.uuid for role in current_roles}
                session.query(UserRole).filter(
                    and_(
                        UserRole.user_uuid == user_uuid,
                        UserRole.role_uuid.in_(role_uuids_to_revoke),
                    )
                ).delete(synchronize_session=False)
                session.commit()
                self._invalidate_user(user_uuid)
                return self.get_user_roles(user_uuid, session)
            except Exception:
                raise

    def bulk_attach_roles_to_user(self, user_uuid: str, role_uuids: List[str]) -> List[Any]:
        with self.db() as session:
            try:
                if not session.is_active:
                    session.begin()
                current_roles = (
                    session.query(UserRole)
                    .options(joinedload(UserRole.role))
                    .filter(UserRole.user_uuid == user_uuid)
                    .all()
                )
                current_role_uuids = {role.role.uuid for role in current_roles}
                new_role_uuids = set(role_uuids) - current_role_uuids
                if not new_role_uuids:
                    return self.get_user_roles(user_uuid, session)
                new_roles = (
                    session.query(Role).filter(Role.uuid.in_(new_role_uuids)).all()
                )
                if len(new_roles) != len(new_role_uuids):
                    raise NotFoundError(detail="There are some roles that does not exist.")
                user_roles = [
                    UserRole(user_uuid=user_uuid, role_uuid=role.uuid) for role in new_roles
                ]
                session.bulk_save_objects(user_roles)
                session.commit()
                self._invalidate_user(user_uuid)
                return self.get_user_roles(user_uuid, session)
            except Exception:
                raise

    # =========================================================================
    # ROLE QUERIES
    # =========================================================================

    def get_role(self, role_uuid: str, session: Optional[Session] = None) -> Any:
        def query_role(session: Session) -> Any:
            role = session.query(Role).filter(Role.uuid == role_uuid).first()
            if not role:
                raise NotFoundError(detail="Requested role does not exist.")
            return role
        if session:
            return query_role(session)
        else:
            with self.db() as session:
                return query_role(session)

    # =========================================================================
    # NOTIFIER STATUS
    # =========================================================================

    def is_watcher_active(self) -> bool:
        if self._notifier is None:
            return False
        return self._notifier._sender is not None

    def get_watcher_status(self) -> dict:
        if not self.is_watcher_active():
            return {
                "active": False,
                "message": "Cache invalidation notifier not initialized or not active"
            }
        return {
            "active": True,
            "type": "azure_service_bus",
            "topic": self._notifier.config.topic_name,
            "subscription": self._notifier.subscription_name,
            "instance_id": self._notifier.instance_id,
            "connected": True,
        }
