# -*- coding: utf-8 -*-
"""Location: ./mcpgateway/admin.py
Copyright 2026
SPDX-License-Identifier: Apache-2.0
Authors: Mihai Criveti

Admin UI Routes for ContextForge AI Gateway.
This module contains all the administrative UI endpoints for ContextForge AI Gateway.
It provides a comprehensive interface for managing servers, tools, resources,
prompts, gateways, and roots through RESTful API endpoints. The module handles
all aspects of CRUD operations for these entities, including creation,
reading, updating, deletion, and status toggling.

All endpoints in this module require authentication, which is enforced via
the require_auth or require_basic_auth dependency. The module integrates with
various services to perform the actual business logic operations on the
underlying data.
"""

# Standard
import asyncio
import base64
import binascii
from collections import defaultdict
import csv
from datetime import datetime, timedelta, timezone
from functools import lru_cache, wraps
import html
import inspect
import io
import json
import logging
import math
import os
from pathlib import Path
import re
import secrets
import tempfile
import time
from typing import Any
from typing import cast as typing_cast
from typing import Dict, List, Optional, Union
import urllib.parse
import uuid

# Third-Party
from fastapi import APIRouter, Body, Depends, HTTPException, Query, Request, Response
from fastapi.encoders import jsonable_encoder
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, RedirectResponse, StreamingResponse
from fastapi.security import HTTPAuthorizationCredentials
import httpx
import orjson
from pydantic import SecretStr, ValidationError
from pydantic_core import ValidationError as CoreValidationError
from sqlalchemy import and_, bindparam, case, cast, desc, false, func, or_, select, String, text
from sqlalchemy.exc import DataError, IntegrityError, InvalidRequestError, OperationalError, SQLAlchemyError
from sqlalchemy.orm import joinedload, selectinload, Session, with_loader_criteria
from sqlalchemy.sql.functions import coalesce
from starlette.background import BackgroundTask
from starlette.datastructures import UploadFile as StarletteUploadFile

# First-Party
from mcpgateway import __version__
from mcpgateway import version as version_module

# Authentication and password-related imports
from mcpgateway.auth import get_current_user, get_user_team_roles

# Re-export canonical get_user_email from auth_context for backward compatibility.
from mcpgateway.auth_context import get_scoped_resource_access_context, get_user_email
from mcpgateway.cache.a2a_stats_cache import a2a_stats_cache
from mcpgateway.cache.global_config_cache import global_config_cache
from mcpgateway.common.models import LogLevel
from mcpgateway.common.query_params import (
    QueryEntityType,
    QueryEntityTypes,
    QueryExportFormatAliased,
    QueryGatewayIdList,
    QueryHttpMethod,
    QueryPeriodType,
    QueryRelationship,
    QueryRenderMode,
    QueryRenderModeControls,
    QueryRenderModeUserSelector,
    QueryStatusFilter,
    QueryTagsFilter,
    QueryTimeRange,
    QueryToolName,
    QueryUserIdentifierNoDescription,
    QueryVisibility,
    QueryVisibilityCompact,
)
from mcpgateway.common.validators import SecurityValidator
from mcpgateway.config import settings, UI_HIDABLE_HEADER_ITEMS, UI_HIDABLE_SECTIONS, UI_HIDE_SECTION_ALIASES
from mcpgateway.db import A2AAgent as DbA2AAgent
from mcpgateway.db import EmailApiToken, EmailTeam, extract_json_field
from mcpgateway.db import Gateway as DbGateway
from mcpgateway.db import get_db, GlobalConfig, ObservabilitySavedQuery, ObservabilitySpan, ObservabilityTrace
from mcpgateway.db import Prompt as DbPrompt
from mcpgateway.db import Resource as DbResource
from mcpgateway.db import Server as DbServer
from mcpgateway.db import SessionLocal
from mcpgateway.db import Tool as DbTool
from mcpgateway.db import utc_now
from mcpgateway.middleware.rbac import _ACCESS_DENIED_MSG, get_current_user_with_permissions, require_any_permission, require_permission
from mcpgateway.routers.email_auth import create_access_token
from mcpgateway.schemas import (
    A2AAgentCreate,
    A2AAgentRead,
    A2AAgentUpdate,
    CatalogBulkRegisterRequest,
    CatalogBulkRegisterResponse,
    CatalogListRequest,
    CatalogListResponse,
    CatalogServerRegisterRequest,
    CatalogServerRegisterResponse,
    CatalogServerStatusResponse,
    GatewayCreate,
    GatewayRead,
    GatewayTestRequest,
    GatewayTestResponse,
    GatewayUpdate,
    GlobalConfigRead,
    GlobalConfigUpdate,
    PaginatedResponse,
    PaginationMeta,
    PluginDetail,
    PluginListResponse,
    PluginModeUpdateRequest,
    PluginModeUpdateResponse,
    PluginStatsResponse,
    PluginToggleRequest,
    PluginToggleResponse,
    PromptCreate,
    PromptMetrics,
    PromptRead,
    PromptUpdate,
    ResourceCreate,
    ResourceMetrics,
    ResourceUpdate,
    ServerCreate,
    ServerMetrics,
    ServerRead,
    ServerUpdate,
    ToolCreate,
    ToolMetrics,
    ToolRead,
    ToolUpdate,
)
from mcpgateway.services.a2a_service import A2AAgentError, A2AAgentNameConflictError, A2AAgentNotFoundError, A2AAgentService
from mcpgateway.services.argon2_service import Argon2PasswordService
from mcpgateway.services.audit_trail_service import get_audit_trail_service
from mcpgateway.services.catalog_service import catalog_service
from mcpgateway.services.content_security import ContentSizeError, ContentTypeError, TemplateValidationError
from mcpgateway.services.email_auth_service import AuthenticationError, EmailAuthService, PasswordValidationError
from mcpgateway.services.encryption_service import get_encryption_service
from mcpgateway.services.export_service import ExportError, ExportService
from mcpgateway.services.gateway_service import GatewayConnectionError, GatewayDuplicateConflictError, GatewayNameConflictError, GatewayNotFoundError, GatewayService
from mcpgateway.services.import_service import ConflictStrategy
from mcpgateway.services.import_service import ImportError as ImportServiceError
from mcpgateway.services.import_service import ImportService, ImportValidationError
from mcpgateway.services.logging_service import LoggingService
from mcpgateway.services.oauth_manager import OAuthManager
from mcpgateway.services.openapi_service import fetch_and_extract_schemas
from mcpgateway.services.performance_service import get_performance_service
from mcpgateway.services.permission_service import PermissionService
from mcpgateway.services.plugin_service import get_plugin_service
from mcpgateway.services.prompt_service import PromptArgumentsJSONError, PromptNameConflictError, PromptNotFoundError, PromptService
from mcpgateway.services.resource_service import ResourceNotFoundError, ResourceService, ResourceURIConflictError
from mcpgateway.services.root_service import RootService, RootServiceError, RootServiceNotFoundError
from mcpgateway.services.server_service import ServerError, ServerLockConflictError, ServerNameConflictError, ServerNotFoundError, ServerService
from mcpgateway.services.structured_logger import get_structured_logger
from mcpgateway.services.tag_service import TagService
from mcpgateway.services.team_management_service import TeamManagementService, UNSET
from mcpgateway.services.token_catalog_service import TokenCatalogService
from mcpgateway.services.tool_service import ToolError, ToolLockConflictError, ToolNameConflictError, ToolNotFoundError, ToolService
from mcpgateway.utils.create_jwt_token import create_jwt_token, get_jwt_token
from mcpgateway.utils.error_formatter import ErrorFormatter
from mcpgateway.utils.metadata_capture import MetadataCapture
from mcpgateway.utils.orjson_response import ORJSONResponse
from mcpgateway.utils.pagination import paginate_query
from mcpgateway.utils.passthrough_headers import PassthroughHeadersError
from mcpgateway.utils.paths import resolve_root_path as _resolve_root_path
from mcpgateway.utils.retry_manager import ResilientHttpClient
from mcpgateway.utils.security_cookies import clear_auth_cookie, CookieTooLargeError, set_auth_cookie
from mcpgateway.utils.services_auth import decode_auth, encode_auth
from mcpgateway.utils.sqlalchemy_modifier import json_contains_tag_expr
from mcpgateway.utils.url_auth import sanitize_url_for_logging
from mcpgateway.utils.validate_signature import sign_data
from mcpgateway.utils.verify_credentials import verify_jwt_token_cached

# Conditional imports for gRPC support (only if grpcio is installed)
try:
    # First-Party
    from mcpgateway.schemas import GrpcServiceCreate, GrpcServiceRead, GrpcServiceUpdate
    from mcpgateway.services.grpc_service import GrpcService, GrpcServiceError, GrpcServiceNameConflictError, GrpcServiceNotFoundError

    GRPC_AVAILABLE = True
except ImportError:
    GRPC_AVAILABLE = False
    # Define placeholder types to avoid NameError
    GrpcServiceCreate = None  # type: ignore
    GrpcServiceRead = None  # type: ignore
    GrpcServiceUpdate = None  # type: ignore
    GrpcService = None  # type: ignore

    # Define placeholder exception classes that maintain the hierarchy
    class GrpcServiceError(Exception):  # type: ignore
        """Placeholder for GrpcServiceError when grpcio is not installed."""

    class GrpcServiceNotFoundError(GrpcServiceError):  # type: ignore
        """Placeholder for GrpcServiceNotFoundError when grpcio is not installed."""

    class GrpcServiceNameConflictError(GrpcServiceError):  # type: ignore
        """Placeholder for GrpcServiceNameConflictError when grpcio is not installed."""


# Import the shared logging service from main
# This will be set by main.py when it imports admin_router
logging_service: Optional[LoggingService] = None
LOGGER: logging.Logger = logging.getLogger("mcpgateway.admin")
UI_SECTION_TO_TABS: Dict[str, tuple[str, ...]] = {
    "overview": ("overview",),
    "servers": ("catalog",),
    "gateways": ("gateways",),
    "tools": ("tools", "tool-ops"),
    "prompts": ("prompts",),
    "resources": ("resources",),
    "roots": ("roots",),
    "mcp-registry": ("mcp-registry",),
    "metrics": ("metrics",),
    "plugins": ("plugins",),
    "export-import": ("export-import",),
    "logs": ("logs",),
    "version-info": ("version-info",),
    "maintenance": ("maintenance",),
    "teams": ("teams",),
    "users": ("users",),
    "agents": ("a2a-agents",),
    "grpc-services": ("grpc-services",),
    "tokens": ("tokens",),
    "settings": ("llm-settings",),
}

# Section-to-permission mapping for menu visibility
# Maps UI section names to required RBAC permissions
# NOTE: This mapping must be kept in sync with @require_permission decorators on admin routes.
# The validate_section_permissions() function (called at startup) verifies consistency.
SECTION_PERMISSIONS: Dict[str, Optional[str]] = {
    # Admin-only sections
    "users": "admin.user_management",
    "maintenance": "admin.system_config",
    "logs": "admin.system_config",
    "export-import": "admin.system_config",
    "plugins": "admin.plugins",
    "metrics": "admin.system_config",
    "version-info": "admin.system_config",
    "settings": "admin.system_config",
    "llm-providers": "admin.system_config",
    "llm-models": "admin.system_config",
    "llm-api-info": "admin.system_config",
    # Core sections (accessible to developers and above)
    "tools": "tools.read",
    "servers": "servers.read",
    "resources": "resources.read",
    "prompts": "prompts.read",
    "gateways": "gateways.read",
    # Team management sections
    "teams": "teams.read",
    "tokens": "tokens.read",
    # A2A agents
    "agents": "a2a.read",
    # gRPC services (separate from A2A - requires admin.grpc)
    "grpc-services": "admin.grpc",
    # Overview and roots
    "overview": "admin.overview",  # Requires admin permission
    "roots": "admin.system_config",  # Roots routes use admin.system_config
    "mcp-registry": "servers.read",  # Catalog is part of servers
}

# Section-to-route-path mapping for validation
# NOTE: Only includes routes that exist on admin_router itself.
# Routes on other routers (version.py, llm_admin_router, etc.) are excluded
# from validation since they're mounted separately and have their own decorators.
_SECTION_TO_ROUTE_PATH: Dict[str, str] = {
    "users": "/admin/users/partial",
    "maintenance": "/admin/maintenance/partial",
    "logs": "/admin/logs",
    "export-import": "/admin/export/configuration",
    "plugins": "/admin/plugins/partial",
    "metrics": "/admin/metrics",
    # "version-info": "/admin/version",  # Route is on version.py router, not admin_router
    # "settings": "/admin/llm-settings",  # No such route exists
    # "llm-providers": "/admin/llm/providers/html",  # Route is on llm_admin_router
    # "llm-models": "/admin/llm/models/html",  # Route is on llm_admin_router
    # "llm-api-info": "/admin/llm/api-info/html",  # Route is on llm_admin_router
    "tools": "/admin/tools/partial",
    "servers": "/admin/servers/partial",
    "resources": "/admin/resources/partial",
    "prompts": "/admin/prompts/partial",
    "gateways": "/admin/gateways/partial",
    "teams": "/admin/teams/partial",
    "tokens": "/admin/tokens/partial",
    "agents": "/admin/a2a/partial",
    "overview": "/admin/overview/partial",
    # "roots": "/admin/roots/partial",  # No such route exists on admin_router
    "mcp-registry": "/admin/servers/partial",
}


def _extract_permission_from_route(route) -> Optional[str]:
    """Extract the required permission from a route's @require_permission decorator.

    This function reads the permission metadata set by the decorator as a function attribute.
    This approach is more robust than closure introspection and immune to decorator
    implementation changes.

    Args:
        route: FastAPI route object

    Returns:
        Permission string if found (e.g., "tools.read"), None otherwise
    """
    try:
        if not hasattr(route, "endpoint"):
            return None

        endpoint = route.endpoint

        # Simply read the metadata attribute set by @require_permission decorator
        return getattr(endpoint, "_required_permission", None)

    except Exception as e:
        LOGGER.debug(f"Error extracting permission from route {getattr(route, 'path', 'unknown')}: {e}")

    return None


def validate_section_permissions(router) -> None:
    """Validate that SECTION_PERMISSIONS matches actual route decorators.

    This function is called at application startup to ensure the hardcoded
    SECTION_PERMISSIONS mapping is consistent with the @require_permission
    decorators on admin routes. Logs warnings for any mismatches.

    In test/CI environments, raises ValueError on mismatches to fail fast.
    In production, logs warnings only to avoid breaking deployments.

    Args:
        router: FastAPI APIRouter instance (admin_router)

    Raises:
        ValueError: In test/CI environments when mismatches are found
    """
    mismatches = []

    for section, expected_perm in SECTION_PERMISSIONS.items():
        route_path = _SECTION_TO_ROUTE_PATH.get(section)

        if route_path is None:
            # Section's route located on a different sub-router, skipping validation
            continue

        # Find matching route
        actual_perm = None
        for route in router.routes:
            if hasattr(route, "path") and route.path == route_path:
                actual_perm = _extract_permission_from_route(route)
                break

        # Compare expected vs actual
        if expected_perm != actual_perm:
            mismatches.append({"section": section, "route": route_path, "expected": expected_perm, "actual": actual_perm})

    if mismatches:
        error_msg = f"SECTION_PERMISSIONS validation found {len(mismatches)} mismatches with route decorators:"
        for m in mismatches:
            error_msg += f"\n  Section '{m['section']}' (route: {m['route']}): expected '{m['expected']}', found '{m['actual']}'"

        # Detect test/CI environment
        is_test_env = (
            os.getenv("PYTEST_CURRENT_TEST") is not None  # pytest is running
            or os.getenv("CI") is not None  # CI environment (GitHub Actions, GitLab CI, etc.)
            or os.getenv("GITHUB_ACTIONS") is not None  # GitHub Actions specifically
        )

        if is_test_env:
            # Hard error in test/CI to fail fast
            raise ValueError(error_msg)
        # Warning only in production to avoid breaking deployments
        LOGGER.warning(error_msg)
        LOGGER.warning("This may indicate the mapping needs updating.")
    else:
        validated_count = len(_SECTION_TO_ROUTE_PATH)
        LOGGER.info(f"SECTION_PERMISSIONS validation passed: all {validated_count} mapped sections verified (skipped {len(SECTION_PERMISSIONS) - validated_count} sections on other routers)")


UI_EMBEDDED_DEFAULT_HIDDEN_HEADER_ITEMS: frozenset[str] = frozenset({"logout", "team_selector"})
UI_HIDE_SECTIONS_COOKIE_NAME = "mcpgateway_ui_hide_sections"
UI_HIDE_SECTIONS_COOKIE_MAX_AGE = 30 * 24 * 60 * 60  # 30 days

# Cache for the bundle filename to avoid reading manifest on every request
# Using a mutable dict to avoid the need for a global statement in the accessor function
_bundle_js_cache: dict[str, Optional[str]] = {"filename": None}


def get_bundle_js_filename() -> str:
    """Get the hashed bundle.js filename from Vite manifest.

    Reads the Vite manifest file to get the current hashed bundle filename.
    Falls back to scanning for bundle-*.js on disk if the manifest is unreadable.
    Invalidates the cache when the cached bundle file no longer exists on disk.

    Returns:
        str: The bundle filename (e.g., 'bundle-abc123.js')
    """
    static_dir = Path(__file__).parent / "static"

    # Use cache if the bundle file still exists on disk
    cached = _bundle_js_cache["filename"]
    if cached is not None and (static_dir / cached).exists():
        return cached

    manifest_path = static_dir / ".vite" / "manifest.json"
    try:
        if manifest_path.exists():
            with open(manifest_path, "r", encoding="utf-8") as f:
                manifest = orjson.loads(f.read())
                # The key is the input path relative to the project root
                entry_key = "mcpgateway/admin_ui/index.js"
                if entry_key in manifest and manifest[entry_key].get("file"):
                    _bundle_js_cache["filename"] = manifest[entry_key]["file"]
                    return _bundle_js_cache["filename"]  # type: ignore[return-value]
    except Exception as e:
        LOGGER.warning(f"Failed to read Vite manifest: {e}")

    # Manifest unreadable or missing entry — find bundle file directly on disk
    bundles = sorted(static_dir.glob("bundle-*.js"), key=lambda p: p.stat().st_mtime, reverse=True)
    if bundles:
        _bundle_js_cache["filename"] = bundles[0].name
        return _bundle_js_cache["filename"]  # type: ignore[return-value]

    LOGGER.error("No bundle-*.js found in %s — admin UI will not load", static_dir)
    return ""


def _normalize_ui_hide_values(raw: Any, valid_values: frozenset[str], aliases: Optional[Dict[str, str]] = None) -> set[str]:
    """Normalize UI hide values from CSV/list input into a validated set.

    Args:
        raw: Source value (CSV string, iterable, or ``None``).
        valid_values: Allowed normalized values.
        aliases: Optional alias mapping to canonical values.

    Returns:
        set[str]: Lowercase validated values with aliases resolved.
    """
    if raw is None:
        return set()

    tokens: list[str] = []
    if isinstance(raw, str):
        tokens = [item.strip() for item in raw.split(",")]
    elif isinstance(raw, (list, tuple, set)):
        tokens = [str(item).strip() for item in raw]
    else:
        return set()

    normalized: set[str] = set()
    for token in tokens:
        if not token:
            continue
        item = token.lower()
        if aliases:
            item = aliases.get(item, item)
        if item in valid_values:
            normalized.add(item)
    return normalized


def get_ui_visibility_config(request: Request, is_admin: bool = False) -> Dict[str, Any]:
    """Build final UI visibility settings for the current admin request.

    Args:
        request: Incoming FastAPI request.
        is_admin: Whether the current user is an admin. Admins use separate
            hide lists and are not affected by embedded-mode defaults.

    Returns:
        Dict[str, Any]: Hidden sections/header items/tabs plus cookie update intent.
    """
    if is_admin:
        hidden_sections = _normalize_ui_hide_values(getattr(settings, "mcpgateway_ui_hide_sections_admin", []), UI_HIDABLE_SECTIONS, UI_HIDE_SECTION_ALIASES)
        hidden_header_items = _normalize_ui_hide_values(getattr(settings, "mcpgateway_ui_hide_header_items_admin", []), UI_HIDABLE_HEADER_ITEMS)
    else:
        hidden_sections = _normalize_ui_hide_values(getattr(settings, "mcpgateway_ui_hide_sections", []), UI_HIDABLE_SECTIONS, UI_HIDE_SECTION_ALIASES)
        hidden_header_items = _normalize_ui_hide_values(getattr(settings, "mcpgateway_ui_hide_header_items", []), UI_HIDABLE_HEADER_ITEMS)

        if bool(getattr(settings, "mcpgateway_ui_embedded", False)):
            hidden_header_items.update(UI_EMBEDDED_DEFAULT_HIDDEN_HEADER_ITEMS)

    query_ui_hide_raw = request.query_params.get("ui_hide")
    query_ui_hide_values = _normalize_ui_hide_values(query_ui_hide_raw, UI_HIDABLE_SECTIONS, UI_HIDE_SECTION_ALIASES) if query_ui_hide_raw is not None else set()

    if query_ui_hide_raw is not None:
        # Query override is additive and also becomes the persisted session value.
        hidden_sections.update(query_ui_hide_values)
    else:
        cookie_ui_hide_raw = request.cookies.get(UI_HIDE_SECTIONS_COOKIE_NAME)
        hidden_sections.update(_normalize_ui_hide_values(cookie_ui_hide_raw, UI_HIDABLE_SECTIONS, UI_HIDE_SECTION_ALIASES))

    hidden_tabs: set[str] = set()
    for section in hidden_sections:
        hidden_tabs.update(UI_SECTION_TO_TABS.get(section, ()))

    cookie_action: Optional[str] = None
    cookie_value: Optional[str] = None
    if query_ui_hide_raw is not None:
        # Empty query clears persisted overrides.
        if query_ui_hide_values:
            cookie_action = "set"
            cookie_value = ",".join(sorted(query_ui_hide_values))
        else:
            cookie_action = "delete"

    return {
        "hidden_sections": sorted(hidden_sections),
        "hidden_header_items": sorted(hidden_header_items),
        "hidden_tabs": sorted(hidden_tabs),
        "cookie_action": cookie_action,
        "cookie_value": cookie_value,
    }


async def get_hidden_sections_for_user(
    db: Session,
    user_email: str,
    is_admin: bool,
    token_teams: Optional[List[str]],
    static_hidden: set[str],
) -> set[str]:
    """Determine which menu sections should be hidden based on user permissions.

    This function implements permission-based menu hiding by checking if the user
    has the required permission for each section. Sections without required permissions
    are added to the hidden set.

    Args:
        db: Database session
        user_email: Email of the authenticated user
        is_admin: Whether the user is a platform admin
        token_teams: Normalized token team scope (None for unrestricted admin, [] for public-only)
        static_hidden: Sections already hidden by static configuration (UI_HIDDEN_SECTIONS)

    Returns:
        set[str]: Complete set of sections to hide (static + permission-based)

    Examples:
        >>> import asyncio
        >>> from unittest.mock import Mock
        >>> db = Mock()
        >>> # Platform admin with unrestricted token sees all sections
        >>> result = asyncio.run(get_hidden_sections_for_user(db, "admin@example.com", True, None, set()))
        >>> isinstance(result, set)
        True
    """
    # Start with static hidden sections (always hidden regardless of permissions)
    hidden = set(static_hidden)

    # Platform admins with unrestricted tokens (token_teams=None) bypass permission checks
    if is_admin and token_teams is None:
        return hidden

    # Initialize permission service
    permission_service = PermissionService(db, audit_enabled=False)

    # Batch-fetch all permissions for the user (single DB query) when the real
    # permission service is available. Some tests patch only check_permission(),
    # so fall back to per-section checks if get_user_permissions() is absent or
    # not awaitable on the patched object.
    user_permissions: Optional[set[str]] = None
    try:
        maybe_permissions = permission_service.get_user_permissions(
            user_email=user_email,
            team_id=None,  # Check across all teams
            include_all_teams=True,  # Include all team-scoped roles
            token_teams=token_teams,  # SECURITY: Respect token narrowing for menu visibility
        )
        if inspect.isawaitable(maybe_permissions):
            user_permissions = await maybe_permissions
        else:
            LOGGER.debug(f"Falling back to per-section permission checks for user {user_email}: get_user_permissions() is not awaitable")
    except Exception as e:
        LOGGER.debug(f"Falling back to per-section permission checks for user {user_email}: {e}")

    for section, required_permission in SECTION_PERMISSIONS.items():
        # Skip if already hidden by static config
        if section in hidden:
            continue

        # Skip sections with no permission requirement (defensive check for future sections)
        if required_permission is None:
            continue

        if user_permissions is not None:
            # SECURITY: Mirror check_permission() behavior — public-only tokens
            # (token_teams=[]) must never satisfy admin.* permissions.
            if required_permission.startswith("admin.") and token_teams is not None and len(token_teams) == 0:
                has_permission = False
            else:
                # In-memory check after a single batched permission fetch.
                has_permission = required_permission in user_permissions or "*" in user_permissions
        else:
            try:
                has_permission = await permission_service.check_permission(
                    user_email=user_email,
                    permission=required_permission,
                    token_teams=token_teams,
                    allow_admin_bypass=False,
                    check_any_team=True,
                )
            except Exception as e:
                LOGGER.warning(f"Error checking permission {required_permission} for user {user_email}: {e}")
                has_permission = False

        # Hide section if user doesn't have permission
        if not has_permission:
            hidden.add(section)
            LOGGER.debug(f"Hiding section '{section}' for user {user_email}: missing permission '{required_permission}'")

    return hidden


# UI Action Permissions Mapping
# Maps UI permission flags to required RBAC permissions
UI_ACTION_PERMISSIONS = {
    # Create actions
    "can_create_team": "teams.create",
    "can_create_server": "servers.create",
    "can_create_tool": "tools.create",
    "can_create_resource": "resources.create",
    "can_create_prompt": "prompts.create",
    "can_create_gateway": "gateways.create",
    "can_create_user": "admin.user_management",
    "can_create_token": "tokens.read",  # Token creation uses tokens.read, setting nosec cause this is false positive as router uses this permission key.  # nosec B105
    "can_create_agent": "a2a.create",
}


async def get_user_action_permissions(
    db: Session,
    user_email: str,
    is_admin: bool,
    token_teams: Optional[List[str]],
) -> Dict[str, bool]:
    """Batch-check all UI action permissions for a user.

    Returns a dictionary mapping permission flags to boolean values.
    Platform admins with unrestricted tokens get all permissions.

    Args:
        db: Database session
        user_email: User's email
        is_admin: Whether user is platform admin
        token_teams: Normalized token team scope (None for unrestricted admin, [] for public-only)

    Returns:
        Dict mapping permission flags (e.g., "can_create_team") to bool

    Examples:
        >>> import asyncio
        >>> from unittest.mock import Mock
        >>> db = Mock()
        >>> # Platform admin with unrestricted token gets all permissions
        >>> result = asyncio.run(get_user_action_permissions(db, "admin@example.com", True, None))
        >>> result["can_create_team"]
        True
        >>> result["can_create_server"]
        True
    """
    # Platform admins with unrestricted tokens bypass all checks
    if is_admin and token_teams is None:
        return {flag: True for flag in UI_ACTION_PERMISSIONS}

    # Initialize permission service
    permission_service = PermissionService(db, audit_enabled=False)

    # Batch check all permissions
    result = {}
    for flag, permission in UI_ACTION_PERMISSIONS.items():
        try:
            has_permission = await permission_service.check_permission(
                user_email=user_email,
                permission=permission,
                token_teams=token_teams,
                allow_admin_bypass=False,  # UI visibility matches team-scoped permissions (no admin bypass)
                check_any_team=True,
            )
            result[flag] = has_permission
        except Exception as e:
            # Fail-closed: deny permission on error
            LOGGER.warning(f"Error checking {permission} for {user_email}: {e}")
            result[flag] = False

    return result


def set_logging_service(service: LoggingService):
    """Set the logging service instance to use.

    This should be called by main.py to share the same logging service.

    Args:
        service: The LoggingService instance to use

    Examples:
        >>> from mcpgateway.services.logging_service import LoggingService
        >>> from mcpgateway import admin
        >>> logging_svc = LoggingService()
        >>> admin.set_logging_service(logging_svc)
        >>> admin.logging_service is not None
        True
        >>> admin.LOGGER is not None
        True

        Test with different service instance:
        >>> new_svc = LoggingService()
        >>> admin.set_logging_service(new_svc)
        >>> admin.logging_service == new_svc
        True
        >>> admin.LOGGER.name
        'mcpgateway.admin'

        Test that global variables are properly set:
        >>> admin.set_logging_service(logging_svc)
        >>> hasattr(admin, 'logging_service')
        True
        >>> hasattr(admin, 'LOGGER')
        True
    """
    global logging_service, LOGGER  # pylint: disable=global-statement
    logging_service = service
    LOGGER = logging_service.get_logger("mcpgateway.admin")


# Fallback for testing - create a temporary instance if not set
if logging_service is None:
    logging_service = LoggingService()
    LOGGER = logging_service.get_logger("mcpgateway.admin")


# Initialize services
server_service: ServerService = ServerService()
tool_service: ToolService = ToolService()
prompt_service: PromptService = PromptService()
gateway_service: GatewayService = GatewayService()
resource_service: ResourceService = ResourceService()
root_service: RootService = RootService()
export_service: ExportService = ExportService()
import_service: ImportService = ImportService()
# Initialize A2A service only if A2A features are enabled
a2a_service: Optional[A2AAgentService] = A2AAgentService() if settings.mcpgateway_a2a_enabled else None
# Initialize gRPC service only if gRPC features are enabled AND grpcio is installed
grpc_service_mgr: Optional[Any] = GrpcService() if (settings.mcpgateway_grpc_enabled and GRPC_AVAILABLE and GrpcService is not None) else None

# Set up basic authentication

# Rate limiting storage
rate_limit_storage = defaultdict(list)


@lru_cache(maxsize=1)
def load_sri_hashes() -> Dict[str, str]:
    """Load SRI hashes from sri_hashes.json file.

    Uses lru_cache to ensure the file is only read once per process.

    Returns:
        Dict[str, str]: Dictionary mapping resource names to SRI hash strings.
                       Returns empty dict if file not found or invalid.
    """
    try:
        sri_file = Path(__file__).parent / "sri_hashes.json"
        if sri_file.exists():
            with sri_file.open("r") as f:
                return json.load(f)
    except Exception as e:
        LOGGER.warning("Failed to load SRI hashes: %s", e)

    return {}


def _normalize_team_id(team_id: Optional[str]) -> Optional[str]:
    """Validate and normalize team IDs for UI endpoints.

    Args:
        team_id: Raw team ID from request params.

    Returns:
        Normalized team ID string or None.

    Raises:
        ValueError: If the team ID is not a valid UUID.
    """
    if not team_id:
        return None
    try:
        return uuid.UUID(str(team_id)).hex
    except (ValueError, AttributeError, TypeError) as exc:
        raise ValueError("Invalid team ID") from exc


def _validated_team_id_param(team_id: Optional[str] = Query(None, description="Filter by team ID")) -> Optional[str]:
    """Normalize team ID query params and raise on invalid UUIDs.

    Args:
        team_id: Raw team ID from query params.

    Returns:
        Normalized team ID string or None.

    Raises:
        HTTPException: If the team ID is not a valid UUID.
    """
    try:
        return _normalize_team_id(team_id)
    except ValueError as exc:
        raise HTTPException(status_code=400, detail="Invalid team ID") from exc


def _form_team_id(form: Any) -> Optional[str]:
    """Extract and normalize team_id from form data, converting whitespace-only values to None.

    Normalizes at the point of extraction so that both the public-visibility guard
    and ``TeamManagementService.verify_team_for_user`` receive the same value, making
    ``?team_id=%20`` behave identically to an absent ``team_id`` end-to-end.

    Args:
        form: The multipart form data object from the request.

    Returns:
        The stripped team_id string, or None if absent or whitespace-only.
    """
    raw = form.get("team_id")
    if not raw:
        return None
    return str(raw).strip() or None


def _build_admin_redirect(root_path: str, fragment: str, *, error: Optional[str] = None, include_inactive: bool = False, team_id: Optional[str] = None) -> str:
    """Build an admin redirect URL preserving query parameters.

    Args:
        root_path: The root path prefix for the application.
        fragment: The URL fragment/hash (e.g. "tools", "catalog").
        error: Optional error message to include as a query parameter.
        include_inactive: Whether the include_inactive flag was set.
        team_id: Optional team ID to preserve in the redirect.

    Returns:
        A fully constructed redirect URL string.
    """
    params: dict[str, str] = {}
    if error:
        params["error"] = error
    if include_inactive:
        params["include_inactive"] = "true"
    if team_id:
        try:
            params["team_id"] = _normalize_team_id(team_id)
        except ValueError:
            pass
    query = urllib.parse.urlencode(params, quote_via=urllib.parse.quote) if params else ""
    sep = "/?" if query else ""
    return f"{root_path}/admin{sep}{query}#{fragment}"


def get_client_ip(request: Request) -> str:
    """Extract client IP address from request.

    Args:
        request: FastAPI request object

    Returns:
        str: Client IP address

    Examples:
        >>> from unittest.mock import MagicMock
        >>>
        >>> # Test with X-Forwarded-For header
        >>> mock_request = MagicMock()
        >>> mock_request.headers = {"X-Forwarded-For": "192.168.1.1, 10.0.0.1"}
        >>> get_client_ip(mock_request)
        '192.168.1.1'
        >>>
        >>> # Test with X-Real-IP header
        >>> mock_request.headers = {"X-Real-IP": "10.0.0.5"}
        >>> get_client_ip(mock_request)
        '10.0.0.5'
        >>>
        >>> # Test with direct client IP
        >>> mock_request.headers = {}
        >>> mock_request.client.host = "127.0.0.1"
        >>> get_client_ip(mock_request)
        '127.0.0.1'
        >>>
        >>> # Test with no client info
        >>> mock_request.client = None
        >>> get_client_ip(mock_request)
        'unknown'
    """
    # Check for X-Forwarded-For header (proxy/load balancer)
    forwarded_for = request.headers.get("X-Forwarded-For")
    if forwarded_for:
        return forwarded_for.split(",")[0].strip()

    # Check for X-Real-IP header
    real_ip = request.headers.get("X-Real-IP")
    if real_ip:
        return real_ip

    # Fall back to direct client IP
    return request.client.host if request.client else "unknown"


def get_user_agent(request: Request) -> str:
    """Extract user agent from request.

    Args:
        request: FastAPI request object

    Returns:
        str: User agent string

    Examples:
        >>> from unittest.mock import MagicMock
        >>>
        >>> # Test with User-Agent header
        >>> mock_request = MagicMock()
        >>> mock_request.headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0)"}
        >>> get_user_agent(mock_request)
        'Mozilla/5.0 (Windows NT 10.0)'
        >>>
        >>> # Test without User-Agent header
        >>> mock_request.headers = {}
        >>> get_user_agent(mock_request)
        'unknown'
    """
    return request.headers.get("User-Agent", "unknown")


def rate_limit(requests_per_minute: Optional[int] = None):
    """Apply rate limiting to admin endpoints.

    Args:
        requests_per_minute: Maximum requests per minute (uses config default if None)

    Returns:
        Decorator function that enforces rate limiting

    Examples:
        Test basic decorator creation:
        >>> from mcpgateway import admin
        >>> decorator = admin.rate_limit(10)
        >>> callable(decorator)
        True

        Test with None parameter (uses default):
        >>> default_decorator = admin.rate_limit(None)
        >>> callable(default_decorator)
        True

        Test with specific limit:
        >>> limited_decorator = admin.rate_limit(5)
        >>> callable(limited_decorator)
        True

        Test decorator returns wrapper:
        >>> async def dummy_func():
        ...     return "success"
        >>> decorated_func = decorator(dummy_func)
        >>> callable(decorated_func)
        True

        Test rate limit storage structure:
        >>> isinstance(admin.rate_limit_storage, dict)
        True
        >>> from collections import defaultdict
        >>> isinstance(admin.rate_limit_storage, defaultdict)
        True

        Test decorator with zero limit:
        >>> zero_limit_decorator = admin.rate_limit(0)
        >>> callable(zero_limit_decorator)
        True

        Test decorator with high limit:
        >>> high_limit_decorator = admin.rate_limit(1000)
        >>> callable(high_limit_decorator)
        True
    """

    def decorator(func_to_wrap):
        """Decorator that wraps the function with rate limiting logic.

        Args:
            func_to_wrap: The function to be wrapped with rate limiting

        Returns:
            The wrapped function with rate limiting applied
        """
        signature_params = inspect.signature(func_to_wrap).parameters.values()
        accepts_request = any(param.name == "request" or param.kind == inspect.Parameter.VAR_KEYWORD for param in signature_params)

        @wraps(func_to_wrap)
        async def wrapper(*args, request: Optional[Request] = None, **kwargs):
            """Execute the wrapped function with rate limiting enforcement.

            Args:
                *args: Positional arguments to pass to the wrapped function
                request: FastAPI Request object for extracting client IP
                **kwargs: Keyword arguments to pass to the wrapped function

            Returns:
                The result of the wrapped function call

            Raises:
                HTTPException: When rate limit is exceeded (429 status)
            """
            # use configured limit if none provided
            limit = requests_per_minute or settings.validation_max_requests_per_minute

            # request can be None in some edge cases (e.g., tests)
            client_ip = request.client.host if request and request.client else "unknown"
            current_time = time.time()
            minute_ago = current_time - 60

            # prune old timestamps
            rate_limit_storage[client_ip] = [ts for ts in rate_limit_storage[client_ip] if ts > minute_ago]

            # enforce
            if len(rate_limit_storage[client_ip]) >= limit:
                LOGGER.warning(f"Rate limit exceeded for IP {client_ip} on endpoint {func_to_wrap.__name__}")
                raise HTTPException(
                    status_code=429,
                    detail=f"Rate limit exceeded. Maximum {limit} requests per minute.",
                )
            rate_limit_storage[client_ip].append(current_time)
            if accepts_request:
                return await func_to_wrap(*args, request=request, **kwargs)
            return await func_to_wrap(*args, **kwargs)

        return wrapper

    return decorator


def _get_user_team_roles(db: Session, user_email: str) -> Dict[str, str]:
    """Return a {team_id: role} mapping for a user's active memberships.

    Args:
        db: The SQLAlchemy database session.
        user_email: Email address of the user to query memberships for.

    Returns:
        Dict mapping team_id to the user's role in that team.
    """
    return get_user_team_roles(db, user_email)


def _adjust_pagination_for_conversion_failures(pagination: "PaginationMeta", failed_count: int, rendered_count: int) -> None:
    """Adjust pagination metadata to account for DB-to-Pydantic conversion failures.

    When items on the current page fail to convert, the "Showing X of Y" display
    would otherwise count items that aren't actually displayed. This adjusts
    total_items and recomputes derived fields (total_pages, has_next, has_prev).
    Also sets page_items to the actual number of successfully rendered items.

    Args:
        pagination: The PaginationMeta object to adjust (modified in-place).
        failed_count: Number of items that failed conversion on the current page.
        rendered_count: Number of items successfully converted and rendered on the current page.
    """
    if failed_count > 0:
        pagination.total_items = max(0, pagination.total_items - failed_count)
        pagination.total_pages = math.ceil(pagination.total_items / pagination.per_page) if pagination.total_items > 0 else 0
        # Do NOT clamp pagination.page — data was already fetched for this page,
        # so the page number must match the displayed data.
        pagination.has_next = pagination.page < pagination.total_pages
        pagination.has_prev = pagination.page > 1
    # Always set page_items to reflect actual rendered count (even if failed_count == 0)
    pagination.page_items = rendered_count


def _get_span_entity_performance(
    db: Session,
    cutoff_time: datetime,
    cutoff_time_naive: datetime,
    span_names: List[str],
    json_key: str,
    result_key: str,
    limit: int = 20,
) -> List[dict]:
    """Shared helper to compute performance metrics for spans grouped by a JSON attribute.

    Args:
        db: Database session.
        cutoff_time: Timezone-aware datetime for filtering spans.
        cutoff_time_naive: Naive datetime for SQLite compatibility.
        span_names: List of span names to filter (e.g., ["tool.invoke"]).
        json_key: JSON attribute key to group by (e.g., "tool.name").
        result_key: Key name for the entity in returned dicts (e.g., "tool_name").
        limit: Maximum number of results to return (default: 20).

    Returns:
        List[dict]: List of dicts with entity key and performance metrics (count, avg, min, max, percentiles).

    Raises:
        ValueError: If `json_key` is not a valid identifier (only letters, digits, underscore, dot or hyphen),
            this function will raise a ValueError to prevent unsafe SQL interpolation when using
            PostgreSQL native percentile queries.

    Note:
        Uses PostgreSQL `percentile_cont` when available and enabled via USE_POSTGRESDB_PERCENTILES config,
        otherwise falls back to Python aggregation.
    """
    # Validate json_key to prevent SQL injection in both PostgreSQL and SQLite paths
    if not isinstance(json_key, str) or not re.match(r"^[A-Za-z0-9_.-]+$", json_key):
        raise ValueError("Invalid json_key for percentile query")

    dialect_name = db.get_bind().dialect.name

    # Use database-native percentiles only if enabled in config and using PostgreSQL
    if dialect_name == "postgresql" and settings.use_postgresdb_percentiles:
        # Safe: uses SQLAlchemy's bindparam for the IN-list
        stats_sql = text("""
            SELECT
                (attributes->> :json_key) AS entity,
                COUNT(*) AS count,
                AVG(duration_ms) AS avg_duration_ms,
                MIN(duration_ms) AS min_duration_ms,
                MAX(duration_ms) AS max_duration_ms,
                percentile_cont(0.50) WITHIN GROUP (ORDER BY duration_ms) AS p50,
                percentile_cont(0.90) WITHIN GROUP (ORDER BY duration_ms) AS p90,
                percentile_cont(0.95) WITHIN GROUP (ORDER BY duration_ms) AS p95,
                percentile_cont(0.99) WITHIN GROUP (ORDER BY duration_ms) AS p99
            FROM observability_spans
            WHERE name IN :names
              AND start_time >= :cutoff_time
              AND duration_ms IS NOT NULL
              AND (attributes->> :json_key) IS NOT NULL
            GROUP BY entity
            ORDER BY avg_duration_ms DESC
            LIMIT :limit
            """).bindparams(bindparam("names", expanding=True))

        results = db.execute(
            stats_sql,
            {"cutoff_time": cutoff_time, "limit": limit, "names": span_names, "json_key": json_key},
        ).fetchall()

        items: List[dict] = []
        for row in results:
            items.append(
                {
                    result_key: row.entity,
                    "count": int(row.count) if row.count is not None else 0,
                    "avg_duration_ms": round(float(row.avg_duration_ms), 2) if row.avg_duration_ms is not None else 0,
                    "min_duration_ms": round(float(row.min_duration_ms), 2) if row.min_duration_ms is not None else 0,
                    "max_duration_ms": round(float(row.max_duration_ms), 2) if row.max_duration_ms is not None else 0,
                    "p50": round(float(row.p50), 2) if row.p50 is not None else 0,
                    "p90": round(float(row.p90), 2) if row.p90 is not None else 0,
                    "p95": round(float(row.p95), 2) if row.p95 is not None else 0,
                    "p99": round(float(row.p99), 2) if row.p99 is not None else 0,
                }
            )

        return items

    # Fallback: Python aggregation (SQLite or other DBs, or PostgreSQL with USE_POSTGRESDB_PERCENTILES=False)
    # Pass dialect_name to extract_json_field to ensure correct SQL syntax for the actual database
    # Use timezone-aware cutoff for PostgreSQL to avoid timezone drift, naive for SQLite
    effective_cutoff = cutoff_time if dialect_name == "postgresql" else cutoff_time_naive
    spans = (
        db.query(
            extract_json_field(ObservabilitySpan.attributes, f'$."{json_key}"', dialect_name=dialect_name).label("entity"),
            ObservabilitySpan.duration_ms,
        )
        .filter(
            ObservabilitySpan.name.in_(span_names),
            ObservabilitySpan.start_time >= effective_cutoff,
            ObservabilitySpan.duration_ms.isnot(None),
            extract_json_field(ObservabilitySpan.attributes, f'$."{json_key}"', dialect_name=dialect_name).isnot(None),
        )
        .all()
    )

    durations_by_entity: Dict[str, List[float]] = defaultdict(list)
    for span in spans:
        durations_by_entity[span.entity].append(span.duration_ms)

    def percentile(data: List[float], p: float) -> float:
        """Calculate percentile using linear interpolation (matches PostgreSQL percentile_cont).

        Args:
            data: Sorted, non-empty list of numeric values.
            p: Percentile to calculate (0.0 to 1.0).

        Returns:
            float: The interpolated percentile value.
        """
        n = len(data)
        k = p * (n - 1)
        f = int(k)
        c = k - f
        next_i = min(f + 1, n - 1)
        return data[f] + c * (data[next_i] - data[f])

    items: List[dict] = []
    for entity, durations in durations_by_entity.items():
        durations_sorted = sorted(durations)
        n = len(durations_sorted)
        items.append(
            {
                result_key: entity,
                "count": n,
                "avg_duration_ms": round(sum(durations) / n, 2),
                "min_duration_ms": round(min(durations), 2),
                "max_duration_ms": round(max(durations), 2),
                "p50": round(percentile(durations_sorted, 0.50), 2),
                "p90": round(percentile(durations_sorted, 0.90), 2),
                "p95": round(percentile(durations_sorted, 0.95), 2),
                "p99": round(percentile(durations_sorted, 0.99), 2),
            }
        )

    items.sort(key=lambda x: x.get("avg_duration_ms", 0), reverse=True)
    return items[:limit]


def get_user_id(user: Union[str, dict[str, Any], object] = None) -> str:
    """Return the user ID from a JWT payload, user object, or string.

    Args:
        user (Union[str, dict, object], optional): User object from JWT token
            (from get_current_user_with_permissions). Can be:
            - dict: representing JWT payload with 'id', 'user_id', or 'sub'
            - object: with an `id` attribute
            - str: a user ID string
            - None: will return "unknown"
            Defaults to None.

    Returns:
        str: User ID, or "unknown" if no ID can be determined.
             - If `user` is a dict, returns `id` if present, else `user_id`, else `sub`, else email as fallback, else "unknown".
             - If `user` has an `id` attribute, returns that.
             - If `user` is a string, returns it.
             - If `user` is None, returns "unknown".
             - Otherwise, returns str(user).

    Examples:
        >>> get_user_id({'id': '123'})
        '123'
        >>> get_user_id({'user_id': '456'})
        '456'
        >>> get_user_id({'sub': 'alice@example.com'})
        'alice@example.com'
        >>> get_user_id({'email': 'bob@company.com'})
        'bob@company.com'
        >>> class MockUser:
        ...     def __init__(self, user_id):
        ...         self.id = user_id
        >>> get_user_id(MockUser('789'))
        '789'
        >>> get_user_id(None)
        'unknown'
        >>> get_user_id('user-xyz')
        'user-xyz'
        >>> get_user_id({})
        'unknown'
    """
    if isinstance(user, dict):
        # Try multiple possible ID fields in order of preference.
        # Email is the primary key in the model, so that's our mostly likely result.
        return user.get("id") or user.get("user_id") or user.get("sub") or user.get("email") or "unknown"

    return "unknown" if user is None else str(getattr(user, "id", user))


def serialize_datetime(obj):
    """Convert datetime objects to ISO format strings for JSON serialization.

    Args:
        obj: Object to serialize, potentially a datetime

    Returns:
        str: ISO format string if obj is datetime, otherwise returns obj unchanged

    Examples:
        Test with datetime object:
        >>> from mcpgateway import admin
        >>> from datetime import datetime, timezone
        >>> dt = datetime(2025, 1, 15, 10, 30, 45, tzinfo=timezone.utc)
        >>> admin.serialize_datetime(dt)
        '2025-01-15T10:30:45+00:00'

        Test with naive datetime:
        >>> dt_naive = datetime(2025, 3, 20, 14, 15, 30)
        >>> result = admin.serialize_datetime(dt_naive)
        >>> '2025-03-20T14:15:30' in result
        True

        Test with datetime with microseconds:
        >>> dt_micro = datetime(2025, 6, 10, 9, 25, 12, 500000)
        >>> result = admin.serialize_datetime(dt_micro)
        >>> '2025-06-10T09:25:12.500000' in result
        True

        Test with non-datetime objects (should return unchanged):
        >>> admin.serialize_datetime("2025-01-15T10:30:45")
        '2025-01-15T10:30:45'
        >>> admin.serialize_datetime(12345)
        12345
        >>> admin.serialize_datetime(['a', 'list'])
        ['a', 'list']
        >>> admin.serialize_datetime({'key': 'value'})
        {'key': 'value'}
        >>> admin.serialize_datetime(None)
        >>> admin.serialize_datetime(True)
        True

        Test with current datetime:
        >>> import datetime as dt_module
        >>> now = dt_module.datetime.now()
        >>> result = admin.serialize_datetime(now)
        >>> isinstance(result, str)
        True
        >>> 'T' in result  # ISO format contains 'T' separator
        True

        Test edge case with datetime min/max:
        >>> dt_min = datetime.min
        >>> result = admin.serialize_datetime(dt_min)
        >>> result.startswith('0001-01-01T')
        True
    """
    if isinstance(obj, datetime):
        return obj.isoformat()
    return obj


def validate_password_strength(password: str, email: str = "", is_admin: bool = False) -> tuple[bool, str]:
    """Validate password meets strength requirements.

    Delegates to PasswordPolicyService for comprehensive validation including
    complexity, common password detection, sequential character detection,
    and username-based validation.

    Respects password_policy_enabled toggle - if disabled, all passwords pass.

    Args:
        password: Password to validate
        email: User's email address (for username-based validation)
        is_admin: Whether this is an admin account (requires longer password)

    Returns:
        tuple: (is_valid, error_message)
    """
    # If password policy is disabled, skip all validation
    if not getattr(settings, "password_policy_enabled", True):
        return True, ""

    # First-Party
    from mcpgateway.services.password_policy_service import PasswordPolicyError, PasswordPolicyService

    with SessionLocal() as db:
        policy = PasswordPolicyService(db)
        try:
            policy.validate_user_password(password, email or None, is_admin)
            return True, ""
        except PasswordPolicyError as e:
            return False, str(e)


ADMIN_CSRF_COOKIE_NAME = "mcpgateway_csrf_token"
ADMIN_CSRF_HEADER_NAME = "x-csrf-token"
ADMIN_CSRF_FORM_FIELD = "csrf_token"


def _admin_cookie_path(request: Request) -> str:
    """Build admin cookie path honoring ASGI root_path.

    Args:
        request: Incoming request used to read ASGI ``root_path``.

    Returns:
        Cookie path scoped to the deployed app root so admin-originated
        non-/admin mutations can carry the same double-submit token.
    """
    root_path = _resolve_root_path(request)
    return root_path or "/"


def _normalize_origin_parts(scheme: str, netloc: str) -> tuple[str, str, int]:
    """Normalize origin components for exact same-origin comparisons.

    Args:
        scheme: URL scheme (for example ``http`` or ``https``).
        netloc: URL authority component (host and optional port).

    Returns:
        Tuple of normalized scheme, hostname, and resolved port.
    """
    parsed = urllib.parse.urlparse(f"{scheme}://{netloc}")
    normalized_scheme = (parsed.scheme or scheme or "http").lower()
    normalized_host = (parsed.hostname or "").lower()
    normalized_port = parsed.port
    if normalized_port is None:
        normalized_port = 443 if normalized_scheme == "https" else 80
    return normalized_scheme, normalized_host, normalized_port


def _request_origin_matches(request: Request) -> bool:
    """Return ``True`` when Origin/Referer matches this request origin.

    The function first performs an exact same-origin comparison using the
    request's forwarded headers (``X-Forwarded-Proto`` / ``X-Forwarded-Host``).
    When that fails — common behind layered reverse proxies where forwarded
    headers reflect internal hops rather than the external scheme — it falls
    back to checking whether the candidate origin is explicitly listed in
    ``settings.allowed_origins``.  Wildcard entries (``*``, ``null``, ``""``)
    are excluded from the fallback to preserve fail-closed behavior.

    Args:
        request: Incoming request carrying Origin/Referer and host headers.

    Returns:
        ``True`` when candidate origin matches either the request origin or an
        entry in ``settings.allowed_origins``; otherwise ``False``.
    """
    origin = request.headers.get("origin")
    referer = request.headers.get("referer")

    candidate_origin = origin
    if not candidate_origin and referer:
        try:
            parsed_referer = urllib.parse.urlparse(referer)
            if parsed_referer.scheme and parsed_referer.netloc:
                candidate_origin = f"{parsed_referer.scheme}://{parsed_referer.netloc}"
        except Exception:  # nosec B110 - invalid Referer should fail closed below
            candidate_origin = None

    if not candidate_origin:
        return False

    parsed_candidate = urllib.parse.urlparse(candidate_origin)
    if not parsed_candidate.scheme or not parsed_candidate.netloc:
        return False

    forwarded_proto = request.headers.get("x-forwarded-proto")
    forwarded_host = request.headers.get("x-forwarded-host")
    request_scheme = (forwarded_proto.split(",")[0].strip() if forwarded_proto else request.url.scheme) or "http"
    request_netloc = (forwarded_host.split(",")[0].strip() if forwarded_host else request.headers.get("host")) or request.url.netloc

    candidate_parts = _normalize_origin_parts(parsed_candidate.scheme, parsed_candidate.netloc)
    request_parts = _normalize_origin_parts(request_scheme, request_netloc)
    if candidate_parts == request_parts:
        return True

    # Fallback: accept origins explicitly listed in settings.allowed_origins.
    # Handles reverse-proxy deployments where forwarded headers may not
    # accurately reflect the external scheme/host.
    for allowed in settings.allowed_origins:
        # Normalize each allowed origin to avoid config surprises such as
        # ["https://a.com "] or [" null "], which could otherwise be
        # mis-parsed or skipped.
        allowed_normalized = str(allowed).strip()
        if not allowed_normalized or allowed_normalized == "*" or allowed_normalized.casefold() == "null":
            continue
        try:
            allowed_parsed = urllib.parse.urlparse(allowed_normalized if "://" in allowed_normalized else f"https://{allowed_normalized}")
            if not allowed_parsed.scheme or not allowed_parsed.netloc:
                continue
            if candidate_parts == _normalize_origin_parts(allowed_parsed.scheme, allowed_parsed.netloc):
                return True
        except Exception:  # nosec B112 - malformed allowed_origins entry should not crash
            continue

    return False


def _set_admin_csrf_cookie(request: Request, response: Response) -> str:
    """Set or refresh admin CSRF cookie and return token value.

    Args:
        request: Incoming request used for existing token and path scoping.
        response: Outgoing response where the cookie will be written.

    Returns:
        CSRF token value stored in the response cookie.
    """
    existing_token = request.cookies.get(ADMIN_CSRF_COOKIE_NAME)
    csrf_token = existing_token if isinstance(existing_token, str) and len(existing_token) >= 32 else secrets.token_urlsafe(32)

    use_secure = (settings.environment == "production") or settings.secure_cookies
    max_age = max(300, int(getattr(settings, "token_expiry", 60)) * 60)
    response.set_cookie(
        key=ADMIN_CSRF_COOKIE_NAME,
        value=csrf_token,
        max_age=max_age,
        path=_admin_cookie_path(request),
        httponly=False,
        secure=use_secure,
        samesite="strict",
    )
    return csrf_token


def _clear_admin_csrf_cookie(request: Request, response: Response) -> None:
    """Clear admin CSRF cookie.

    Args:
        request: Incoming request used to compute cookie path.
        response: Outgoing response where cookie deletion is applied.
    """
    use_secure = (settings.environment == "production") or settings.secure_cookies
    response.delete_cookie(
        key=ADMIN_CSRF_COOKIE_NAME,
        path=_admin_cookie_path(request),
        secure=use_secure,
        httponly=False,
        samesite="strict",
    )


async def enforce_admin_csrf(request: Request) -> None:
    """Enforce CSRF protections for cookie-authenticated admin mutations.

    Args:
        request: Incoming admin request to validate.

    Returns:
        ``None`` when validation passes.

    Raises:
        HTTPException: If origin validation fails or CSRF token validation fails.
    """
    if request.method.upper() in {"GET", "HEAD", "OPTIONS", "TRACE"}:
        return

    jwt_cookie = request.cookies.get("jwt_token")
    if not jwt_cookie:
        # CSRF is relevant only for browser cookie auth. Token-auth API calls
        # without session cookies are not subject to browser CSRF.
        return

    if not _request_origin_matches(request):
        raise HTTPException(status_code=403, detail="CSRF origin validation failed")

    csrf_cookie = request.cookies.get(ADMIN_CSRF_COOKIE_NAME)
    if not isinstance(csrf_cookie, str) or not csrf_cookie:
        raise HTTPException(status_code=403, detail="CSRF token cookie missing")

    submitted_token = request.headers.get(ADMIN_CSRF_HEADER_NAME)
    if not submitted_token:
        content_type = (request.headers.get("content-type") or "").lower()
        if "application/x-www-form-urlencoded" in content_type:
            try:
                form = await request.form()
                form_token = form.get(ADMIN_CSRF_FORM_FIELD)
                if isinstance(form_token, str):
                    submitted_token = form_token
            except Exception:
                submitted_token = None

    if not isinstance(submitted_token, str) or not submitted_token or not secrets.compare_digest(submitted_token, csrf_cookie):
        raise HTTPException(status_code=403, detail="CSRF token validation failed")


admin_router = APIRouter(
    prefix="/admin",
    tags=["Admin UI"],
    dependencies=[Depends(enforce_admin_csrf)],
)

####################
# Admin UI Routes  #
####################


def _escape_like(value: str) -> str:
    """Escape SQL LIKE wildcard characters.

    Args:
        value (str): Raw search string.

    Returns:
        str: Escaped string safe for use in ``LIKE`` expressions with ``ESCAPE '\\'``.
    """
    return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")


def _like_contains(column, value: str):
    """Case-insensitive substring match with proper LIKE wildcard escaping.

    Wraps the escaped *value* with ``%`` wildcards and adds an explicit
    ``ESCAPE '\\\\'`` clause so that ``%`` and ``_`` in the search term are
    treated literally on all backends (SQLite requires the clause).

    Args:
        column: SQLAlchemy column expression (pre-wrapped with ``func.lower``
            / ``coalesce`` as needed by the caller).
        value: Raw search term — escaping is applied internally.

    Returns:
        A SQLAlchemy binary expression suitable for ``.where()``.
    """
    return column.like("%" + _escape_like(value) + "%", escape="\\")


async def _get_user_team_ids(user: dict, db: Session) -> list:
    """Return team IDs for the authenticated user.

    When called from :func:`admin_unified_search`, the user dict carries a
    ``_cached_team_ids`` key so the expensive lookup is executed only once
    per request instead of once per entity type.

    If the auth context includes explicit ``token_teams`` (API tokens), the
    returned IDs are derived from that token scope so search endpoints cannot
    return entities outside the token's team restrictions.

    Args:
        user (dict): Authenticated user context.
        db (Session): Database session.

    Returns:
        list: Team ID list for the user.
    """
    cached = user.get("_cached_team_ids")
    if cached is not None:
        return cached

    if "token_teams" in user:
        token_teams = user.get("token_teams")
        if token_teams is not None:
            team_ids: list[str] = []
            for team in token_teams:
                if isinstance(team, dict):
                    team_id = team.get("id")
                    if isinstance(team_id, str) and team_id:
                        team_ids.append(team_id)
                elif isinstance(team, str) and team:
                    team_ids.append(team)
            return team_ids

    user_email = get_user_email(user)
    team_service = TeamManagementService(db)
    user_teams = await team_service.get_user_teams(user_email)
    return [t.id for t in user_teams]


def _check_public_visibility_allowed(visibility: str, team_id: Optional[str] = None) -> None:
    """Raise HTTP 422 if public visibility is disabled and the request is team-scoped.

    Public visibility is only restricted when a team_id is present — on the
    global admin view (no team) public entities are still permitted.

    Args:
        visibility: The visibility value from the incoming form or request body.
        team_id: The team ID from the form or request body, if any.

    Raises:
        HTTPException: 422 when flag is false, team_id is set, and visibility is 'public'.
    """
    if not settings.allow_public_visibility and visibility == "public" and team_id and team_id.strip():
        raise HTTPException(
            status_code=422,
            detail="Public visibility is disabled by platform configuration (ALLOW_PUBLIC_VISIBILITY=false).",
        )


def _is_explicit_token_team_scope(user: Any) -> bool:
    """Return whether the auth context carries explicit token team scope.

    Tokens with ``token_teams`` present and not ``None`` are scope-constrained
    (including public-only tokens with ``[]``). ``None`` denotes admin bypass.

    Args:
        user (Any): Authenticated user context.

    Returns:
        bool: True when ``token_teams`` is present and not ``None``.
    """
    if not isinstance(user, dict):
        return False
    return "token_teams" in user and user.get("token_teams") is not None


def _owner_access_condition(owner_column, team_column, *, user_email: str, team_ids: list[str], user: Any):
    """Build owner visibility predicate honoring token team scoping.

    For explicit token scopes, owner visibility is constrained to token teams.
    For legacy/session contexts without explicit scope (or admin bypass), keep
    existing owner visibility semantics.

    Args:
        owner_column: SQLAlchemy owner-email column expression.
        team_column: SQLAlchemy team-id column expression.
        user_email (str): Current user email.
        team_ids (list[str]): Team IDs visible to this auth context.
        user (Any): Authenticated user context.

    Returns:
        Any: SQLAlchemy boolean predicate for owner visibility.
    """
    if _is_explicit_token_team_scope(user):
        if not team_ids:
            return false()
        return and_(owner_column == user_email, team_column.in_(team_ids))
    return owner_column == user_email


def _merge_select_all_ids(form: Any, flag_key: str, all_ids_key: str, checked_list: list[str]) -> list[str]:
    """Merge server-fetched IDs with UI-checked IDs when "Select All" is active.

    When the user clicks "Select All" in a paginated list, the browser populates
    *all_ids_key* with IDs fetched from the corresponding /ids endpoint. Because
    that endpoint may be team-scoped, it can miss platform-public items that are
    still visible (and checked) in the UI. Taking the union of both sources
    ensures every explicitly selected item is preserved.

    Note: both sources are client-supplied form values. Downstream persistence
    code is responsible for enforcing final access control on the merged IDs.

    Args:
        form: Starlette form object.
        flag_key (str): Form field that signals "Select All" mode (e.g. ``"selectAllTools"``).
        all_ids_key (str): Form field holding the JSON-encoded server-fetched IDs.
        checked_list (list[str]): IDs collected from checked checkboxes in the form.

    Returns:
        list[str]: Merged, deduplicated list of string IDs; or *checked_list* unchanged
        when Select All is not active or the JSON payload cannot be parsed.
    """
    if form.get(flag_key) != "true":
        return checked_list
    raw = form.get(all_ids_key) or "[]"
    try:
        server_ids = orjson.loads(raw)
        # Normalise to str to avoid silent int/str duplicates from different sources.
        merged = list({str(i) for i in server_ids} | set(checked_list))
        LOGGER.info("Select All (%s): %d items after merge", all_ids_key, len(merged))
        return merged
    except orjson.JSONDecodeError:
        LOGGER.warning("Failed to parse %s JSON, falling back to checked items", all_ids_key)
        return checked_list


async def _has_permission(
    *,
    db: Session,
    user: dict,
    permission: str,
    team_id: Optional[str] = None,
    allow_admin_bypass: bool = False,
    check_any_team: bool = False,
) -> bool:
    """Check a permission for the current user context.

    Args:
        db (Session): Database session.
        user (dict): Authenticated user context.
        permission (str): Permission to evaluate.
        team_id (Optional[str]): Optional team scope for the permission check.
        allow_admin_bypass (bool): Whether admin bypass is allowed.
        check_any_team (bool): Whether to check across all team-scoped roles.

    Returns:
        bool: True when permission is granted.
    """
    permission_service = PermissionService(db)
    return await permission_service.check_permission(
        user_email=get_user_email(user),
        permission=permission,
        team_id=team_id,
        token_teams=user.get("token_teams"),
        ip_address=user.get("ip_address"),
        user_agent=user.get("user_agent"),
        allow_admin_bypass=allow_admin_bypass,
        check_any_team=check_any_team,
    )


def _normalize_search_query(query: Optional[str]) -> str:
    """Normalize search query values for consistent filtering.

    Args:
        query (Optional[str]): Raw query value or FastAPI ``Query`` wrapper.

    Returns:
        str: Lowercased, trimmed query string (empty string when unset).
    """
    if query is None:
        return ""
    if isinstance(query, str):
        return query.strip().lower()

    # Support direct unit-test invocation where FastAPI Query(...) defaults
    # can be passed through instead of resolved string values.
    default_value = getattr(query, "default", None)
    if default_value is None:
        return ""
    if isinstance(default_value, str):
        return default_value.strip().lower()
    return str(default_value).strip().lower()


def _normalize_tags_query(tags: Any) -> str:
    """Normalize tags query values.

    Handles plain strings and FastAPI `Query(...)` defaults when handlers are
    called directly in unit tests.

    Args:
        tags (Any): Raw tags value or FastAPI ``Query`` wrapper.

    Returns:
        str: Trimmed tags expression (empty string when unset).
    """
    if tags is None:
        return ""
    if isinstance(tags, str):
        return tags.strip()

    default_value = getattr(tags, "default", None)
    if default_value is None:
        return ""
    if isinstance(default_value, str):
        return default_value.strip()
    return str(default_value).strip()


def _normalize_int_query(value: Any, fallback: int) -> int:
    """Normalize integer query values, including FastAPI Query defaults.

    Args:
        value (Any): Raw integer value or FastAPI ``Query`` wrapper.
        fallback (int): Fallback value when normalization fails.

    Returns:
        int: Normalized integer value.
    """
    if isinstance(value, int):
        return value

    default_value = getattr(value, "default", None)
    if isinstance(default_value, int):
        return default_value

    try:
        return int(value)
    except (TypeError, ValueError):
        return fallback


_TAG_MAX_GROUPS = 20
_TAG_MAX_TERMS_PER_GROUP = 10


def _parse_tag_filter_groups(tags: Optional[str]) -> list[list[str]]:
    """Parse tag filter expressions.

    Expression syntax:
    - `,` separates OR groups (capped at :data:`_TAG_MAX_GROUPS`)
    - `+` separates AND terms inside a group (capped at :data:`_TAG_MAX_TERMS_PER_GROUP`)

    Examples:
      - `"prod,staging"` => [["prod"], ["staging"]]
      - `"mcp+critical"` => [["mcp", "critical"]]
      - `"mcp+critical,ui"` => [["mcp", "critical"], ["ui"]]

    Args:
        tags (Optional[str]): Tag expression with comma-separated OR groups and
            plus-separated AND terms.

    Returns:
        list[list[str]]: Parsed tag groups ready for SQL filter construction.
    """
    if not tags:
        return []

    groups: list[list[str]] = []
    for raw_group in tags.split(","):
        if len(groups) >= _TAG_MAX_GROUPS:
            break
        candidate = [term.strip() for term in raw_group.split("+") if term.strip()][:_TAG_MAX_TERMS_PER_GROUP]
        if candidate:
            groups.append(candidate)
    return groups


def _apply_tag_filter_groups(query: Any, db: Session, column: Any, tag_groups: list[list[str]]) -> Any:
    """Apply parsed tag filter groups to a SQLAlchemy query.

    Args:
        query (Any): SQLAlchemy ``select`` query to update.
        db (Session): Database session.
        column (Any): SQLAlchemy model column containing tags.
        tag_groups (list[list[str]]): Parsed OR-of-AND tag groups.

    Returns:
        Any: Updated query with tag filters applied.
    """
    if not tag_groups:
        return query

    group_exprs = []
    for group in tag_groups:
        # Single term group => OR semantics (term exists)
        # Multi-term group => AND semantics (all terms exist)
        group_exprs.append(json_contains_tag_expr(db, column, group, match_any=len(group) == 1))

    if len(group_exprs) == 1:
        return query.where(group_exprs[0])
    return query.where(or_(*group_exprs))


def _build_search_response(
    *,
    entity_key: str,
    entity_type: str,
    items: list[dict[str, Any]],
    query: str,
    tags: str,
    tag_groups: list[list[str]],
) -> dict[str, Any]:
    """Build a consistent search response while preserving legacy keys.

    Args:
        entity_key (str): Legacy entity key (for example ``tools``).
        entity_type (str): Canonical entity type label.
        items (list[dict[str, Any]]): Serialized entity items.
        query (str): Normalized free-text query.
        tags (str): Normalized tag expression.
        tag_groups (list[list[str]]): Parsed tag groups.

    Returns:
        dict[str, Any]: Unified search payload with legacy and standard keys.
    """
    filters_applied = {"q": query, "tags": tags, "tag_groups": tag_groups}
    return {
        entity_key: items,  # legacy key for backward compatibility
        "items": items,
        "count": len(items),
        "entity_type": entity_type,
        "query": query,
        "filters_applied": filters_applied,
    }


@admin_router.get("/overview/partial")
@require_permission("admin.overview", allow_admin_bypass=False)
async def get_overview_partial(
    request: Request,
    db: Session = Depends(get_db),
    user=Depends(get_current_user_with_permissions),
) -> HTMLResponse:
    """Render the overview dashboard partial HTML template.

    This endpoint returns a rendered HTML partial containing an architecture
    diagram showing ContextForge inputs (Virtual Servers), middleware (Plugins),
    and outputs (A2A Agents, Gateways, Tools, etc.) along with key metrics.

    Args:
        request: FastAPI request object
        db: Database session
        user: Authenticated user

    Returns:
        HTMLResponse with rendered overview partial template
    """
    LOGGER.debug(f"User {get_user_email(user)} requested overview partial")

    try:
        # Gather counts for all entity types
        # Note: SQLAlchemy func.count requires pylint disable=not-callable
        # Virtual Servers (inputs) - uses 'enabled' field
        servers_total = db.query(func.count(DbServer.id)).scalar() or 0  # pylint: disable=not-callable
        servers_active = db.query(func.count(DbServer.id)).filter(DbServer.enabled.is_(True)).scalar() or 0  # pylint: disable=not-callable

        # Gateways - uses 'enabled' field
        gateways_total = db.query(func.count(DbGateway.id)).scalar() or 0  # pylint: disable=not-callable
        gateways_active = db.query(func.count(DbGateway.id)).filter(DbGateway.enabled.is_(True)).scalar() or 0  # pylint: disable=not-callable

        # A2A Agents (if enabled) - uses 'enabled' field
        a2a_total = 0
        a2a_active = 0
        if settings.mcpgateway_a2a_enabled:
            a2a_total = db.query(func.count(DbA2AAgent.id)).scalar() or 0  # pylint: disable=not-callable
            a2a_active = db.query(func.count(DbA2AAgent.id)).filter(DbA2AAgent.enabled.is_(True)).scalar() or 0  # pylint: disable=not-callable

        # Tools - uses 'enabled' field
        tools_total = db.query(func.count(DbTool.id)).scalar() or 0  # pylint: disable=not-callable
        tools_active = db.query(func.count(DbTool.id)).filter(DbTool.enabled.is_(True)).scalar() or 0  # pylint: disable=not-callable

        # Prompts - uses 'enabled' field
        prompts_total = db.query(func.count(DbPrompt.id)).scalar() or 0  # pylint: disable=not-callable
        prompts_active = db.query(func.count(DbPrompt.id)).filter(DbPrompt.enabled.is_(True)).scalar() or 0  # pylint: disable=not-callable

        # Resources - uses 'enabled' field
        resources_total = db.query(func.count(DbResource.id)).scalar() or 0  # pylint: disable=not-callable
        resources_active = db.query(func.count(DbResource.id)).filter(DbResource.enabled.is_(True)).scalar() or 0  # pylint: disable=not-callable

        # Plugin stats — self-heal the cache so the overview reflects the live
        # shared toggle even on a process that booted with plugins disabled.
        overview_plugin_service = get_plugin_service()
        await _sync_plugin_service_from_runtime(request, overview_plugin_service)
        plugin_stats = await overview_plugin_service.get_plugin_statistics()

        # Infrastructure status (database, cache, uptime)
        _, db_reachable = version_module._database_version()  # pylint: disable=protected-access
        db_dialect = version_module.engine.dialect.name
        cache_type = settings.cache_type
        uptime_seconds = int(time.time() - version_module.START_TIME)

        # Redis status (if applicable)
        redis_available = version_module.REDIS_AVAILABLE
        redis_reachable = False
        if redis_available and cache_type.lower() == "redis" and settings.redis_url:
            try:
                # First-Party
                from mcpgateway.utils.redis_client import is_redis_available  # pylint: disable=import-outside-toplevel

                redis_reachable = await is_redis_available()
            except Exception:
                redis_reachable = False

        # Aggregate metrics from services
        overview_tool_service = ToolService()
        overview_server_service = ServerService()
        overview_prompt_service = PromptService()
        overview_resource_service = ResourceService()

        tool_metrics = await overview_tool_service.aggregate_metrics(db)
        server_metrics = await overview_server_service.aggregate_metrics(db)
        prompt_metrics = await overview_prompt_service.aggregate_metrics(db)
        resource_metrics = await overview_resource_service.aggregate_metrics(db)

        # Calculate totals
        total_executions = (
            (tool_metrics.get("total_executions", 0) if isinstance(tool_metrics, dict) else getattr(tool_metrics, "total_executions", 0))
            + (server_metrics.total_executions if hasattr(server_metrics, "total_executions") else server_metrics.get("total_executions", 0))
            + (prompt_metrics.get("total_executions", 0) if isinstance(prompt_metrics, dict) else getattr(prompt_metrics, "total_executions", 0))
            + (resource_metrics.total_executions if hasattr(resource_metrics, "total_executions") else resource_metrics.get("total_executions", 0))
        )

        successful_executions = (
            (tool_metrics.get("successful_executions", 0) if isinstance(tool_metrics, dict) else getattr(tool_metrics, "successful_executions", 0))
            + (server_metrics.successful_executions if hasattr(server_metrics, "successful_executions") else server_metrics.get("successful_executions", 0))
            + (prompt_metrics.get("successful_executions", 0) if isinstance(prompt_metrics, dict) else getattr(prompt_metrics, "successful_executions", 0))
            + (resource_metrics.successful_executions if hasattr(resource_metrics, "successful_executions") else resource_metrics.get("successful_executions", 0))
        )

        success_rate = (successful_executions / total_executions * 100) if total_executions > 0 else 100.0

        # Calculate average latency across all services
        latencies = []
        for m in [tool_metrics, server_metrics, prompt_metrics, resource_metrics]:
            avg_time = m.get("avg_response_time") if isinstance(m, dict) else getattr(m, "avg_response_time", None)
            if avg_time is not None:
                latencies.append(avg_time)
        avg_latency = sum(latencies) / len(latencies) if latencies else 0.0

        # Prepare context
        context = {
            "request": request,
            "root_path": _resolve_root_path(request),
            # Inputs
            "servers_total": servers_total,
            "servers_active": servers_active,
            # Outputs
            "gateways_total": gateways_total,
            "gateways_active": gateways_active,
            "a2a_total": a2a_total,
            "a2a_active": a2a_active,
            "a2a_enabled": settings.mcpgateway_a2a_enabled,
            "tools_total": tools_total,
            "tools_active": tools_active,
            "prompts_total": prompts_total,
            "prompts_active": prompts_active,
            "resources_total": resources_total,
            "resources_active": resources_active,
            # Plugins (plugin_stats can be dict or PluginStatsResponse)
            "plugins_total": plugin_stats.get("total_plugins", 0) if isinstance(plugin_stats, dict) else getattr(plugin_stats, "total_plugins", 0),
            "plugins_enabled": plugin_stats.get("enabled_plugins", 0) if isinstance(plugin_stats, dict) else getattr(plugin_stats, "enabled_plugins", 0),
            "plugins_by_hook": plugin_stats.get("plugins_by_hook", {}) if isinstance(plugin_stats, dict) else getattr(plugin_stats, "plugins_by_hook", {}),
            # Metrics
            "total_executions": total_executions,
            "success_rate": success_rate,
            "avg_latency_ms": avg_latency * 1000 if avg_latency else 0.0,
            # Version
            "version": __version__,
            # Infrastructure
            "db_dialect": db_dialect,
            "db_reachable": db_reachable,
            "cache_type": cache_type,
            "redis_available": redis_available,
            "redis_reachable": redis_reachable,
            "uptime_seconds": uptime_seconds,
            "mcp_runtime": version_module.mcp_runtime_status_payload(),
        }

        return request.app.state.templates.TemplateResponse(request, "overview_partial.html", context)

    except Exception as e:
        LOGGER.error(f"Error rendering overview partial: {e}")
        error_html = f"""
        <div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-700 dark:text-red-300 px-4 py-3 rounded">
            <strong class="font-bold">Error loading overview:</strong>
            <span class="block sm:inline">{html.escape(str(e))}</span>
        </div>
        """
        return HTMLResponse(content=error_html, status_code=500)


@admin_router.get("/config/passthrough-headers", response_model=GlobalConfigRead)
@require_permission("admin.system_config", allow_admin_bypass=False)
@rate_limit(requests_per_minute=30)  # Lower limit for config endpoints
async def get_global_passthrough_headers(
    db: Session = Depends(get_db),
    _user=Depends(get_current_user_with_permissions),
) -> GlobalConfigRead:
    """Get the global passthrough headers configuration.

    Args:
        db: Database session
        _user: Authenticated user

    Returns:
        GlobalConfigRead: The current global passthrough headers configuration

    Examples:
        >>> # Test function exists and has correct name
        >>> from mcpgateway.admin import get_global_passthrough_headers
        >>> get_global_passthrough_headers.__name__
        'get_global_passthrough_headers'
        >>> # Test it's a coroutine function
        >>> import inspect
        >>> inspect.iscoroutinefunction(get_global_passthrough_headers)
        True
    """
    # Use cache for reads (Issue #1715)
    # Pass env defaults so env/merge modes return correct headers
    passthrough_headers = global_config_cache.get_passthrough_headers(db, settings.default_passthrough_headers)
    return GlobalConfigRead(passthrough_headers=passthrough_headers)


@admin_router.put("/config/passthrough-headers", response_model=GlobalConfigRead)
@require_permission("admin.system_config", allow_admin_bypass=False)
@rate_limit(requests_per_minute=20)  # Stricter limit for config updates
async def update_global_passthrough_headers(
    request: Request,  # pylint: disable=unused-argument
    config_update: GlobalConfigUpdate,
    db: Session = Depends(get_db),
    _user=Depends(get_current_user_with_permissions),
) -> GlobalConfigRead:
    """Update the global passthrough headers configuration.

    Args:
        request: HTTP request object
        config_update: The new configuration
        db: Database session
        _user: Authenticated user

    Raises:
        HTTPException: If there is a conflict or validation error

    Returns:
        GlobalConfigRead: The updated configuration

    Examples:
        >>> # Test function exists and has correct name
        >>> from mcpgateway.admin import update_global_passthrough_headers
        >>> update_global_passthrough_headers.__name__
        'update_global_passthrough_headers'
        >>> # Test it's a coroutine function
        >>> import inspect
        >>> inspect.iscoroutinefunction(update_global_passthrough_headers)
        True
    """
    try:
        config = db.query(GlobalConfig).first()
        if not config:
            config = GlobalConfig(passthrough_headers=config_update.passthrough_headers)
            db.add(config)
        else:
            config.passthrough_headers = config_update.passthrough_headers
        db.commit()
        # Invalidate both global and loopback caches so changes propagate immediately (Issue #1715, #3640)
        # First-Party
        from mcpgateway.utils.passthrough_headers import invalidate_passthrough_header_caches  # pylint: disable=import-outside-toplevel

        invalidate_passthrough_header_caches()
        return GlobalConfigRead(passthrough_headers=config.passthrough_headers)
    except IntegrityError as e:
        db.rollback()
        raise HTTPException(status_code=409, detail="Passthrough headers conflict") from e
    except ValidationError as e:
        db.rollback()
        raise HTTPException(status_code=422, detail="Invalid passthrough headers format") from e
    except PassthroughHeadersError as e:
        db.rollback()
        LOGGER.error(f"Passthrough headers error: {e}")
        raise HTTPException(status_code=500, detail="Passthrough headers error") from e


@admin_router.post("/config/passthrough-headers/invalidate-cache")
@require_permission("admin.system_config", allow_admin_bypass=False)
@rate_limit(requests_per_minute=10)  # Strict limit for cache operations
async def invalidate_passthrough_headers_cache(
    _user=Depends(get_current_user_with_permissions),
    _db: Session = Depends(get_db),
) -> Dict[str, Any]:
    """Invalidate the GlobalConfig cache.

    Forces an immediate cache refresh on the next access. Use this after
    updating GlobalConfig outside the normal API flow, or when you need
    changes to propagate immediately across all workers.

    Args:
        _user: Authenticated user
        _db: Database session for permission checks.

    Returns:
        Dict with invalidation status and cache statistics

    Examples:
        >>> # Test function exists and has correct name
        >>> from mcpgateway.admin import invalidate_passthrough_headers_cache
        >>> invalidate_passthrough_headers_cache.__name__
        'invalidate_passthrough_headers_cache'
        >>> # Test it's a coroutine function
        >>> import inspect
        >>> inspect.iscoroutinefunction(invalidate_passthrough_headers_cache)
        True
    """
    # First-Party
    from mcpgateway.utils.passthrough_headers import invalidate_passthrough_header_caches  # pylint: disable=import-outside-toplevel

    invalidate_passthrough_header_caches()
    stats = global_config_cache.stats()
    return {
        "status": "invalidated",
        "message": "Passthrough header caches invalidated successfully",
        "cache_stats": stats,
    }


@admin_router.get("/config/passthrough-headers/cache-stats")
@require_permission("admin.system_config", allow_admin_bypass=False)
@rate_limit(requests_per_minute=30)
async def get_passthrough_headers_cache_stats(
    _user=Depends(get_current_user_with_permissions),
    _db: Session = Depends(get_db),
) -> Dict[str, Any]:
    """Get GlobalConfig cache statistics.

    Returns cache hit/miss counts, hit rate, TTL, and current cache status.
    Useful for monitoring cache effectiveness and debugging.

    Args:
        _user: Authenticated user
        _db: Database session for permission checks.

    Returns:
        Dict with cache statistics

    Examples:
        >>> # Test function exists and has correct name
        >>> from mcpgateway.admin import get_passthrough_headers_cache_stats
        >>> get_passthrough_headers_cache_stats.__name__
        'get_passthrough_headers_cache_stats'
        >>> # Test it's a coroutine function
        >>> import inspect
        >>> inspect.iscoroutinefunction(get_passthrough_headers_cache_stats)
        True
    """
    return global_config_cache.stats()


# ===================================
# A2A Stats Cache Endpoints
# ===================================


@admin_router.post("/cache/a2a-stats/invalidate")
@require_permission("admin.system_config", allow_admin_bypass=False)
@rate_limit(requests_per_minute=10)
async def invalidate_a2a_stats_cache(
    _user=Depends(get_current_user_with_permissions),
    _db: Session = Depends(get_db),
) -> Dict[str, Any]:
    """Invalidate the A2A stats cache.

    Forces an immediate cache refresh on the next access. Use this after
    modifying A2A agents outside the normal API flow, or when you need
    changes to propagate immediately.

    Args:
        _user: Authenticated user
        _db: Database session for permission checks.

    Returns:
        Dict with invalidation status and cache statistics

    Examples:
        >>> from mcpgateway.admin import invalidate_a2a_stats_cache
        >>> invalidate_a2a_stats_cache.__name__
        'invalidate_a2a_stats_cache'
        >>> import inspect
        >>> inspect.iscoroutinefunction(invalidate_a2a_stats_cache)
        True
    """
    a2a_stats_cache.invalidate()
    stats = a2a_stats_cache.stats()
    return {
        "status": "invalidated",
        "message": "A2A stats cache invalidated successfully",
        "cache_stats": stats,
    }


@admin_router.get("/cache/a2a-stats/stats")
@require_permission("admin.system_config", allow_admin_bypass=False)
@rate_limit(requests_per_minute=30)
async def get_a2a_stats_cache_stats(
    _user=Depends(get_current_user_with_permissions),
    _db: Session = Depends(get_db),
) -> Dict[str, Any]:
    """Get A2A stats cache statistics.

    Returns cache hit/miss counts, hit rate, TTL, and current cache status.
    Useful for monitoring cache effectiveness and debugging.

    Args:
        _user: Authenticated user
        _db: Database session for permission checks.

    Returns:
        Dict with cache statistics

    Examples:
        >>> from mcpgateway.admin import get_a2a_stats_cache_stats
        >>> get_a2a_stats_cache_stats.__name__
        'get_a2a_stats_cache_stats'
        >>> import inspect
        >>> inspect.iscoroutinefunction(get_a2a_stats_cache_stats)
        True
    """
    return a2a_stats_cache.stats()


@admin_router.get("/config/settings")
@require_permission("admin.system_config", allow_admin_bypass=False)
async def get_configuration_settings(
    _db: Session = Depends(get_db),
    _user=Depends(get_current_user_with_permissions),
) -> Dict[str, Any]:
    """Get application configuration settings grouped by category.

    Returns configuration settings with sensitive values masked.

    Args:
        _db: Database session
        _user: Authenticated user

    Returns:
        Dict with configuration groups and their settings
    """

    def mask_sensitive(value: Any, key: str) -> Any:
        """Mask sensitive configuration values.

        Args:
            value: Configuration value to potentially mask
            key: Configuration key name to check for sensitive patterns

        Returns:
            Masked value if sensitive, original value otherwise
        """
        sensitive_keys = {"password", "secret", "key", "token", "credentials", "client_secret", "private_key", "auth_encryption_secret"}
        if any(s in key.lower() for s in sensitive_keys):
            # Handle SecretStr objects
            if isinstance(value, SecretStr):
                return settings.masked_auth_value
            if value and str(value) not in ["", "None", "null"]:
                return settings.masked_auth_value
        return value

    # Group settings by category
    config_groups = {
        "Basic Settings": {
            "app_name": settings.app_name,
            "host": settings.host,
            "port": settings.port,
            "environment": settings.environment,
            "app_domain": str(settings.app_domain),
            "protocol_version": settings.protocol_version,
        },
        "Authentication & Security": {
            "auth_required": settings.auth_required,
            "basic_auth_user": settings.basic_auth_user,
            "basic_auth_password": mask_sensitive(settings.basic_auth_password, "password"),
            "jwt_algorithm": settings.jwt_algorithm,
            "jwt_secret_key": mask_sensitive(settings.jwt_secret_key, "secret_key"),
            "jwt_audience": settings.jwt_audience,
            "jwt_issuer": settings.jwt_issuer,
            "token_expiry": settings.token_expiry,
            "require_token_expiration": settings.require_token_expiration,
            "mcp_client_auth_enabled": settings.mcp_client_auth_enabled,
            "trust_proxy_auth": settings.trust_proxy_auth,
            "skip_ssl_verify": settings.skip_ssl_verify,
        },
        "SSO Configuration": {
            "sso_enabled": settings.sso_enabled,
            "sso_github_enabled": settings.sso_github_enabled,
            "sso_google_enabled": settings.sso_google_enabled,
            "sso_ibm_verify_enabled": settings.sso_ibm_verify_enabled,
            "sso_okta_enabled": settings.sso_okta_enabled,
            "sso_keycloak_enabled": settings.sso_keycloak_enabled,
            "sso_entra_enabled": settings.sso_entra_enabled,
            "sso_generic_enabled": settings.sso_generic_enabled,
            "sso_auto_create_users": settings.sso_auto_create_users,
            "sso_preserve_admin_auth": settings.sso_preserve_admin_auth,
            "sso_require_admin_approval": settings.sso_require_admin_approval,
        },
        "Email Authentication": {
            "email_auth_enabled": settings.email_auth_enabled,
            "platform_admin_email": settings.platform_admin_email,
            "platform_admin_password": mask_sensitive(settings.platform_admin_password, "password"),
        },
        "Database & Cache": {
            "database_url": settings.database_url.replace("://", "://***@") if "@" in settings.database_url else settings.database_url,
            "cache_type": settings.cache_type,
            "redis_url": settings.redis_url.replace("://", "://***@") if settings.redis_url and "@" in settings.redis_url else settings.redis_url,
            "db_pool_size": settings.db_pool_size,
            "db_max_overflow": settings.db_max_overflow,
        },
        "Feature Flags": {
            "mcpgateway_ui_enabled": settings.mcpgateway_ui_enabled,
            "mcpgateway_admin_api_enabled": settings.mcpgateway_admin_api_enabled,
            "mcpgateway_bulk_import_enabled": settings.mcpgateway_bulk_import_enabled,
            "mcpgateway_a2a_enabled": settings.mcpgateway_a2a_enabled,
            "mcpgateway_catalog_enabled": settings.mcpgateway_catalog_enabled,
            "plugins_enabled": settings.plugins.enabled,
            "well_known_enabled": settings.well_known_enabled,
            "mcpgateway_direct_proxy_enabled": settings.mcpgateway_direct_proxy_enabled,
        },
        "Connection Timeouts": {
            "federation_timeout": settings.federation_timeout,  # Gateway/server HTTP request timeout
            "mcpgateway_direct_proxy_timeout": settings.mcpgateway_direct_proxy_timeout,
        },
        "Transport": {
            "transport_type": settings.transport_type,
            "websocket_ping_interval": settings.websocket_ping_interval,
            "sse_retry_timeout": settings.sse_retry_timeout,
            "sse_keepalive_enabled": settings.sse_keepalive_enabled,
        },
        "Logging": {
            "log_level": settings.log_level,
            "log_format": settings.log_format,
            "log_to_file": settings.log_to_file,
            "log_file": settings.log_file,
            "log_rotation_enabled": settings.log_rotation_enabled,
        },
        "Resources & Tools": {
            "tool_timeout": settings.tool_timeout,
            "tool_rate_limit": settings.tool_rate_limit,
            "tool_concurrent_limit": settings.tool_concurrent_limit,
            "resource_cache_size": settings.resource_cache_size,
            "resource_cache_ttl": settings.resource_cache_ttl,
            "max_resource_size": settings.max_resource_size,
        },
        "CORS Settings": {
            "cors_enabled": settings.cors_enabled,
            "allowed_origins": list(settings.allowed_origins),
            "cors_allow_credentials": settings.cors_allow_credentials,
        },
        "Security Headers": {
            "security_headers_enabled": settings.security_headers_enabled,
            "x_frame_options": settings.x_frame_options,
            "hsts_enabled": settings.hsts_enabled,
            "hsts_max_age": settings.hsts_max_age,
            "remove_server_headers": settings.remove_server_headers,
        },
        "Observability": {
            "otel_enable_observability": settings.otel_enable_observability,
            "otel_traces_exporter": settings.otel_traces_exporter,
            "otel_service_name": settings.otel_service_name,
        },
        "Development": {
            "dev_mode": settings.dev_mode,
            "reload": settings.reload,
            "debug": settings.debug,
        },
    }

    return {
        "groups": config_groups,
        "security_status": settings.get_security_status(),
    }


@admin_router.get("/servers", response_model=PaginatedResponse)
@require_permission("servers.read", allow_admin_bypass=False)
async def admin_list_servers(
    page: int = Query(1, ge=1, description="Page number (1-indexed)"),
    per_page: int = Query(settings.pagination_default_page_size, ge=1, le=settings.pagination_max_page_size, description="Items per page"),
    include_inactive: bool = False,
    db: Session = Depends(get_db),
    user=Depends(get_current_user_with_permissions),
) -> Dict[str, Any]:
    """
    List servers for the admin UI with pagination support.

    This endpoint retrieves a paginated list of servers from the database, optionally
    including those that are inactive. Uses offset-based (page/per_page) pagination.

    Args:
        page (int): Page number (1-indexed) for offset pagination.
        per_page (int): Number of items per page.
        include_inactive (bool): Whether to include inactive servers.
        db (Session): The database session dependency.
        user (str): The authenticated user dependency.

    Returns:
        Dict[str, Any]: A dictionary containing:
            - data: List of server records formatted with by_alias=True
            - pagination: Pagination metadata
            - links: Pagination links (optional)

    Examples:
        >>> callable(admin_list_servers)
        True
        >>> admin_list_servers.__name__
        'admin_list_servers'
    """
    LOGGER.debug(f"User {get_user_email(user)} requested server list (page={page}, per_page={per_page})")
    user_email = get_user_email(user)

    # Call server_service.list_servers with page-based pagination
    paginated_result = await server_service.list_servers(
        db=db,
        include_inactive=include_inactive,
        page=page,
        per_page=per_page,
        user_email=user_email,
    )

    # End the read-only transaction early to avoid idle-in-transaction under load.
    db.commit()

    # Return standardized paginated response
    return {
        "data": [server.model_dump(by_alias=True) for server in paginated_result["data"]],
        "pagination": paginated_result["pagination"].model_dump(),
        "links": paginated_result["links"].model_dump() if paginated_result["links"] else None,
    }


@admin_router.get("/servers/partial", response_class=HTMLResponse)
@require_permission("servers.read", allow_admin_bypass=False)
async def admin_servers_partial_html(
    request: Request,
    page: int = Query(1, ge=1, description="Page number (1-indexed)"),
    per_page: int = Query(settings.pagination_default_page_size, ge=1, le=settings.pagination_max_page_size, description="Items per page"),
    q: str = Query("", max_length=500, description="Search query"),
    tags: QueryTagsFilter = None,
    include_inactive: bool = True,
    render: QueryRenderMode = None,
    team_id: Optional[str] = Depends(_validated_team_id_param),
    db: Session = Depends(get_db),
    user: dict = Depends(get_current_user_with_permissions),
) -> Response:
    """Return paginated servers HTML partials for the admin UI.

    This HTMX endpoint returns only the partial HTML used by the admin UI for
    servers. It supports three render modes:

    - default: full table partial (rows + controls)
    - ``render="controls"``: return only pagination controls
    - ``render="selector"``: return selector items for infinite scroll

    Args:
        request (Request): FastAPI request object used by the template engine.
        page (int): Page number (1-indexed).
        per_page (int): Number of items per page (bounded by settings).
        q (str): Free-text query string.
        tags (Optional[str]): Tag filter expression (comma=OR, plus=AND).
        include_inactive (bool): If True, include inactive servers in results.
        render (Optional[str]): Render mode; one of None, "controls", "selector".
        team_id (Optional[str]): Filter by team ID.
        db (Session): Database session (dependency-injected).
        user: Authenticated user object from dependency injection.

    Returns:
        Union[HTMLResponse, TemplateResponse]: A rendered template response
        containing either the table partial, pagination controls, or selector
        items depending on ``render``. The response contains JSON-serializable
        encoded server data when templates expect it.
    """
    LOGGER.debug(f"User {get_user_email(user)} requested servers HTML partial (page={page}, per_page={per_page}, include_inactive={include_inactive}, render={render}, team_id={team_id})")
    search_query = _normalize_search_query(q)
    normalized_tags = _normalize_tags_query(tags)
    tag_groups = _parse_tag_filter_groups(normalized_tags)

    # Normalize per_page within configured bounds
    per_page = max(settings.pagination_min_page_size, min(per_page, settings.pagination_max_page_size))

    user_email = get_user_email(user)

    # Team scoping
    team_ids = await _get_user_team_ids(user, db)

    # Build base query with eager loading to avoid N+1 queries
    # Filter out deactivated tools, resources, prompts, and agents at query level
    query = select(DbServer).options(
        selectinload(DbServer.tools),
        with_loader_criteria(DbTool, DbTool.enabled.is_(True)),
        selectinload(DbServer.resources),
        with_loader_criteria(DbResource, DbResource.enabled.is_(True)),
        selectinload(DbServer.prompts),
        with_loader_criteria(DbPrompt, DbPrompt.enabled.is_(True)),
        selectinload(DbServer.a2a_agents),
        with_loader_criteria(DbA2AAgent, DbA2AAgent.enabled.is_(True)),
        joinedload(DbServer.email_team),
    )

    if not include_inactive:
        query = query.where(DbServer.enabled.is_(True))

    # Build access conditions
    # When team_id is specified, show ONLY items from that team (team-scoped view)
    # Otherwise, show all accessible items (All Teams view)
    if team_id:
        # Team-specific view: only show servers from the specified team
        if team_id in team_ids:
            # Apply visibility check: team/public resources + user's own resources (including private)
            team_access = [
                and_(DbServer.team_id == team_id, DbServer.visibility.in_(["team", "public"])),
                and_(DbServer.team_id == team_id, DbServer.owner_email == user_email),
            ]
            query = query.where(or_(*team_access))
            LOGGER.debug(f"Filtering servers by team_id: {team_id}")
        else:
            # User is not a member of this team, return no results using SQLAlchemy's false()
            LOGGER.warning(f"User {user_email} attempted to filter by team {team_id} but is not a member")
            query = query.where(false())
    else:
        # All Teams view: apply standard access conditions (owner, team, public)
        access_conditions = []
        access_conditions.append(_owner_access_condition(DbServer.owner_email, DbServer.team_id, user_email=user_email, team_ids=team_ids, user=user))
        if team_ids:
            access_conditions.append(and_(DbServer.team_id.in_(team_ids), DbServer.visibility.in_(["team", "public"])))
        access_conditions.append(DbServer.visibility == "public")
        query = query.where(or_(*access_conditions))

    if search_query:
        query = query.where(
            or_(
                _like_contains(func.lower(DbServer.id), search_query),
                _like_contains(func.lower(DbServer.name), search_query),
                _like_contains(func.lower(coalesce(DbServer.description, "")), search_query),
            )
        )

    query = _apply_tag_filter_groups(query, db, DbServer.tags, tag_groups)

    # Apply pagination ordering for cursor support
    query = query.order_by(desc(DbServer.created_at), desc(DbServer.id))

    # Build query params for pagination links
    query_params = {}
    if include_inactive:
        query_params["include_inactive"] = "true"
    if team_id:
        query_params["team_id"] = team_id
    if search_query:
        query_params["q"] = search_query
    if normalized_tags:
        query_params["tags"] = normalized_tags

    # Use unified pagination function
    root_path = _resolve_root_path(request)
    base_url = f"{root_path}/admin/servers/partial"
    paginated_result = await paginate_query(
        db=db,
        query=query,
        page=page,
        per_page=per_page,
        cursor=None,  # HTMX partials use page-based navigation
        base_url=base_url,
        query_params=query_params,
        use_cursor_threshold=False,  # Disable auto-cursor switching for UI
    )

    # Extract paginated servers (DbServer objects)
    servers_db = paginated_result["data"]
    pagination = paginated_result["pagination"]
    links = paginated_result["links"]

    # Team names are loaded via joinedload(DbServer.email_team) and accessed via server.team property

    # Batch convert to Pydantic models using server service
    # This eliminates the N+1 query problem from calling get_server_details() in a loop
    servers_pydantic = []
    failed_count = 0
    for s in servers_db:
        try:
            servers_pydantic.append(server_service.convert_server_to_read(s, include_metrics=False))
        except (ValidationError, ValueError, KeyError, TypeError, binascii.Error) as e:
            failed_count += 1
            LOGGER.exception(f"Failed to convert server {getattr(s, 'id', 'unknown')} ({getattr(s, 'name', 'unknown')}): {e}")
    _adjust_pagination_for_conversion_failures(pagination, failed_count, len(servers_pydantic))
    data = jsonable_encoder(servers_pydantic)

    # End the read-only transaction before template rendering to avoid idle-in-transaction timeouts.
    db.commit()

    if render == "controls":
        return request.app.state.templates.TemplateResponse(
            request,
            "pagination_controls.html",
            {
                "request": request,
                "pagination": pagination.model_dump(),
                "base_url": base_url,
                "hx_target": "#servers-table-body",
                "hx_indicator": "#servers-loading",
                "query_params": query_params,
                "root_path": _resolve_root_path(request),
            },
        )

    if render == "selector":
        return request.app.state.templates.TemplateResponse(
            request,
            "servers_selector_items.html",
            {
                "request": request,
                "data": data,
                "pagination": pagination.model_dump(),
                "root_path": _resolve_root_path(request),
            },
        )

    _is_admin = bool(user.get("is_admin", False) if isinstance(user, dict) else getattr(user, "is_admin", False))
    _team_roles = _get_user_team_roles(db, user_email) if not _is_admin else {}
    return request.app.state.templates.TemplateResponse(
        request,
        "servers_partial.html",
        {
            "request": request,
            "data": data,
            "pagination": pagination.model_dump(),
            "links": links.model_dump() if links else None,
            "root_path": _resolve_root_path(request),
            "include_inactive": include_inactive,
            "query_params": query_params,
            "current_user_email": user_email,
            "is_admin": _is_admin,
            "user_team_roles": _team_roles,
        },
    )


@admin_router.get("/servers/{server_id}", response_model=ServerRead)
@require_permission("servers.read", allow_admin_bypass=False)
async def admin_get_server(server_id: str, request: Request, db: Session = Depends(get_db), user=Depends(get_current_user_with_permissions)) -> Dict[str, Any]:
    """
    Retrieve server details for the admin UI.

    Args:
        server_id (str): The ID of the server to retrieve.
        request (Request): Incoming FastAPI request (for visibility scope resolution).
        db (Session): The database session dependency.
        user (str): The authenticated user dependency.

    Returns:
        Dict[str, Any]: The server details.

    Raises:
        HTTPException: If the server is not found.
        Exception: For any other unexpected errors.

    Examples:
        >>> callable(admin_get_server)
        True
        >>> admin_get_server.__name__
        'admin_get_server'
    """
    try:
        LOGGER.debug(f"User {get_user_email(user)} requested details for server ID {server_id}")
        auth_user_email, auth_token_teams = get_scoped_resource_access_context(request, user)
        server = await server_service.get_server(db, server_id, user_email=auth_user_email, token_teams=auth_token_teams)
        return server.masked().model_dump(by_alias=True)
    except ServerNotFoundError as e:
        raise HTTPException(status_code=404, detail=str(e))
    except Exception as e:
        LOGGER.error(f"Error getting server {server_id}: {e}")
        raise e


@admin_router.post("/servers", response_model=ServerRead)
@require_permission("servers.create", allow_admin_bypass=False)
async def admin_add_server(request: Request, db: Session = Depends(get_db), user=Depends(get_current_user_with_permissions)) -> JSONResponse:
    """
    Add a new server via the admin UI.

    This endpoint processes form data to create a new server entry in the database.
    It handles exceptions gracefully and logs any errors that occur during server
    registration.

    Expects form fields:
      - name (required): The name of the server
      - description (optional): A description of the server's purpose
      - icon (optional): URL or path to the server's icon
      - associatedTools (optional, multiple values): Tools associated with this server
      - associatedResources (optional, multiple values): Resources associated with this server
      - associatedPrompts (optional, multiple values): Prompts associated with this server

    Args:
        request (Request): FastAPI request containing form data.
        db (Session): Database session dependency
        user (str): Authenticated user dependency

    Returns:
        JSONResponse: A JSON response indicating success or failure of the server creation operation.

    Raises:
        HTTPException: 422 when public visibility is disabled and request is team-scoped.

    Examples:
        >>> # Test function exists and has correct name
        >>> from mcpgateway.admin import admin_add_server
        >>> admin_add_server.__name__
        'admin_add_server'
        >>> # Test it's a coroutine function
        >>> import inspect
        >>> inspect.iscoroutinefunction(admin_add_server)
        True
    """
    form = await request.form()
    # is_inactive_checked = form.get("is_inactive_checked", "false")
    team_id = _form_team_id(form)

    # Parse tags from comma-separated string
    tags_str = str(form.get("tags", ""))
    tags: list[str] = [tag.strip() for tag in tags_str.split(",") if tag.strip()] if tags_str else []

    visibility = str(form.get("visibility", "private"))
    _check_public_visibility_allowed(visibility, team_id=team_id)

    try:
        LOGGER.debug(f"User {get_user_email(user)} is adding a new server with name: {form['name']}")

        # Handle "Select All" for tools, resources, and prompts.
        # _merge_select_all_ids takes the union of the server-fetched paginated IDs
        # (allToolIds etc.) with the explicitly checked form values so that
        # platform-public items visible in the UI are never silently dropped.
        associated_tools_list = _merge_select_all_ids(form, "selectAllTools", "allToolIds", form.getlist("associatedTools"))
        associated_resources_list = _merge_select_all_ids(form, "selectAllResources", "allResourceIds", form.getlist("associatedResources"))
        associated_prompts_list = _merge_select_all_ids(form, "selectAllPrompts", "allPromptIds", form.getlist("associatedPrompts"))

        # Handle OAuth 2.0 configuration (RFC 9728)
        oauth_enabled = form.get("oauth_enabled") == "on"
        oauth_config = None
        if oauth_enabled:
            authorization_server = str(form.get("oauth_authorization_server", "")).strip()
            scopes_str = str(form.get("oauth_scopes", "")).strip()
            token_endpoint = str(form.get("oauth_token_endpoint", "")).strip()

            if authorization_server:
                oauth_config = {"authorization_servers": [authorization_server]}
                if scopes_str:
                    # Convert space-separated scopes to list
                    oauth_config["scopes_supported"] = scopes_str.split()
                if token_endpoint:
                    oauth_config["token_endpoint"] = token_endpoint
            else:
                # Invalid or incomplete OAuth configuration; disable OAuth to avoid inconsistent state
                LOGGER.warning(
                    "OAuth was enabled for server '%s' but no authorization server was provided; disabling OAuth for this server.",
                    form.get("name"),
                )
                oauth_enabled = False
                oauth_config = None

        server = ServerCreate(
            id=form.get("id") or None,
            name=form.get("name"),
            description=form.get("description"),
            icon=form.get("icon"),
            associated_tools=",".join(str(x) for x in associated_tools_list),
            associated_resources=",".join(str(x) for x in associated_resources_list),
            associated_prompts=",".join(str(x) for x in associated_prompts_list),
            tags=tags,
            visibility=visibility,
            oauth_enabled=oauth_enabled,
            oauth_config=oauth_config,
        )
    except KeyError as e:
        # Convert KeyError to ValidationError-like response
        return ORJSONResponse(content={"message": f"Missing required field: {e}", "success": False}, status_code=422)
    try:
        user_email = get_user_email(user)
        team_service = TeamManagementService(db)
        team_id = await team_service.verify_team_for_user(user_email, team_id)

        # Extract metadata for server creation
        creation_metadata = MetadataCapture.extract_creation_metadata(request, user)

        # Ensure default visibility is private and assign to personal team when available
        team_id_cast = typing_cast(Optional[str], team_id)
        await server_service.register_server(
            db,
            server,
            created_by=user_email,  # Use the consistent user_email
            created_from_ip=creation_metadata["created_from_ip"],
            created_via=creation_metadata["created_via"],
            created_user_agent=creation_metadata["created_user_agent"],
            team_id=team_id_cast,
            visibility=visibility,
        )
        return ORJSONResponse(
            content={"message": "Server created successfully!", "success": True},
            status_code=200,
        )

    except CoreValidationError as ex:
        return ORJSONResponse(content={"message": str(ex), "success": False}, status_code=422)
    except ServerNameConflictError as ex:
        return ORJSONResponse(content={"message": str(ex), "success": False}, status_code=409)
    except ServerError as ex:
        return ORJSONResponse(content={"message": str(ex), "success": False}, status_code=500)
    # NOTE: Pydantic validation errors subclass ValueError; CoreValidationError must be handled first.
    except ValueError as ex:
        return ORJSONResponse(content={"message": str(ex), "success": False}, status_code=400)
    except IntegrityError as ex:
        return ORJSONResponse(content=ErrorFormatter.format_database_error(ex), status_code=409)
    except Exception as ex:
        LOGGER.exception(f"Unexpected error in admin_add_server: {ex}")
        return ORJSONResponse(content={"message": "An unexpected error occurred. Please try again or contact support.", "success": False}, status_code=500)


@admin_router.post("/servers/{server_id}/edit")
@require_permission("servers.update", allow_admin_bypass=False)
async def admin_edit_server(
    server_id: str,
    request: Request,
    db: Session = Depends(get_db),
    user=Depends(get_current_user_with_permissions),
) -> JSONResponse:
    """
    Edit an existing server via the admin UI.

    This endpoint processes form data to update an existing server's properties.
    It handles exceptions gracefully and logs any errors that occur during the
    update operation.

    Expects form fields:
      - id (optional): Updated UUID for the server
      - name (optional): The updated name of the server
      - description (optional): An updated description of the server's purpose
      - icon (optional): Updated URL or path to the server's icon
      - associatedTools (optional, multiple values): Updated list of tools associated with this server
      - associatedResources (optional, multiple values): Updated list of resources associated with this server
      - associatedPrompts (optional, multiple values): Updated list of prompts associated with this server

    Args:
        server_id (str): The ID of the server to edit
        request (Request): FastAPI request containing form data
        db (Session): Database session dependency
        user (str): Authenticated user dependency

    Returns:
        JSONResponse: A JSON response indicating success or failure of the server update operation.

    Raises:
        HTTPException: 422 when public visibility is disabled and request is team-scoped.

    Examples:
        >>> callable(admin_edit_server)
        True
        >>> admin_edit_server.__name__
        'admin_edit_server'
    """
    form = await request.form()
    team_id = _form_team_id(form)

    # Parse tags from comma-separated string
    tags_str = str(form.get("tags", ""))
    tags: list[str] = [tag.strip() for tag in tags_str.split(",") if tag.strip()] if tags_str else []
    try:
        LOGGER.debug(f"User {get_user_email(user)} is editing server ID {server_id} with name: {form.get('name')}")
        visibility = str(form.get("visibility", "private"))
        _check_public_visibility_allowed(visibility, team_id=team_id)
        user_email = get_user_email(user)

        # Preserve existing server's team_id when no explicit team_id is provided.
        # Without this guard, verify_team_for_user() falls back to the user's
        # personal team, silently reassigning the server on every edit.
        if not team_id:
            existing_server = db.get(DbServer, server_id)
            existing_team = getattr(existing_server, "team_id", None) if existing_server else None
            if isinstance(existing_team, str) and existing_team:
                team_id = existing_team

        team_service = TeamManagementService(db)
        team_id = await team_service.verify_team_for_user(user_email, team_id)

        mod_metadata = MetadataCapture.extract_modification_metadata(request, user, 0)

        # Handle "Select All" for tools, resources, and prompts.
        # _merge_select_all_ids takes the union of the server-fetched paginated IDs
        # (allToolIds etc.) with the explicitly checked form values so that
        # platform-public items visible in the UI are never silently dropped.
        associated_tools_list = _merge_select_all_ids(form, "selectAllTools", "allToolIds", form.getlist("associatedTools"))
        associated_resources_list = _merge_select_all_ids(form, "selectAllResources", "allResourceIds", form.getlist("associatedResources"))
        associated_prompts_list = _merge_select_all_ids(form, "selectAllPrompts", "allPromptIds", form.getlist("associatedPrompts"))

        # Handle OAuth 2.0 configuration (RFC 9728)
        oauth_enabled = form.get("oauth_enabled") == "on"
        oauth_config = None
        if oauth_enabled:
            authorization_server = str(form.get("oauth_authorization_server", "")).strip()
            scopes_str = str(form.get("oauth_scopes", "")).strip()
            token_endpoint = str(form.get("oauth_token_endpoint", "")).strip()

            if authorization_server:
                oauth_config = {"authorization_servers": [authorization_server]}
                if scopes_str:
                    # Convert space-separated scopes to list
                    oauth_config["scopes_supported"] = scopes_str.split()
                if token_endpoint:
                    oauth_config["token_endpoint"] = token_endpoint
            else:
                # Invalid or incomplete OAuth configuration; disable OAuth to avoid inconsistent state
                LOGGER.warning(
                    "OAuth was enabled for server '%s' but no authorization server was provided; disabling OAuth for this server.",
                    form.get("name"),
                )
                oauth_enabled = False
                oauth_config = None

        server = ServerUpdate(
            id=form.get("id"),
            name=form.get("name"),
            description=form.get("description"),
            icon=form.get("icon"),
            associated_tools=",".join(str(x) for x in associated_tools_list),
            associated_resources=",".join(str(x) for x in associated_resources_list),
            associated_prompts=",".join(str(x) for x in associated_prompts_list),
            tags=tags,
            visibility=visibility,
            team_id=team_id,
            owner_email=user_email,
            oauth_enabled=oauth_enabled,
            oauth_config=oauth_config,
        )

        await server_service.update_server(
            db,
            server_id,
            server,
            user_email,
            modified_by=mod_metadata["modified_by"],
            modified_from_ip=mod_metadata["modified_from_ip"],
            modified_via=mod_metadata["modified_via"],
            modified_user_agent=mod_metadata["modified_user_agent"],
        )

        return ORJSONResponse(
            content={"message": "Server updated successfully!", "success": True},
            status_code=200,
        )
    except (ValidationError, CoreValidationError) as ex:
        # Catch both Pydantic and pydantic_core validation errors
        return ORJSONResponse(content=ErrorFormatter.format_validation_error(ex), status_code=422)
    except ServerNameConflictError as ex:
        return ORJSONResponse(content={"message": str(ex), "success": False}, status_code=409)
    except ServerError as ex:
        return ORJSONResponse(content={"message": str(ex), "success": False}, status_code=500)
    except ValueError as ex:
        return ORJSONResponse(content={"message": str(ex), "success": False}, status_code=400)
    except RuntimeError as ex:
        return ORJSONResponse(content={"message": str(ex), "success": False}, status_code=500)
    except IntegrityError as ex:
        return ORJSONResponse(content=ErrorFormatter.format_database_error(ex), status_code=409)
    except PermissionError as e:
        LOGGER.info(f"Permission denied for user {get_user_email(user)}: {e}")
        return ORJSONResponse(content={"message": str(e), "success": False}, status_code=403)
    except HTTPException:
        raise
    except Exception as ex:
        LOGGER.exception(f"Unexpected error in admin_edit_server: {ex}")
        return ORJSONResponse(content={"message": "An unexpected error occurred. Please try again or contact support.", "success": False}, status_code=500)


@admin_router.post("/servers/{server_id}/state")
@require_permission("servers.update", allow_admin_bypass=False)
async def admin_set_server_state(
    server_id: str,
    request: Request,
    db: Session = Depends(get_db),
    user=Depends(get_current_user_with_permissions),
) -> Response:
    """
    Set a server's active status via the admin UI.

    This endpoint processes a form request to activate or deactivate a server.
    It expects a form field 'activate' with value "true" to activate the server
    or "false" to deactivate it. The endpoint handles exceptions gracefully and
    logs any errors that might occur during the status change operation.

    Args:
        server_id (str): The ID of the server whose status to set.
        request (Request): FastAPI request containing form data with the 'activate' field.
        db (Session): Database session dependency.
        user (str): Authenticated user dependency.

    Returns:
        Response: A redirect to the admin dashboard catalog section with a
        status code of 303 (See Other).

    Examples:
        >>> callable(admin_set_server_state)
        True
        >>> admin_set_server_state.__name__
        'admin_set_server_state'
    """
    form = await request.form()
    error_message = None
    user_email = get_user_email(user)
    LOGGER.debug(f"User {user_email} is setting server ID {server_id} state with activate: {form.get('activate')}")
    activate = str(form.get("activate", "true")).lower() == "true"
    is_inactive_checked = str(form.get("is_inactive_checked", "false"))
    try:
        await server_service.set_server_state(db, server_id, activate, user_email=user_email)
    except PermissionError as e:
        LOGGER.warning(f"Permission denied for user {user_email} setting server {server_id} state: {e}")
        error_message = str(e)
    except ServerLockConflictError as e:
        LOGGER.warning(f"Lock conflict for user {user_email} setting server {server_id} state: {e}")
        error_message = "Server is being modified by another request. Please try again."
    except Exception as e:
        LOGGER.error(f"Error setting server status: {e}")
        error_message = "Error setting server status. Please try again."

    root_path = _resolve_root_path(request)
    team_id = str(form.get("team_id", "") or "")
    redirect_url = _build_admin_redirect(root_path, "catalog", error=error_message, include_inactive=is_inactive_checked.lower() == "true", team_id=team_id)
    return RedirectResponse(redirect_url, status_code=303)


@admin_router.post("/servers/{server_id}/delete")
@require_permission("servers.delete", allow_admin_bypass=False)
async def admin_delete_server(server_id: str, request: Request, db: Session = Depends(get_db), user=Depends(get_current_user_with_permissions)) -> RedirectResponse:
    """
    Delete a server via the admin UI.

    This endpoint removes a server from the database by its ID. It handles exceptions
    gracefully and logs any errors that occur during the deletion process.

    Args:
        server_id (str): The ID of the server to delete
        request (Request): FastAPI request object (not used but required by route signature).
        db (Session): Database session dependency
        user (str): Authenticated user dependency

    Returns:
        RedirectResponse: A redirect to the admin dashboard catalog section with a
        status code of 303 (See Other)

    Examples:
        >>> callable(admin_delete_server)
        True
        >>> admin_delete_server.__name__
        'admin_delete_server'
    """
    form = await request.form()
    is_inactive_checked = str(form.get("is_inactive_checked", "false"))
    purge_metrics = str(form.get("purge_metrics", "false")).lower() == "true"
    error_message = None
    try:
        user_email = get_user_email(user)
        LOGGER.debug(f"User {user_email} is deleting server ID {server_id}")
        await server_service.delete_server(db, server_id, user_email=user_email, purge_metrics=purge_metrics)
    except PermissionError as e:
        LOGGER.warning(f"Permission denied for user {get_user_email(user)} deleting server {server_id}: {e}")
        error_message = str(e)
    except Exception as e:
        LOGGER.error(f"Error deleting server: {e}")
        error_message = "Failed to delete server. Please try again."

    root_path = _resolve_root_path(request)
    team_id = str(form.get("team_id", "") or "")
    redirect_url = _build_admin_redirect(root_path, "catalog", error=error_message, include_inactive=is_inactive_checked.lower() == "true", team_id=team_id)
    return RedirectResponse(redirect_url, status_code=303)


@admin_router.get("/resources", response_model=PaginatedResponse)
@require_permission("resources.read", allow_admin_bypass=False)
async def admin_list_resources(
    page: int = Query(1, ge=1, description="Page number (1-indexed)"),
    per_page: int = Query(settings.pagination_default_page_size, ge=1, le=settings.pagination_max_page_size, description="Items per page"),
    include_inactive: bool = False,
    db: Session = Depends(get_db),
    user=Depends(get_current_user_with_permissions),
) -> Dict[str, Any]:
    """
    List resources for the admin UI with pagination support.

    This endpoint retrieves a paginated list of resources from the database, optionally
    including those that are inactive. Uses offset-based (page/per_page) pagination.

    Args:
        page (int): Page number (1-indexed). Default: 1.
        per_page (int): Items per page. Default: 50.
        include_inactive (bool): Whether to include inactive resources in the results.
        db (Session): Database session dependency.
        user (str): Authenticated user dependency.

    Returns:
        Dict with 'data', 'pagination', and 'links' keys containing paginated resources.

    Examples:
        >>> callable(admin_list_resources)
        True
        >>> admin_list_resources.__name__
        'admin_list_resources'
    """
    LOGGER.debug(f"User {get_user_email(user)} requested resource list (page={page}, per_page={per_page})")
    user_email = get_user_email(user)

    # Call resource_service.list_resources with page-based pagination
    paginated_result = await resource_service.list_resources(
        db=db,
        include_inactive=include_inactive,
        page=page,
        per_page=per_page,
        user_email=user_email,
    )

    # Return standardized paginated response
    return {
        "data": [resource.model_dump(by_alias=True) for resource in paginated_result["data"]],
        "pagination": paginated_result["pagination"].model_dump(),
        "links": paginated_result["links"].model_dump() if paginated_result["links"] else None,
    }


@admin_router.get("/prompts", response_model=PaginatedResponse)
@require_permission("prompts.read", allow_admin_bypass=False)
async def admin_list_prompts(
    page: int = Query(1, ge=1, description="Page number (1-indexed)"),
    per_page: int = Query(settings.pagination_default_page_size, ge=1, le=settings.pagination_max_page_size, description="Items per page"),
    include_inactive: bool = False,
    db: Session = Depends(get_db),
    user=Depends(get_current_user_with_permissions),
) -> Dict[str, Any]:
    """
    List prompts for the admin UI with pagination support.

    This endpoint retrieves a paginated list of prompts from the database, optionally
    including those that are inactive. Uses offset-based (page/per_page) pagination.

    Args:
        page (int): Page number (1-indexed) for offset pagination.
        per_page (int): Number of items per page.
        include_inactive (bool): Whether to include inactive prompts in the results.
        db (Session): Database session dependency.
        user (str): Authenticated user dependency.

    Returns:
        Dict[str, Any]: A dictionary containing:
            - data: List of prompt records formatted with by_alias=True
            - pagination: Pagination metadata
            - links: Pagination links (optional)

    Examples:
        >>> callable(admin_list_prompts)
        True
        >>> admin_list_prompts.__name__
        'admin_list_prompts'
    """
    LOGGER.debug(f"User {get_user_email(user)} requested prompt list (page={page}, per_page={per_page})")
    user_email = get_user_email(user)

    # Call prompt_service.list_prompts with page-based pagination
    paginated_result = await prompt_service.list_prompts(
        db=db,
        include_inactive=include_inactive,
        page=page,
        per_page=per_page,
        user_email=user_email,
    )

    # Return standardized paginated response
    return {
        "data": [prompt.model_dump(by_alias=True) for prompt in paginated_result["data"]],
        "pagination": paginated_result["pagination"].model_dump(),
        "links": paginated_result["links"].model_dump() if paginated_result["links"] else None,
    }


@admin_router.get("/gateways", response_model=PaginatedResponse)
@require_permission("gateways.read", allow_admin_bypass=False)
async def admin_list_gateways(
    page: int = Query(1, ge=1, description="Page number (1-indexed)"),
    per_page: int = Query(settings.pagination_default_page_size, ge=1, le=settings.pagination_max_page_size, description="Items per page"),
    include_inactive: bool = False,
    db: Session = Depends(get_db),
    user=Depends(get_current_user_with_permissions),
) -> Dict[str, Any]:
    """
    List gateways for the admin UI with pagination support.

    This endpoint retrieves a paginated list of gateways from the database, optionally
    including those that are inactive. Uses offset-based (page/per_page) pagination.

    Args:
        page (int): Page number (1-indexed) for offset pagination.
        per_page (int): Number of items per page.
        include_inactive (bool): Whether to include inactive gateways in the results.
        db (Session): Database session dependency.
        user (str): Authenticated user dependency.

    Returns:
        Dict[str, Any]: A dictionary containing:
            - data: List of gateway records formatted with by_alias=True
            - pagination: Pagination metadata
            - links: Pagination links (optional)

    Examples:
        >>> callable(admin_list_gateways)
        True
        >>> admin_list_gateways.__name__
        'admin_list_gateways'
    """
    user_email = get_user_email(user)
    LOGGER.debug(f"User {user_email} requested gateway list (page={page}, per_page={per_page})")

    # Call gateway_service.list_gateways with page-based pagination
    paginated_result = await gateway_service.list_gateways(
        db=db,
        include_inactive=include_inactive,
        page=page,
        per_page=per_page,
        user_email=user_email,
    )

    # Return standardized paginated response
    return {
        "data": [gateway.model_dump(by_alias=True) for gateway in paginated_result["data"]],
        "pagination": paginated_result["pagination"].model_dump(),
        "links": paginated_result["links"].model_dump() if paginated_result["links"] else None,
    }


@admin_router.post("/gateways/{gateway_id}/state")
@require_permission("gateways.update", allow_admin_bypass=False)
async def admin_set_gateway_state(
    gateway_id: str,
    request: Request,
    db: Session = Depends(get_db),
    user=Depends(get_current_user_with_permissions),
) -> RedirectResponse:
    """
    Set the active status of a gateway via the admin UI.

    This endpoint allows an admin to set the active status of a gateway.
    It expects a form field 'activate' with a value of "true" or "false" to
    determine the new status of the gateway.

    Args:
        gateway_id (str): The ID of the gateway to set state for.
        request (Request): The FastAPI request object containing form data.
        db (Session): The database session dependency.
        user (str): The authenticated user dependency.

    Returns:
        RedirectResponse: A redirect response to the admin dashboard with a
        status code of 303 (See Other).

    Examples:
        >>> callable(admin_set_gateway_state)
        True
        >>> admin_set_gateway_state.__name__
        'admin_set_gateway_state'
    """
    error_message = None
    user_email = get_user_email(user)
    LOGGER.debug(f"User {user_email} is setting gateway state for ID {gateway_id}")
    form = await request.form()
    activate = str(form.get("activate", "true")).lower() == "true"
    is_inactive_checked = str(form.get("is_inactive_checked", "false"))

    try:
        await gateway_service.set_gateway_state(db, gateway_id, activate, user_email=user_email)
    except PermissionError as e:
        LOGGER.warning(f"Permission denied for user {user_email} setting gateway state {gateway_id}: {e}")
        error_message = str(e)
    except Exception as e:
        LOGGER.error(f"Error setting gateway state: {e}")
        error_message = "Failed to set gateway state. Please try again."

    root_path = _resolve_root_path(request)
    team_id = str(form.get("team_id", "") or "")
    redirect_url = _build_admin_redirect(root_path, "gateways", error=error_message, include_inactive=is_inactive_checked.lower() == "true", team_id=team_id)
    return RedirectResponse(redirect_url, status_code=303)


@admin_router.get("/", name="admin_home", response_class=HTMLResponse)
@require_permission("admin.dashboard", allow_admin_bypass=False)
async def admin_ui(
    request: Request,
    team_id: Optional[str] = Depends(_validated_team_id_param),
    include_inactive: bool = False,
    db: Session = Depends(get_db),
    user=Depends(get_current_user_with_permissions),
    _jwt_token: str = Depends(get_jwt_token),
) -> Any:
    """
    Render the admin dashboard HTML page.

    This endpoint serves as the main entry point to the admin UI. It fetches data for
    servers, tools, resources, prompts, gateways, and roots from their respective
    services, then renders the admin dashboard template with this data.

    Supports optional `team_id` query param to scope the returned data to a team.
    If `team_id` is provided and email-based team management is enabled, we
    validate the user is a member of that team. We attempt to pass team_id into
    service listing functions (preferred). If the service API does not accept a
    team_id parameter we fall back to post-filtering the returned items.

    The endpoint also sets a JWT token as a cookie for authentication in subsequent
    requests. This token is HTTP-only for security reasons.

    Args:
        request (Request): FastAPI request object.
        team_id (Optional[str]): Optional team ID to filter data by team.
        include_inactive (bool): Whether to include inactive items in all listings.
        db (Session): Database session dependency.
        user (dict): Authenticated user context with permissions.

    Returns:
        Any: Rendered HTML template for the admin dashboard.

    Raises:
        HTTPException: 403 if a non-admin user supplies a team_id they do not belong to.

    Examples:
        >>> callable(admin_ui)
        True
        >>> admin_ui.__name__
        'admin_ui'
    """
    LOGGER.debug(f"User {get_user_email(user)} accessed the admin UI (team_id={team_id})")
    user_email = get_user_email(user)
    is_admin = bool(user.get("is_admin", False) if isinstance(user, dict) else getattr(user, "is_admin", False))
    ui_visibility_config = get_ui_visibility_config(request, is_admin=is_admin)
    hidden_sections = set(ui_visibility_config["hidden_sections"])
    hidden_header_items = set(ui_visibility_config["hidden_header_items"])

    # --------------------------------------------------------------------------------
    # Determine team loading requirements BEFORE permission-based hiding
    # This ensures teams load based on query-param visibility, not permission restrictions
    # --------------------------------------------------------------------------------
    sections_requiring_user_teams = {
        "teams",
        "tokens",
        "users",
        "tools",
        "servers",
        "resources",
        "prompts",
        "gateways",
        "agents",
    }
    # Check visibility based on query-param/static hidden sections only
    any_data_section_visible = any(section not in hidden_sections for section in sections_requiring_user_teams)

    # --------------------------------------------------------------------------------
    # Add permission-based hiding (merge with static config)
    # Only apply permission-based hiding when email auth is enabled
    # --------------------------------------------------------------------------------
    is_admin_user = bool(user.get("is_admin", False) if isinstance(user, dict) else getattr(user, "is_admin", False))
    token_teams = user.get("token_teams") if isinstance(user, dict) else getattr(user, "token_teams", None)

    if getattr(settings, "email_auth_enabled", False):
        permission_hidden_sections = await get_hidden_sections_for_user(
            db=db,
            user_email=user_email,
            is_admin=is_admin_user,
            token_teams=token_teams,
            static_hidden=hidden_sections,
        )

        # Merge permission-based hiding with query-param and static config
        hidden_sections = hidden_sections | permission_hidden_sections

    # --------------------------------------------------------------------------------
    # Get user action permissions for UI button visibility
    # Only check permissions when email auth is enabled (same as section hiding)
    # When email auth is disabled, default to all permissions enabled
    # --------------------------------------------------------------------------------
    if getattr(settings, "email_auth_enabled", False):
        user_permissions = await get_user_action_permissions(
            db=db,
            user_email=user_email,
            is_admin=is_admin_user,
            token_teams=token_teams,
        )
    else:
        # Default to all permissions enabled when email auth is disabled
        user_permissions = {flag: True for flag in UI_ACTION_PERMISSIONS}

    # --------------------------------------------------------------------------------
    # Load user teams so we can validate team_id
    # --------------------------------------------------------------------------------
    user_teams = []
    team_service = None
    # Load teams if: team_id is specified, team_selector is visible, OR any data section is visible
    should_load_user_teams = getattr(settings, "email_auth_enabled", False) and (team_id is not None or "team_selector" not in hidden_header_items or any_data_section_visible)
    if should_load_user_teams:
        try:
            team_service = TeamManagementService(db)
            if user_email and "@" in user_email:
                raw_teams = await team_service.get_user_teams(user_email)

                # Batch fetch all data in 2 queries instead of 2N queries (N+1 elimination)
                team_ids = [str(team.id) for team in raw_teams]
                member_counts = await team_service.get_member_counts_batch_cached(team_ids)
                user_roles = team_service.get_user_roles_batch(user_email, team_ids)

                user_teams = []
                for team in raw_teams:
                    try:
                        current_team_id = str(team.id) if team.id else ""
                        team_dict = {
                            "id": current_team_id,
                            "name": str(team.name) if team.name else "",
                            "type": str(getattr(team, "type", "organization")),
                            "is_personal": bool(getattr(team, "is_personal", False)),
                            "member_count": member_counts.get(current_team_id, 0),
                            "role": user_roles.get(current_team_id) or "member",
                        }
                        user_teams.append(team_dict)
                    except Exception as team_error:
                        LOGGER.warning(f"Failed to serialize team {getattr(team, 'id', 'unknown')}: {team_error}")
                        continue
        except Exception as e:
            LOGGER.warning(f"Failed to load user teams: {e}")
            user_teams = []

    # --------------------------------------------------------------------------------
    # Validate team_id if provided (only when email-based teams are enabled).
    # Platform admins with unrestricted tokens (is_admin AND token_teams is None)
    # bypass the membership check. Team-scoped admin tokens can still view any
    # team for governance. Non-admins get their team filter reset when they
    # supply a team_id they do not belong to.
    # --------------------------------------------------------------------------------
    selected_team_id = team_id
    admin_viewing_non_member_team = False

    if team_id and getattr(settings, "email_auth_enabled", False):
        _token_teams = user.get("token_teams") if isinstance(user, dict) else getattr(user, "token_teams", None)
        if not (is_admin_user and _token_teams is None):
            if not user_teams:
                LOGGER.warning("team_id requested but user_teams not available; rejecting (team_id=%s)", team_id)
                raise HTTPException(status_code=403, detail="Unable to verify team membership")

            valid_team_ids = {t["id"] for t in user_teams if t.get("id")}
            if str(team_id) not in valid_team_ids:
                if not is_admin_user:
                    LOGGER.warning("Non-admin requested team_id not in their teams; ignoring team filter (team_id=%s)", team_id)
                    selected_team_id = None
                else:
                    # Admin selected a team they don't belong to; show banner and default content to All Teams
                    LOGGER.info("Admin viewing non-member team for governance (team_id=%s)", team_id)
                    admin_viewing_non_member_team = True
                    selected_team_id = None

    # --------------------------------------------------------------------------------
    # Helper: attempt to call a listing function with team_id if it supports it.
    # If the method signature doesn't accept team_id, fall back to calling it without
    # and then (optionally) filter the returned results.
    # --------------------------------------------------------------------------------
    async def _call_list_with_team_support(method, *args, **kwargs):
        """
        Attempt to call a method with an optional `team_id` parameter.

        This function tries to call the given asynchronous `method` with all provided
        arguments and an additional `team_id=selected_team_id`, assuming `selected_team_id`
        is defined and not None. If the method does not accept a `team_id` keyword argument
        (raises TypeError), the function retries the call without it.

        This is useful in scenarios where some service methods optionally support team
        scoping via a `team_id` parameter, but not all do.

        Args:
            method (Callable): The async function to be called.
            *args: Positional arguments to pass to the method.
            **kwargs: Keyword arguments to pass to the method.

        Returns:
            Any: The result of the awaited method call, typically a list of model instances.

        Raises:
            Any exception raised by the method itself, except TypeError when `team_id` is unsupported.


        Doctest:
            >>> async def sample_method(a, b):
            ...     return [a, b]
            >>> async def sample_method_with_team(a, b, team_id=None):
            ...     return [a, b, team_id]
            >>> selected_team_id = 42
            >>> import asyncio
            >>> asyncio.run(_call_list_with_team_support(sample_method_with_team, 1, 2))
            [1, 2, 42]
            >>> asyncio.run(_call_list_with_team_support(sample_method, 1, 2))
            [1, 2]

        Notes:
            - This function depends on a global `selected_team_id` variable.
            - If `selected_team_id` is None, the method is called without `team_id`.
        """
        if selected_team_id is None:
            return await method(*args, **kwargs)

        try:
            # Preferred: pass team_id to the service method if it accepts it
            return await method(*args, team_id=selected_team_id, **kwargs)
        except TypeError:
            # The method doesn't accept team_id -> fall back to original API
            LOGGER.debug("Service method %s does not accept team_id; falling back and will post-filter", getattr(method, "__name__", str(method)))
            return await method(*args, **kwargs)

    # Small utility to check if a returned model or dict matches the selected_team_id.
    def _matches_selected_team(item, tid: str) -> bool:
        """
        Determine whether the given item is associated with the specified team ID.

        This function attempts to determine if the input `item` (which may be a Pydantic model,
        an object with attributes, or a dictionary) is associated with the given team ID (`tid`).
        It checks several common attribute names (e.g., `team_id`, `team_ids`, `teams`) to see
        if any of them match the provided team ID. These fields may contain either a single ID
        or a list of IDs.

        If `tid` is falsy (e.g., empty string), the function returns True.

        Args:
            item: An object or dictionary that may contain team identification fields.
            tid (str): The team ID to match.

        Returns:
            bool: True if the item is associated with the specified team ID, otherwise False.

        Examples:
            >>> class Obj:
            ...     team_id = 'abc123'
            >>> _matches_selected_team(Obj(), 'abc123')
            True

            >>> class Obj:
            ...     team_ids = ['abc123', 'def456']
            >>> _matches_selected_team(Obj(), 'def456')
            True

            >>> _matches_selected_team({'teamId': 'xyz789'}, 'xyz789')
            True

            >>> _matches_selected_team({'teamIds': ['123', '456']}, '789')
            False

            >>> _matches_selected_team({'teams': ['t1', 't2']}, 't1')
            True

            >>> _matches_selected_team(None, 'abc')
            False
        """
        # If an item is explicitly public, it should be visible to any team
        try:
            vis = getattr(item, "visibility", None)
            if vis is None and isinstance(item, dict):
                vis = item.get("visibility")
            if isinstance(vis, str) and vis.lower() == "public":
                return True
        except Exception as exc:  # pragma: no cover - defensive logging for unexpected types
            LOGGER.debug(
                "Error checking visibility on item (type=%s): %s",
                type(item),
                exc,
                exc_info=True,
            )
        # item may be a pydantic model or dict-like
        # check common fields for team membership
        candidates = []
        # Extract team IDs from object attributes, catching exceptions from each property
        for attr_name in ["team_id", "teamId", "team_ids", "teamIds", "teams"]:
            try:
                val = getattr(item, attr_name, None)
                candidates.append(val)
            except Exception:
                pass  # nosec B110 - Intentionally ignore errors when extracting team IDs from properties
        # Extract team IDs from dict keys, catching exceptions from each .get() call
        if isinstance(item, dict):
            for key_name in ["team_id", "teamId", "team_ids", "teamIds", "teams"]:
                try:
                    val = item.get(key_name)
                    candidates.append(val)
                except Exception:
                    pass  # nosec B110 - Intentionally ignore errors when extracting team IDs from dict .get() calls

        for c in candidates:
            if c is None:
                continue
            # Some fields may be single id or list of ids
            if isinstance(c, (list, tuple, set)):
                if str(tid) in [str(x) for x in c]:
                    return True
            else:
                if str(c) == str(tid):
                    return True
        return False

    # --------------------------------------------------------------------------------
    # Load each resource list using the safe _call_list_with_team_support helper.
    # For each returned list, try to produce consistent "model_dump(by_alias=True)" dicts,
    # applying server-side filtering as a fallback if the service didn't accept team_id.
    # --------------------------------------------------------------------------------
    raw_tools = []
    if "tools" not in hidden_sections:
        try:
            raw_tools = await _call_list_with_team_support(tool_service.list_tools, db, include_inactive=include_inactive, user_email=user_email, limit=0)
            if isinstance(raw_tools, tuple):
                raw_tools = raw_tools[0]
        except Exception as e:
            LOGGER.exception("Failed to load tools for user: %s", e)

    raw_servers = []
    if "servers" not in hidden_sections:
        try:
            raw_servers = await _call_list_with_team_support(server_service.list_servers, db, include_inactive=include_inactive, user_email=user_email, limit=0)
            # Handle tuple return (list, cursor)
            if isinstance(raw_servers, tuple):
                raw_servers = raw_servers[0]
        except Exception as e:
            LOGGER.exception("Failed to load servers for user: %s", e)

    raw_resources = []
    if "resources" not in hidden_sections:
        try:
            raw_resources = await _call_list_with_team_support(resource_service.list_resources, db, include_inactive=include_inactive, user_email=user_email, limit=0)
            if isinstance(raw_resources, tuple):
                raw_resources = raw_resources[0]
        except Exception as e:
            LOGGER.exception("Failed to load resources for user: %s", e)

    raw_prompts = []
    if "prompts" not in hidden_sections:
        try:
            raw_prompts = await _call_list_with_team_support(prompt_service.list_prompts, db, include_inactive=include_inactive, user_email=user_email, limit=0)
            # Handle tuple return (list, cursor)
            if isinstance(raw_prompts, tuple):
                raw_prompts = raw_prompts[0]
        except Exception as e:
            LOGGER.exception("Failed to load prompts for user: %s", e)

    gateways_raw = []
    if "gateways" not in hidden_sections:
        try:
            gateways_raw = await _call_list_with_team_support(gateway_service.list_gateways, db, include_inactive=include_inactive, user_email=user_email, limit=0)
            # Handle tuple return (list, cursor)
            if isinstance(gateways_raw, tuple):
                gateways_raw = gateways_raw[0]
        except Exception as e:
            LOGGER.exception("Failed to load gateways: %s", e)

    # Convert models to dicts and filter as needed
    def _to_dict_and_filter(raw_list):
        """
        Convert a list of items (Pydantic models, dicts, or similar) to dictionaries and filter them
        based on a globally defined `selected_team_id`.

        For each item:
        - Try to convert it to a dictionary via `.model_dump(by_alias=True)` (if it's a Pydantic model),
        or keep it as-is if it's already a dictionary.
        - If the conversion fails, try to coerce the item to a dictionary via `dict(item)`.
        - If `selected_team_id` is set, include only items that match it via `_matches_selected_team`.

        Args:
            raw_list (list): A list of Pydantic models, dictionaries, or similar objects.

        Returns:
            list: A filtered list of dictionaries.

        Examples:
            >>> global selected_team_id
            >>> selected_team_id = 'team123'
            >>> class Model:
            ...     def __init__(self, team_id): self.team_id = team_id
            ...     def model_dump(self, by_alias=False): return {'team_id': self.team_id}
            >>> items = [Model('team123'), Model('team999')]
            >>> _to_dict_and_filter(items)
            [{'team_id': 'team123'}]

            >>> selected_team_id = None
            >>> _to_dict_and_filter([{'team_id': 'any_team'}])
            [{'team_id': 'any_team'}]

            >>> selected_team_id = 't1'
            >>> _to_dict_and_filter([{'team_ids': ['t1', 't2']}, {'team_ids': ['t3']}])
            [{'team_ids': ['t1', 't2']}]
        """
        out = []
        for item in raw_list or []:
            try:
                dumped = item.model_dump(by_alias=True) if hasattr(item, "model_dump") else (item if isinstance(item, dict) else None)
            except Exception:
                # if dumping failed, try to coerce to dict
                try:
                    dumped = dict(item) if hasattr(item, "__iter__") else None
                except Exception:
                    dumped = None
            if dumped is None:
                continue

            # If we passed team_id to service, server-side filtering applied.
            # Otherwise, filter by common team-aware fields if selected_team_id is set.
            if selected_team_id:
                if _matches_selected_team(item, selected_team_id) or _matches_selected_team(dumped, selected_team_id):
                    out.append(dumped)
                else:
                    # skip items that don't match the selected team
                    continue
            else:
                out.append(dumped)
        return out

    tools = list(sorted(_to_dict_and_filter(raw_tools), key=lambda t: ((t.get("url") or "").lower(), (t.get("original_name") or "").lower())))
    servers = _to_dict_and_filter(raw_servers)
    resources = _to_dict_and_filter(raw_resources)  # pylint: disable=unnecessary-comprehension
    prompts = _to_dict_and_filter(raw_prompts)
    gateways = [g.model_dump(by_alias=True) if hasattr(g, "model_dump") else (g if isinstance(g, dict) else {}) for g in (gateways_raw or [])]
    # If gateways need team filtering as dicts too, apply _to_dict_and_filter similarly:
    gateways = _to_dict_and_filter(gateways_raw) if isinstance(gateways_raw, (list, tuple)) else gateways

    # roots
    roots = [root.model_dump(by_alias=True) for root in await root_service.list_roots()]

    # Load A2A agents if enabled
    a2a_agents = []
    if "agents" not in hidden_sections and a2a_service and settings.mcpgateway_a2a_enabled:
        a2a_agents_raw = await a2a_service.list_agents_for_user(
            db,
            user_info=user_email,
            include_inactive=include_inactive,
        )
        a2a_agents = [agent.model_dump(by_alias=True) for agent in a2a_agents_raw]
        a2a_agents = _to_dict_and_filter(a2a_agents) if isinstance(a2a_agents, (list, tuple)) else a2a_agents

    # Load gRPC services if enabled and available
    grpc_services = []
    try:
        if "grpc-services" not in hidden_sections and GRPC_AVAILABLE and grpc_service_mgr and settings.mcpgateway_grpc_enabled:
            grpc_services_raw = await grpc_service_mgr.list_services(
                db,
                include_inactive=include_inactive,
                user_email=user_email,
                team_id=selected_team_id,
            )
            grpc_services = [service.model_dump(by_alias=True) for service in grpc_services_raw]
            grpc_services = _to_dict_and_filter(grpc_services) if isinstance(grpc_services, (list, tuple)) else grpc_services
    except Exception as e:
        LOGGER.exception("Failed to load gRPC services: %s", e)
        grpc_services = []

    # Template variables and context: include selected_team_id so the template and frontend can read it
    root_path = settings.app_root_path
    max_name_length = settings.validation_max_name_length

    # End the read-only transaction before template rendering to avoid idle-in-transaction timeouts.
    db.commit()

    response = request.app.state.templates.TemplateResponse(
        request,
        "admin.html",
        {
            "request": request,
            "servers": servers,
            "tools": tools,
            "resources": resources,
            "prompts": prompts,
            "gateways": gateways,
            "a2a_agents": a2a_agents,
            "grpc_services": grpc_services,
            "roots": roots,
            "include_inactive": include_inactive,
            "root_path": root_path,
            "bundle_js": get_bundle_js_filename(),
            "max_name_length": max_name_length,
            "gateway_tool_name_separator": settings.gateway_tool_name_separator,
            "bulk_import_max_tools": settings.mcpgateway_bulk_import_max_tools,
            "a2a_enabled": settings.mcpgateway_a2a_enabled,
            "grpc_enabled": GRPC_AVAILABLE and settings.mcpgateway_grpc_enabled,
            "catalog_enabled": settings.mcpgateway_catalog_enabled,
            "llmchat_enabled": getattr(settings, "llmchat_enabled", False),
            "toolops_enabled": getattr(settings, "toolops_enabled", False),
            "observability_enabled": getattr(settings, "observability_enabled", False),
            "performance_enabled": getattr(settings, "mcpgateway_performance_tracking", False),
            "current_user": get_user_email(user),
            "email_auth_enabled": getattr(settings, "email_auth_enabled", False),
            "is_admin": is_admin,
            "user_teams": user_teams,
            "mcpgateway_ui_tool_test_timeout": settings.mcpgateway_ui_tool_test_timeout,
            "allow_public_visibility": settings.allow_public_visibility,
            "auth_header_name": settings.auth_header_name,
            "selected_team_id": selected_team_id,
            "admin_viewing_non_member_team": admin_viewing_non_member_team,
            "ui_airgapped": settings.mcpgateway_ui_airgapped,
            "ui_hidden_sections": sorted(hidden_sections),
            "ui_hidden_header_items": ui_visibility_config["hidden_header_items"],
            "ui_hidden_tabs": ui_visibility_config["hidden_tabs"],
            "user_permissions": user_permissions,
            # Password policy flags for frontend templates
            "password_min_length": getattr(settings, "password_min_length", 8),
            "password_require_uppercase": getattr(settings, "password_require_uppercase", False),
            "password_require_lowercase": getattr(settings, "password_require_lowercase", False),
            "password_require_numbers": getattr(settings, "password_require_numbers", False),
            "password_require_special": getattr(settings, "password_require_special", False),
            # Token policy flags
            "require_token_expiration": getattr(settings, "require_token_expiration", True),
            "sri_hashes": load_sri_hashes(),
            "max_members_per_team": settings.max_members_per_team,
        },
    )

    # Set JWT token cookie for HTMX requests if email auth is enabled
    if getattr(settings, "email_auth_enabled", False):
        try:
            # JWT library is imported at top level as jwt

            # Determine the admin user email
            admin_email = get_user_email(user)
            is_admin_flag = bool(user.get("is_admin") if isinstance(user, dict) else True)
            full_name = getattr(settings, "platform_admin_full_name", "Platform User")
            if isinstance(user, dict):
                full_name = user.get("full_name") or full_name
            else:
                full_name = getattr(user, "full_name", full_name) or full_name

            # Preserve auth provider across admin UI token refreshes so logout behavior
            # can reliably detect SSO sessions (e.g., Keycloak) later.
            auth_provider = "local"
            if isinstance(user, dict):
                provider_from_user = user.get("auth_provider")
                if isinstance(provider_from_user, str) and provider_from_user.strip():
                    auth_provider = provider_from_user.strip()
            else:
                provider_from_user = getattr(user, "auth_provider", None)
                if isinstance(provider_from_user, str) and provider_from_user.strip():
                    auth_provider = provider_from_user.strip()

            # get_current_user_with_permissions may not include auth_provider in its dict.
            # Fall back to the current jwt_token cookie payload before refreshing it.
            if auth_provider == "local":
                jwt_cookie = request.cookies.get("jwt_token")
                if isinstance(jwt_cookie, str) and jwt_cookie:
                    try:
                        existing_payload = await verify_jwt_token_cached(jwt_cookie, request)
                        existing_user = existing_payload.get("user")
                        provider_from_token = existing_user.get("auth_provider") if isinstance(existing_user, dict) else None
                        if not provider_from_token:
                            provider_from_token = existing_payload.get("auth_provider")
                        if isinstance(provider_from_token, str) and provider_from_token.strip():
                            auth_provider = provider_from_token.strip()
                    except Exception as provider_error:  # nosec B110 - best-effort provider preservation
                        LOGGER.warning("Could not resolve auth_provider from existing JWT cookie; SSO logout may not function correctly: %s", provider_error)
                        if settings.sso_keycloak_enabled:
                            auth_provider = "keycloak"

            # Generate a lightweight session JWT token
            now = datetime.now(timezone.utc)
            payload = {
                "sub": admin_email,
                "iss": settings.jwt_issuer,
                "aud": settings.jwt_audience,
                "iat": int(now.timestamp()),
                "exp": int((now + timedelta(minutes=settings.token_expiry)).timestamp()),
                "jti": str(uuid.uuid4()),
                "auth_provider": auth_provider,
                "user": {"email": admin_email, "full_name": full_name, "is_admin": is_admin_flag, "auth_provider": auth_provider},
                "token_use": "session",  # nosec B105 - token type marker, not a password
                "scopes": {"server_id": None, "permissions": ["*"] if is_admin_flag else [], "ip_restrictions": [], "time_restrictions": {}},
            }

            # Generate token using centralized token creation
            token = await create_jwt_token(payload)

            # Set HTTP-only cookie using centralized security cookie utility
            set_auth_cookie(response, token, remember_me=False)
            LOGGER.debug(f"Set session JWT token cookie for user: {admin_email}")
        except Exception as e:
            LOGGER.warning(f"Failed to set JWT token cookie for user {user}: {e}")

    cookie_action = ui_visibility_config.get("cookie_action")
    if cookie_action:
        scope_root_path = _resolve_root_path(request)
        ui_cookie_path = f"{scope_root_path}/admin" if scope_root_path else "/admin"
        use_secure = (settings.environment == "production") or settings.secure_cookies
        samesite = settings.cookie_samesite
        if cookie_action == "set":
            response.set_cookie(
                key=UI_HIDE_SECTIONS_COOKIE_NAME,
                value=ui_visibility_config.get("cookie_value", ""),
                max_age=UI_HIDE_SECTIONS_COOKIE_MAX_AGE,
                path=ui_cookie_path,
                httponly=True,
                secure=use_secure,
                samesite=samesite,
            )
        elif cookie_action == "delete":
            response.delete_cookie(
                key=UI_HIDE_SECTIONS_COOKIE_NAME,
                path=ui_cookie_path,
                secure=use_secure,
                httponly=True,
                samesite=samesite,
            )

    _set_admin_csrf_cookie(request, response)
    return response


@admin_router.get("/login")
async def admin_login_page(request: Request) -> Response:
    """
    Render the admin login page.

    This endpoint serves the login form for email-based authentication.
    If email auth is disabled, redirects to the main admin page.
    If user is already authenticated, redirects to the dashboard.

    Args:
        request (Request): FastAPI request object.

    Returns:
        Response: Rendered HTML or redirect response.

    Examples:
        >>> from fastapi import Request
        >>> from fastapi.responses import HTMLResponse
        >>> from unittest.mock import MagicMock
        >>>
        >>> # Mock request
        >>> mock_request = MagicMock(spec=Request)
        >>> mock_request.scope = {"root_path": "/test"}
        >>> mock_request.app.state.templates = MagicMock()
        >>> mock_response = HTMLResponse("<html>Login</html>")
        >>> mock_request.app.state.templates.TemplateResponse.return_value = mock_response
        >>>
        >>> import asyncio
        >>> async def test_login_page():
        ...     response = await admin_login_page(mock_request)
        ...     return isinstance(response, HTMLResponse)
        >>>
        >>> asyncio.run(test_login_page())
        True
    """
    # Check if email auth is enabled
    if not getattr(settings, "email_auth_enabled", False):
        root_path = _resolve_root_path(request)
        return RedirectResponse(url=f"{root_path}/admin", status_code=303)

    root_path = settings.app_root_path

    # Check if user is already authenticated via JWT cookie
    # Skip redirect when an error param is present — the user was sent here
    # intentionally (e.g. admin_required, account_disabled).
    clear_invalid_cookies = False
    if not request.query_params.get("error"):
        jwt_token = request.cookies.get("jwt_token") or request.cookies.get("access_token")
        if jwt_token:
            try:
                # First-Party
                from mcpgateway.auth import validate_token_user

                auth_user = await validate_token_user(request, jwt_token)
                token_teams = getattr(request.state, "token_teams", None)

                # Preserve public-only denial invariant — same as AdminAuthMiddleware
                if token_teams is not None and len(token_teams) == 0:
                    pass  # Render login page; do not redirect to /admin
                elif auth_user.is_admin:
                    return RedirectResponse(url=f"{root_path}/admin", status_code=303)
                else:
                    # Non-admin with valid token: check RBAC admin permission
                    with SessionLocal() as db:
                        permission_service = PermissionService(db)
                        has_admin_access = await permission_service.has_admin_permission(
                            auth_user.email,
                            team_id=None,
                            token_teams=token_teams,
                        )
                        if has_admin_access:
                            return RedirectResponse(url=f"{root_path}/admin", status_code=303)
                        # else: render login page; token is valid but lacks admin access
            except Exception:
                clear_invalid_cookies = True

    # Only show secure cookie warning if there's a login error AND problematic config
    secure_cookie_warning = None
    if settings.secure_cookies and settings.environment == "development":
        secure_cookie_warning = "Serving over HTTP with secure cookies enabled. If you have login issues, try disabling secure cookies in your configuration."

    # Preserve email from failed login attempt
    prefill_email = request.query_params.get("email", "")

    # Use external template file
    response = request.app.state.templates.TemplateResponse(
        request,
        "login.html",
        {
            "request": request,
            "root_path": root_path,
            "secure_cookie_warning": secure_cookie_warning,
            "ui_airgapped": settings.mcpgateway_ui_airgapped,
            "prefill_email": prefill_email,
            "password_reset_enabled": getattr(settings, "password_reset_enabled", True),
            "sri_hashes": load_sri_hashes(),
        },
    )

    # Set CSRF cookie first, then clear invalid JWT cookies
    # This ensures CSRF cookie appears in set-cookie header
    _set_admin_csrf_cookie(request, response)

    # Clear invalid JWT cookies to prevent redirect loop
    if clear_invalid_cookies:
        response.delete_cookie("jwt_token", path="/")
        response.delete_cookie("access_token", path="/")

    return response


@admin_router.post("/login")
async def admin_login_handler(request: Request, db: Session = Depends(get_db)) -> RedirectResponse:
    """
    Handle admin login form submission.

    This endpoint processes the email/password login form, authenticates the user,
    sets the JWT cookie, and redirects to the admin panel or back to login with error.

    Args:
        request (Request): FastAPI request object.
        db (Session): Database session dependency.

    Returns:
        RedirectResponse: Redirect to admin panel on success or login page on failure.

    Examples:
        >>> from fastapi import Request
        >>> from fastapi.responses import RedirectResponse
        >>> from unittest.mock import MagicMock, AsyncMock
        >>>
        >>> # Mock request with form data
        >>> mock_request = MagicMock(spec=Request)
        >>> mock_request.scope = {"root_path": "/test"}
        >>> mock_form = {"email": "admin@example.com", "password": "changeme"}
        >>> mock_request.form = AsyncMock(return_value=mock_form)
        >>>
        >>> mock_db = MagicMock()
        >>>
        >>> import asyncio
        >>> async def test_login_handler():
        ...     try:
        ...         response = await admin_login_handler(mock_request, mock_db)
        ...         return isinstance(response, RedirectResponse)
        ...     except Exception:
        ...         return True  # Expected due to mocked dependencies
        >>>
        >>> asyncio.run(test_login_handler())
        True
    """
    root_path = _resolve_root_path(request)

    if not getattr(settings, "email_auth_enabled", False):
        return RedirectResponse(url=f"{root_path}/admin", status_code=303)

    try:
        form = await request.form()
        email_val = form.get("email")
        password_val = form.get("password")
        email = email_val if isinstance(email_val, str) else None
        password = password_val if isinstance(password_val, str) else None

        if not email or not password:
            params = "error=missing_fields"
            if email:
                params += f"&email={urllib.parse.quote(email)}"
            return RedirectResponse(url=f"{root_path}/admin/login?{params}", status_code=303)

        # Authenticate using the email auth service
        auth_service = EmailAuthService(db)

        try:
            # Authenticate user
            LOGGER.debug(f"Attempting authentication for {email}")
            user = await auth_service.authenticate_user(email, password)
            LOGGER.debug(f"Authentication result: {user}")

            if not user:
                LOGGER.warning(f"Authentication failed for {email} - user is None")
                return RedirectResponse(url=f"{root_path}/admin/login?error=invalid_credentials&email={urllib.parse.quote(email)}", status_code=303)

            if settings.sso_enabled and settings.sso_preserve_admin_auth and not bool(getattr(user, "is_admin", False)):
                LOGGER.info("Blocking local password login for non-admin user %s because SSO_PRESERVE_ADMIN_AUTH is enabled", email)
                return RedirectResponse(url=f"{root_path}/admin/login?error=sso_required&email={urllib.parse.quote(email)}", status_code=303)

            # Password change enforcement respects master switch and toggles
            needs_password_change = False

            if settings.password_change_enforcement_enabled:
                # If flag is set on the user, always honor it (flag is cleared when password is changed)
                if getattr(user, "password_change_required", False):
                    needs_password_change = True
                    LOGGER.debug("User %s has password_change_required flag set", email)

                # Enforce expiry-based password change if configured and not already required
                if not needs_password_change:
                    try:
                        pwd_changed = getattr(user, "password_changed_at", None)
                        if pwd_changed:
                            age_days = (utc_now() - pwd_changed).days
                            max_age = getattr(settings, "password_max_age_days", 90)
                            if age_days >= max_age:
                                needs_password_change = True
                                LOGGER.debug("User %s password expired (%s days >= %s)", email, age_days, max_age)
                    except Exception as exc:
                        LOGGER.debug("Failed to evaluate password age for %s: %s", email, exc)

                # Detect default password on login if enabled
                if getattr(settings, "detect_default_password_on_login", True):
                    password_service = Argon2PasswordService()
                    is_using_default_password = await password_service.verify_password_async(settings.default_user_password.get_secret_value(), user.password_hash)  # nosec B105
                    if is_using_default_password:
                        if getattr(settings, "require_password_change_for_default_password", True):
                            user.password_change_required = True
                            needs_password_change = True
                            try:
                                db.commit()
                            except Exception as exc:  # log commit failures
                                LOGGER.warning("Failed to commit password_change_required flag for %s: %s", email, exc)
                        else:
                            LOGGER.info("User %s is using default password but enforcement is disabled", email)

            if needs_password_change:
                LOGGER.info(f"User {email} requires password change - redirecting to change password page")

                # Create temporary JWT token for password change process
                token, _ = await create_access_token(user)

                # Create redirect response to password change page
                response = RedirectResponse(url=f"{root_path}/admin/change-password-required", status_code=303)

                # Set JWT token as secure cookie for the password change process
                try:
                    set_auth_cookie(response, token, remember_me=False)
                except CookieTooLargeError:
                    return RedirectResponse(
                        url=f"{root_path}/admin/login?error=token_too_large&email={urllib.parse.quote(email)}",
                        status_code=303,
                    )

                _set_admin_csrf_cookie(request, response)
                return response

            # Create JWT token with proper audience and issuer claims
            token, _ = await create_access_token(user)  # expires_seconds not needed here

            # Create redirect response
            response = RedirectResponse(url=f"{root_path}/admin", status_code=303)

            # Set JWT token as secure cookie
            try:
                set_auth_cookie(response, token, remember_me=False)
            except CookieTooLargeError:
                return RedirectResponse(
                    url=f"{root_path}/admin/login?error=token_too_large&email={urllib.parse.quote(email)}",
                    status_code=303,
                )

            _set_admin_csrf_cookie(request, response)
            LOGGER.info(f"Admin user {email} logged in successfully")
            return response

        except Exception as e:
            LOGGER.warning(f"Login failed for {email}: {e}")

            if settings.secure_cookies and settings.environment == "development":
                LOGGER.warning("Login failed - set SECURE_COOKIES to false in config for HTTP development")

            return RedirectResponse(url=f"{root_path}/admin/login?error=invalid_credentials&email={urllib.parse.quote(email)}", status_code=303)

    except Exception as e:
        LOGGER.error(f"Login handler error: {e}")
        return RedirectResponse(url=f"{root_path}/admin/login?error=server_error", status_code=303)


@admin_router.get("/forgot-password")
async def admin_forgot_password_page(request: Request) -> Response:
    """Render forgot-password page.

    Args:
        request: Incoming HTTP request.

    Returns:
        Response: Forgot-password page response.
    """
    root_path = settings.app_root_path
    if not getattr(settings, "email_auth_enabled", False):
        return RedirectResponse(url=f"{root_path}/admin/login", status_code=303)
    response = request.app.state.templates.TemplateResponse(
        request,
        "forgot-password.html",
        {
            "request": request,
            "root_path": root_path,
            "password_reset_enabled": getattr(settings, "password_reset_enabled", True),
            "ui_airgapped": settings.mcpgateway_ui_airgapped,
            "sri_hashes": load_sri_hashes(),
        },
    )
    _set_admin_csrf_cookie(request, response)
    return response


@admin_router.post("/forgot-password")
async def admin_forgot_password_handler(request: Request, db: Session = Depends(get_db)) -> RedirectResponse:
    """Handle forgot-password form submission.

    Args:
        request: Incoming HTTP request with form data.
        db: Database session dependency.

    Returns:
        RedirectResponse: Redirect to login or forgot-password page with status.
    """
    root_path = _resolve_root_path(request)
    if not getattr(settings, "email_auth_enabled", False):
        return RedirectResponse(url=f"{root_path}/admin/login", status_code=303)
    if not getattr(settings, "password_reset_enabled", True):
        return RedirectResponse(url=f"{root_path}/admin/forgot-password?error=password_reset_disabled", status_code=303)

    try:
        form = await request.form()
        email_val = form.get("email")
        email = str(email_val).strip() if email_val else ""
        if not email:
            return RedirectResponse(url=f"{root_path}/admin/forgot-password?error=missing_email", status_code=303)

        auth_service = EmailAuthService(db)
        result = await auth_service.request_password_reset(email=email, ip_address=get_client_ip(request), user_agent=get_user_agent(request))
        if result.rate_limited:
            return RedirectResponse(url=f"{root_path}/admin/forgot-password?error=rate_limited", status_code=303)
        return RedirectResponse(url=f"{root_path}/admin/login?notice=reset_email_sent", status_code=303)
    except Exception as exc:
        LOGGER.warning("Forgot-password request failed: %s", exc)
        return RedirectResponse(url=f"{root_path}/admin/forgot-password?error=server_error", status_code=303)


@admin_router.get("/reset-password/{token}")
async def admin_reset_password_page(token: str, request: Request, db: Session = Depends(get_db)) -> Response:
    """Render password reset form for a token.

    Args:
        token: One-time reset token.
        request: Incoming HTTP request.
        db: Database session dependency.

    Returns:
        Response: Reset-password page response.
    """
    root_path = settings.app_root_path
    if not getattr(settings, "email_auth_enabled", False):
        return RedirectResponse(url=f"{root_path}/admin/login", status_code=303)
    if not getattr(settings, "password_reset_enabled", True):
        return RedirectResponse(url=f"{root_path}/admin/forgot-password?error=password_reset_disabled", status_code=303)

    auth_service = EmailAuthService(db)
    token_valid = False
    token_error = None
    try:
        await auth_service.validate_password_reset_token(token=token, ip_address=get_client_ip(request), user_agent=get_user_agent(request))
        token_valid = True
    except AuthenticationError as exc:
        token_error = str(exc)

    response = request.app.state.templates.TemplateResponse(
        request,
        "reset-password.html",
        {
            "request": request,
            "root_path": root_path,
            "token": token,
            "token_valid": token_valid,
            "token_error": token_error,
            "password_min_length": settings.password_min_length,
            "ui_airgapped": settings.mcpgateway_ui_airgapped,
            "sri_hashes": load_sri_hashes(),
        },
    )
    _set_admin_csrf_cookie(request, response)
    return response


@admin_router.post("/reset-password/{token}")
async def admin_reset_password_handler(token: str, request: Request, db: Session = Depends(get_db)) -> RedirectResponse:
    """Handle password reset form submission.

    Args:
        token: One-time reset token.
        request: Incoming HTTP request with reset form data.
        db: Database session dependency.

    Returns:
        RedirectResponse: Redirect to login or reset page with status.
    """
    root_path = _resolve_root_path(request)
    if not getattr(settings, "email_auth_enabled", False):
        return RedirectResponse(url=f"{root_path}/admin/login", status_code=303)
    if not getattr(settings, "password_reset_enabled", True):
        return RedirectResponse(url=f"{root_path}/admin/forgot-password?error=password_reset_disabled", status_code=303)

    try:
        form = await request.form()
        password = str(form.get("password", ""))
        confirm_password = str(form.get("confirm_password", ""))
        if not password or not confirm_password:
            return RedirectResponse(url=f"{root_path}/admin/reset-password/{urllib.parse.quote(token)}?error=missing_fields", status_code=303)
        if password != confirm_password:
            return RedirectResponse(url=f"{root_path}/admin/reset-password/{urllib.parse.quote(token)}?error=password_mismatch", status_code=303)

        auth_service = EmailAuthService(db)
        await auth_service.reset_password_with_token(token=token, new_password=password, ip_address=get_client_ip(request), user_agent=get_user_agent(request))
        return RedirectResponse(url=f"{root_path}/admin/login?notice=password_reset_success", status_code=303)
    except PasswordValidationError as exc:
        return RedirectResponse(url=f"{root_path}/admin/reset-password/{urllib.parse.quote(token)}?error={urllib.parse.quote(str(exc))}", status_code=303)
    except AuthenticationError as exc:
        msg = str(exc).lower()
        if "expired" in msg:
            return RedirectResponse(url=f"{root_path}/admin/forgot-password?error=reset_link_expired", status_code=303)
        if "used" in msg:
            return RedirectResponse(url=f"{root_path}/admin/forgot-password?error=reset_link_used", status_code=303)
        return RedirectResponse(url=f"{root_path}/admin/forgot-password?error=reset_link_invalid", status_code=303)
    except Exception as exc:
        LOGGER.warning("Password reset failed: %s", exc)
        return RedirectResponse(url=f"{root_path}/admin/reset-password/{urllib.parse.quote(token)}?error=server_error", status_code=303)


async def _admin_logout(request: Request) -> Response:
    """
    Handle admin logout by clearing authentication cookies.

    Supports three logout scenarios:
    - POST: User-initiated logout from the UI (redirects to login page or Keycloak logout)
    - GET with browser headers: Browser navigation to /admin/logout (redirects to login page)
    - GET without browser headers: OIDC front-channel logout callback from IdP (returns 200 OK)

    For OIDC front-channel logout (per OpenID Connect Front-Channel Logout 1.0 spec),
    identity providers like Microsoft Entra ID send GET requests to notify the application
    that the user has logged out from the IdP. The application should clear the session
    and return HTTP 200.

    Args:
        request (Request): FastAPI request object.

    Returns:
        Response: RedirectResponse for POST, or Response with 200 for GET (front-channel logout).

    Examples:
        >>> from fastapi import Request
        >>> from fastapi.responses import RedirectResponse, Response
        >>> from unittest.mock import MagicMock
        >>>
        >>> # Mock POST request (user-initiated)
        >>> mock_request = MagicMock(spec=Request)
        >>> mock_request.scope = {"root_path": "/test"}
        >>> mock_request.method = "POST"
        >>>
        >>> import asyncio
        >>> async def test_logout_post():
        ...     response = await _admin_logout(mock_request)
        ...     return isinstance(response, RedirectResponse) and response.status_code == 303
        >>>
        >>> asyncio.run(test_logout_post())
        True

        >>> # Mock GET request (front-channel logout)
        >>> mock_request.method = "GET"
        >>> async def test_logout_get():
        ...     response = await _admin_logout(mock_request)
        ...     return response.status_code == 200
        >>>
        >>> asyncio.run(test_logout_get())
        True
    """

    async def _extract_auth_provider_from_jwt_cookie() -> Optional[str]:
        """Best-effort auth provider resolution from the current JWT cookie.

        Returns:
            Optional[str]: Auth provider from JWT payload, if available.
        """
        cookies = getattr(request, "cookies", None)
        if not cookies or not hasattr(cookies, "get"):
            return None
        token = cookies.get("jwt_token")
        if not isinstance(token, str) or not token:
            return None
        try:
            payload = await verify_jwt_token_cached(token, request)
        except Exception as exc:  # nosec B110 - best-effort provider detection during logout
            LOGGER.warning("Failed to verify JWT during logout - SSO session may not be cleared: %s", exc)
            if settings.sso_keycloak_enabled:
                return "keycloak"
            return None

        user_payload = payload.get("user")
        if isinstance(user_payload, dict):
            user_provider = user_payload.get("auth_provider")
            if isinstance(user_provider, str) and user_provider:
                return user_provider

        auth_provider = payload.get("auth_provider")
        if isinstance(auth_provider, str) and auth_provider:
            return auth_provider
        return None

    def _build_absolute_login_url(root_path: str) -> Optional[str]:
        """Build an absolute login URL using request URL, with app_domain fallback.

        Args:
            root_path (str): Application root path from request scope.

        Returns:
            Optional[str]: Absolute login URL when resolvable, otherwise ``None``.
        """
        login_path = f"{root_path}/admin/login"
        request_url = getattr(request, "url", None)
        scheme = getattr(request_url, "scheme", None) if request_url is not None else None
        netloc = getattr(request_url, "netloc", None) if request_url is not None else None
        if isinstance(scheme, str) and scheme and isinstance(netloc, str) and netloc:
            return f"{scheme}://{netloc}{login_path}"

        app_domain = str(getattr(settings, "app_domain", "") or "").rstrip("/")
        if app_domain:
            return f"{app_domain}{login_path}"
        return None

    def _build_keycloak_logout_url(root_path: str) -> Optional[str]:
        """Build Keycloak RP-initiated logout URL when all required config is available.

        Args:
            root_path (str): Application root path from request scope.

        Returns:
            Optional[str]: Keycloak logout URL when all inputs are valid, otherwise ``None``.
        """
        if not settings.sso_keycloak_enabled or not settings.sso_keycloak_base_url:
            return None

        login_url = _build_absolute_login_url(root_path)
        if not login_url:
            LOGGER.warning("Cannot build Keycloak logout URL: unable to resolve absolute login URL")
            return None

        keycloak_base = (settings.sso_keycloak_public_base_url or settings.sso_keycloak_base_url or "").rstrip("/")
        realm = str(settings.sso_keycloak_realm or "").strip()
        if not keycloak_base or not realm:
            LOGGER.warning("Cannot build Keycloak logout URL: missing keycloak_base or realm configuration")
            return None

        logout_endpoint = f"{keycloak_base}/realms/{urllib.parse.quote(realm, safe='')}/protocol/openid-connect/logout"
        query_params: Dict[str, str] = {
            "post_logout_redirect_uri": login_url,
            # Legacy Keycloak compatibility
            "redirect_uri": login_url,
        }
        if settings.sso_keycloak_client_id:
            query_params["client_id"] = settings.sso_keycloak_client_id

        cookies = getattr(request, "cookies", None)
        if cookies and hasattr(cookies, "get"):
            id_token_hint = cookies.get("sso_id_token_hint")
            if isinstance(id_token_hint, str) and id_token_hint:
                # Only include the hint if the id_token has not expired.
                # Keycloak rejects expired id_token_hint with an error page.
                try:
                    payload_b64 = id_token_hint.split(".")[1]
                    payload_b64 += "=" * (-len(payload_b64) % 4)  # pad base64
                    claims = orjson.loads(binascii.a2b_base64(payload_b64))
                    if claims.get("exp", 0) > time.time():
                        query_params["id_token_hint"] = id_token_hint
                    else:
                        LOGGER.info("Omitting expired id_token_hint from Keycloak logout URL")
                except Exception:
                    LOGGER.debug("Could not decode id_token_hint; omitting from logout URL")

        return f"{logout_endpoint}?{urllib.parse.urlencode(query_params)}"

    LOGGER.info(f"Admin user logging out (method: {request.method})")
    root_path = _resolve_root_path(request)

    # Revoke JWT token in blocklist for immediate invalidation
    cookies = getattr(request, "cookies", None)
    if cookies and hasattr(cookies, "get"):
        token = cookies.get("jwt_token")
        if isinstance(token, str) and token:
            try:
                # First-Party
                from mcpgateway.services.token_blocklist_service import get_token_blocklist_service  # pylint: disable=import-outside-toplevel

                payload = await verify_jwt_token_cached(token, request)
                jti = payload.get("jti")
                email = payload.get("email", "admin")

                if jti:
                    blocklist_service = get_token_blocklist_service()

                    # Get token expiry from payload
                    exp_ts = payload.get("exp")
                    token_expiry = None
                    if exp_ts:
                        token_expiry = datetime.fromtimestamp(exp_ts, tz=timezone.utc)

                    # Get last activity if present
                    last_activity = None
                    last_activity_ts = payload.get("last_activity")
                    if last_activity_ts:
                        last_activity = datetime.fromtimestamp(last_activity_ts, tz=timezone.utc)

                    blocklist_service.revoke_token(jti=jti, revoked_by=email, reason="admin_logout", token_expiry=token_expiry, last_activity=last_activity)
                    LOGGER.info(f"Token revoked during admin logout: jti={jti}", extra={"security_event": "admin_logout_token_revoked", "security_severity": "low", "jti": jti, "user_id": email})
            except Exception as revoke_error:
                # Log but don't fail logout if token revocation fails
                LOGGER.warning(f"Failed to revoke token during admin logout: {revoke_error}")

    # For GET requests, distinguish between browser navigation and OIDC front-channel logout
    if request.method == "GET":
        # Check if request is from a browser (Accept: text/html, HX-Request header, or admin referer)
        # Detection must match auth_middleware.py and rbac.py patterns to ensure consistent behavior
        # Browser navigation should redirect to login, OIDC callbacks should return 200 OK
        accept_header = request.headers.get("accept", "")
        is_htmx = request.headers.get("hx-request") == "true"
        referer = request.headers.get("referer", "")
        is_browser_request = "text/html" in accept_header or is_htmx or "/admin" in referer

        if is_browser_request:
            # Browser navigation - redirect to login (cookies cleared below)
            response = RedirectResponse(url=f"{root_path}/admin/login", status_code=303)
        else:
            # OIDC front-channel logout from IdP - return 200 OK per OIDC spec
            # Reference: OpenID Connect Front-Channel Logout 1.0
            # https://openid.net/specs/openid-connect-frontchannel-1_0.html
            # The RP must clear the session and return HTTP 200 to acknowledge logout
            response = Response(content="Logged out", status_code=200)
    else:
        # POST requests (user-initiated) - redirect to login (cookies cleared below)
        response = RedirectResponse(url=f"{root_path}/admin/login", status_code=303)

        auth_provider = await _extract_auth_provider_from_jwt_cookie()
        if auth_provider == "keycloak":
            keycloak_logout_url = _build_keycloak_logout_url(root_path)
            if keycloak_logout_url:
                LOGGER.info("Redirecting to Keycloak RP-initiated logout endpoint")
                response = RedirectResponse(url=keycloak_logout_url, status_code=303)

    # Always clear local JWT session cookie.
    clear_auth_cookie(response)

    # Clear CSRF token cookie
    # First-Party
    from mcpgateway.services.csrf_service import clear_csrf_cookie

    clear_csrf_cookie(response, settings)

    use_secure = (settings.environment == "production") or settings.secure_cookies
    response.delete_cookie(
        key="sso_id_token_hint",
        path=settings.app_root_path or "/",
        secure=use_secure,
        httponly=True,
        samesite=settings.cookie_samesite,
    )
    _clear_admin_csrf_cookie(request, response)
    return response


@admin_router.get("/logout", operation_id="admin_logout_get")
async def admin_logout_get(request: Request) -> Response:
    """GET logout endpoint for OIDC front-channel logout.

    Args:
        request (Request): FastAPI request object.

    Returns:
        Response: Logout response for front-channel requests.
    """
    return await _admin_logout(request)


@admin_router.post("/logout", operation_id="admin_logout_post")
async def admin_logout_post(request: Request) -> Response:
    """POST logout endpoint for user-initiated UI logout.

    Args:
        request (Request): FastAPI request object.

    Returns:
        Response: Logout response for UI-initiated requests.
    """
    return await _admin_logout(request)


@admin_router.get("/change-password-required", response_class=HTMLResponse)
async def change_password_required_page(request: Request) -> HTMLResponse:
    """
    Render the password change required page.

    This page is shown when a user's password has expired and must be changed
    to continue accessing the system.

    Args:
        request (Request): FastAPI request object.

    Returns:
        HTMLResponse: The password change required page.

    Examples:
        >>> from unittest.mock import MagicMock
        >>> from fastapi import Request
        >>> from fastapi.responses import HTMLResponse
        >>>
        >>> # Mock request
        >>> mock_request = MagicMock(spec=Request)
        >>> mock_request.scope = {"root_path": "/test"}
        >>> mock_request.app.state.templates = MagicMock()
        >>> mock_response = HTMLResponse("<html>Change Password</html>")
        >>> mock_request.app.state.templates.TemplateResponse.return_value = mock_response
        >>>
        >>> import asyncio
        >>> async def test_change_password_page():
        ...     # Note: This requires email_auth_enabled=True in settings
        ...     return True  # Simplified test due to settings dependency
        >>>
        >>> asyncio.run(test_change_password_page())
        True
    """
    if not getattr(settings, "email_auth_enabled", False):
        root_path = _resolve_root_path(request)
        return RedirectResponse(url=f"{root_path}/admin", status_code=303)

    # Get root path for template
    root_path = _resolve_root_path(request)

    response = request.app.state.templates.TemplateResponse(
        request,
        "change-password-required.html",
        {
            "request": request,
            "root_path": root_path,
            "ui_airgapped": settings.mcpgateway_ui_airgapped,
            "password_policy_enabled": getattr(settings, "password_policy_enabled", True),
            "password_min_length": getattr(settings, "password_min_length", 8),
            "password_require_uppercase": getattr(settings, "password_require_uppercase", False),
            "password_require_lowercase": getattr(settings, "password_require_lowercase", False),
            "password_require_numbers": getattr(settings, "password_require_numbers", False),
            "password_require_special": getattr(settings, "password_require_special", False),
            "sri_hashes": load_sri_hashes(),
        },
    )
    _set_admin_csrf_cookie(request, response)
    return response


@admin_router.post("/change-password-required")
async def change_password_required_handler(request: Request, db: Session = Depends(get_db)) -> RedirectResponse:
    """
    Handle password change requirement form submission.

    This endpoint processes the forced password change form, validates the credentials,
    changes the password, clears the password_change_required flag, and redirects to admin panel.

    Args:
        request (Request): FastAPI request object.
        db (Session): Database session dependency.

    Returns:
        RedirectResponse: Redirect to admin panel on success or back to form with error.

    Examples:
        >>> from unittest.mock import MagicMock, AsyncMock
        >>> from fastapi import Request
        >>> from fastapi.responses import RedirectResponse
        >>>
        >>> # Mock request with form data
        >>> mock_request = MagicMock(spec=Request)
        >>> mock_request.scope = {"root_path": "/test"}
        >>> mock_form = {
        ...     "current_password": "oldpass",
        ...     "new_password": "newpass123",
        ...     "confirm_password": "newpass123"
        ... }
        >>> mock_request.form = AsyncMock(return_value=mock_form)
        >>> mock_request.cookies = {"jwt_token": "test_token"}
        >>> mock_request.headers = {"User-Agent": "TestAgent"}
        >>>
        >>> mock_db = MagicMock()
        >>>
        >>> import asyncio
        >>> async def test_password_change_handler():
        ...     # Note: Full test requires email_auth_enabled and valid JWT
        ...     return True  # Simplified test due to settings/auth dependencies
        >>>
        >>> asyncio.run(test_password_change_handler())
        True
    """
    root_path = _resolve_root_path(request)

    if not getattr(settings, "email_auth_enabled", False):
        return RedirectResponse(url=f"{root_path}/admin", status_code=303)

    try:
        form = await request.form()
        current_password_val = form.get("current_password")
        new_password_val = form.get("new_password")
        confirm_password_val = form.get("confirm_password")

        current_password = current_password_val if isinstance(current_password_val, str) else None
        new_password = new_password_val if isinstance(new_password_val, str) else None
        confirm_password = confirm_password_val if isinstance(confirm_password_val, str) else None

        if not all([current_password, new_password, confirm_password]):
            return RedirectResponse(url=f"{root_path}/admin/change-password-required?error=missing_fields", status_code=303)

        if new_password != confirm_password:
            return RedirectResponse(url=f"{root_path}/admin/change-password-required?error=mismatch", status_code=303)

        # Get user from JWT token in cookie
        try:
            jwt_token = request.cookies.get("jwt_token")
            current_user = None
            if jwt_token:
                credentials = HTTPAuthorizationCredentials(scheme="Bearer", credentials=jwt_token)
                current_user = await get_current_user(credentials, request=request)
        except Exception as e:
            LOGGER.error(f"Authentication error: {e}")
            current_user = None

        if not current_user:
            return RedirectResponse(url=f"{root_path}/admin/login?error=session_expired", status_code=303)

        # Authenticate using the email auth service
        auth_service = EmailAuthService(db)
        ip_address = get_client_ip(request)
        user_agent = get_user_agent(request)

        try:
            # Change password
            success = await auth_service.change_password(email=current_user.email, old_password=current_password, new_password=new_password, ip_address=ip_address, user_agent=user_agent)

            if success:
                # Re-attach current_user to session for downstream use (e.g., get_teams() in token creation)
                # Note: password_change_required is already cleared by auth_service.change_password()
                # We must re-attach to ensure team claims are populated in the new JWT token.
                user_email = current_user.email  # Save before potential re-query
                try:
                    # pylint: disable=import-outside-toplevel
                    # Third-Party
                    from sqlalchemy import inspect as sa_inspect

                    # First-Party
                    from mcpgateway.db import EmailUser

                    insp = sa_inspect(current_user)
                    if insp.transient or insp.detached:
                        current_user = db.query(EmailUser).filter(EmailUser.email == user_email).first()
                        if current_user is None:
                            LOGGER.error(f"User {user_email} not found after successful password change - possible race condition")
                            return RedirectResponse(url=f"{root_path}/admin/change-password-required?error=server_error", status_code=303)
                except Exception as e:
                    # Return early to avoid creating token with empty team claims
                    LOGGER.error(f"Failed to re-attach user {user_email} to session: {e} - password changed but token creation skipped")
                    return RedirectResponse(url=f"{root_path}/admin/login?message=password_changed", status_code=303)

                # Create new JWT token
                token, _ = await create_access_token(current_user)

                # Create redirect response to admin panel
                response = RedirectResponse(url=f"{root_path}/admin", status_code=303)

                # Update JWT token cookie
                try:
                    set_auth_cookie(response, token, remember_me=False)
                except CookieTooLargeError:
                    return RedirectResponse(
                        url=f"{root_path}/admin/login?error=token_too_large",
                        status_code=303,
                    )

                LOGGER.info(f"User {current_user.email} successfully changed their expired password")
                return response

            return RedirectResponse(url=f"{root_path}/admin/change-password-required?error=change_failed", status_code=303)

        except AuthenticationError:
            return RedirectResponse(url=f"{root_path}/admin/change-password-required?error=invalid_password", status_code=303)
        except PasswordValidationError as e:
            LOGGER.warning(f"Password validation failed for {current_user.email}: {e}")
            return RedirectResponse(url=f"{root_path}/admin/change-password-required?error=weak_password", status_code=303)
        except Exception as e:
            LOGGER.error(f"Password change failed for {current_user.email}: {e}", exc_info=True)
            return RedirectResponse(url=f"{root_path}/admin/change-password-required?error=server_error", status_code=303)

    except Exception as e:
        LOGGER.error(f"Password change handler error: {e}")
        return RedirectResponse(url=f"{root_path}/admin/change-password-required?error=server_error", status_code=303)


# ============================================================================ #
#                            TEAM ADMIN ROUTES                                #
# ============================================================================ #


async def _generate_unified_teams_view(team_service, current_user, root_path):  # pylint: disable=unused-argument
    """Generate unified team view with relationship badges.

    Args:
        team_service: Service for team operations
        current_user: Current authenticated user
        root_path: Application root path

    Returns:
        HTML string containing the unified teams view
    """
    # Get user's teams (owned + member)
    user_teams = await team_service.get_user_teams(current_user.email)

    # Get public teams user can join
    public_teams = await team_service.discover_public_teams(current_user.email)

    # Batch fetch ALL data upfront - 3 queries instead of 3N queries (N+1 elimination)
    user_team_ids = [str(t.id) for t in user_teams]
    public_team_ids = [str(t.id) for t in public_teams]
    all_team_ids = user_team_ids + public_team_ids

    member_counts = await team_service.get_member_counts_batch_cached(all_team_ids)
    user_roles = team_service.get_user_roles_batch(current_user.email, user_team_ids)
    pending_requests = team_service.get_pending_join_requests_batch(current_user.email, public_team_ids)

    # Combine teams with relationship information
    all_teams = []

    # Add user's teams (owned and member)
    for team in user_teams:
        team_id = str(team.id)
        user_role = user_roles.get(team_id)
        relationship = "owner" if user_role == "owner" else "member"
        all_teams.append({"team": team, "relationship": relationship, "member_count": member_counts.get(team_id, 0)})

    # Add public teams user can join
    for team in public_teams:
        team_id = str(team.id)
        pending_request = pending_requests.get(team_id)
        relationship_data = {"team": team, "relationship": "join", "member_count": member_counts.get(team_id, 0), "pending_request": pending_request}
        all_teams.append(relationship_data)

    # Generate HTML for unified team view
    teams_html = ""
    for item in all_teams:
        team = item["team"]
        relationship = item["relationship"]
        member_count = item["member_count"]
        pending_request = item.get("pending_request")

        # Relationship badge - special handling for personal teams
        if team.is_personal:
            badge_html = '<span class="relationship-badge inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300">PERSONAL</span>'
        elif relationship == "owner":
            badge_html = (
                '<span class="relationship-badge inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300">OWNER</span>'
            )
        elif relationship == "member":
            badge_html = (
                '<span class="relationship-badge inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300">MEMBER</span>'
            )
        else:  # join
            badge_html = '<span class="relationship-badge inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-300">CAN JOIN</span>'

        # Visibility badge
        visibility_badge = (
            f'<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300">{team.visibility.upper()}</span>'
        )

        # Subtitle based on relationship - special handling for personal teams
        if team.is_personal:
            subtitle = "Your personal team • Private workspace"
        elif relationship == "owner":
            subtitle = "You own this team"
        elif relationship == "member":
            subtitle = f"You are a member • Owner: {team.created_by}"
        else:  # join
            subtitle = f"Public team • Owner: {team.created_by}"

        # Escape team name for safe HTML attributes
        safe_team_name = html.escape(team.name)

        # Actions based on relationship - special handling for personal teams
        actions_html = ""
        if team.is_personal:
            # Personal teams have no management actions - they're private workspaces
            actions_html = """
            <div class="flex flex-wrap gap-2 mt-3">
                <span class="px-3 py-1 text-sm font-medium text-gray-500 dark:text-gray-400 bg-gray-100 dark:bg-gray-700 rounded-md">
                    Personal workspace - no actions available
                </span>
            </div>
            """
        elif relationship == "owner":
            delete_button = f'<button data-team-id="{team.id}" data-team-name="{safe_team_name}" onclick="deleteTeamSafe(this)" class="px-3 py-1 text-sm font-medium text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300 border border-red-300 dark:border-red-600 hover:border-red-500 dark:hover:border-red-400 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500">Delete Team</button>'
            join_requests_button = (
                f'<button data-team-id="{team.id}" onclick="viewJoinRequestsSafe(this)" class="px-3 py-1 text-sm font-medium text-purple-600 dark:text-purple-400 hover:text-purple-800 dark:hover:text-purple-300 border border-purple-300 dark:border-purple-600 hover:border-purple-500 dark:hover:border-purple-400 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500">Join Requests</button>'
                if team.visibility == "public"
                else ""
            )
            actions_html = f"""
            <div class="flex flex-wrap gap-2 mt-3">
                <button data-team-id="{team.id}" onclick="manageTeamMembersSafe(this)" class="px-3 py-1 text-sm font-medium text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 border border-blue-300 dark:border-blue-600 hover:border-blue-500 dark:hover:border-blue-400 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
                    Manage Members
                </button>
                <button data-team-id="{team.id}" onclick="editTeamSafe(this)" class="px-3 py-1 text-sm font-medium text-green-600 dark:text-green-400 hover:text-green-800 dark:hover:text-green-300 border border-green-300 dark:border-green-600 hover:border-green-500 dark:hover:border-green-400 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500">
                    Edit Settings
                </button>
                {join_requests_button}
                {delete_button}
            </div>
            """
        elif relationship == "member":
            leave_button = f'<button data-team-id="{team.id}" data-team-name="{safe_team_name}" onclick="leaveTeamSafe(this)" class="px-3 py-1 text-sm font-medium text-orange-600 dark:text-orange-400 hover:text-orange-800 dark:hover:text-orange-300 border border-orange-300 dark:border-orange-600 hover:border-orange-500 dark:hover:border-orange-400 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-orange-500">Leave Team</button>'
            actions_html = f"""
            <div class="flex flex-wrap gap-2 mt-3">
                {leave_button}
            </div>
            """
        else:  # join
            if pending_request:
                # Show "Requested to Join [Cancel Request]" state
                actions_html = f"""
                <div class="flex flex-wrap gap-2 mt-3">
                    <span class="px-3 py-1 text-sm font-medium text-yellow-600 dark:text-yellow-400 bg-yellow-100 dark:bg-yellow-900 rounded-md border border-yellow-300 dark:border-yellow-600">
                        ⏳ Requested to Join
                    </span>
                    <button onclick="cancelJoinRequest('{team.id}', '{pending_request.id}')" class="px-3 py-1 text-sm font-medium text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300 border border-red-300 dark:border-red-600 hover:border-red-500 dark:hover:border-red-400 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500">
                        Cancel Request
                    </button>
                </div>
                """
            else:
                # Show "Request to Join" button (disabled if feature is disabled)
                allow_join_requests = getattr(settings, "allow_team_join_requests", True)
                if allow_join_requests:
                    actions_html = f"""
                <div class="flex flex-wrap gap-2 mt-3">
                    <button data-team-id="{team.id}" data-team-name="{safe_team_name}" onclick="requestToJoinTeamSafe(this)" class="px-3 py-1 text-sm font-medium text-indigo-600 dark:text-indigo-400 hover:text-indigo-800 dark:hover:text-indigo-300 border border-indigo-300 dark:border-indigo-600 hover:border-indigo-500 dark:hover:border-indigo-400 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
                        Request to Join
                    </button>
                </div>
                """
                else:
                    actions_html = """
                <div class="flex flex-wrap gap-2 mt-3">
                    <button disabled class="px-3 py-1 text-sm font-medium text-gray-400 dark:text-gray-600 border border-gray-300 dark:border-gray-600 rounded-md cursor-not-allowed opacity-50" title="Team join requests are currently disabled">
                        Request to Join
                    </button>
                </div>
                """

        # Truncated description (properly escaped)
        description_text = ""
        if team.description:
            safe_description = html.escape(team.description)
            truncated = safe_description[:80] + "..." if len(safe_description) > 80 else safe_description
            description_text = f'<p class="team-description text-sm text-gray-600 dark:text-gray-400 mt-1">{truncated}</p>'

        teams_html += f"""
        <div class="team-card bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-lg p-4 shadow-sm hover:shadow-md transition-shadow" data-relationship="{relationship}">
            <div class="flex justify-between items-start mb-3">
                <div class="flex-1">
                    <div class="flex items-center gap-3 mb-2">
                        <h4 class="team-name text-lg font-medium text-gray-900 dark:text-white">🏢 {safe_team_name}</h4>
                        {badge_html}
                        {visibility_badge}
                        <span class="text-sm text-gray-500 dark:text-gray-400">{member_count} members</span>
                    </div>
                    <p class="text-sm text-gray-600 dark:text-gray-400">{subtitle}</p>
                    {description_text}
                </div>
            </div>
            {actions_html}
        </div>
        """

    if not teams_html:
        teams_html = '<div class="text-center py-12"><p class="text-gray-500 dark:text-gray-400">No teams found. Create your first team using the button above.</p></div>'

    return HTMLResponse(content=teams_html)


@admin_router.get("/teams/ids", response_class=JSONResponse)
@require_permission("teams.read", allow_admin_bypass=False)
async def admin_get_all_team_ids(
    include_inactive: bool = False,
    visibility: QueryVisibilityCompact = None,
    q: Optional[str] = Query(None, max_length=500, description="Search query"),
    db: Session = Depends(get_db),
    user=Depends(get_current_user_with_permissions),
):
    """Return all team IDs accessible to the current user.

    Args:
        include_inactive (bool): Whether to include inactive teams.
        visibility (Optional[str]): Filter by team visibility.
        q (Optional[str]): Search query string.
        db (Session): Database session dependency.
        user: Current authenticated user.

    Returns:
        JSONResponse: Dictionary with list of team IDs and count.
    """
    team_service = TeamManagementService(db)
    user_email = get_user_email(user)

    auth_service = EmailAuthService(db)
    current_user = await auth_service.get_user_by_email(user_email)

    if not current_user:
        return {"team_ids": [], "count": 0}

    # If admin, get all teams (filtered)
    # If regular user, get user teams + accessible public teams?
    # For now, admin only per usage pattern?
    # But tools/ids handles team_id scoping. Here we filter by teams user can see.
    # get_all_team_ids supports search/visibility.

    # Check admin
    if current_user.is_admin:
        # Admin sees all non-personal teams plus their own personal team (single query)
        team_ids = await team_service.get_all_team_ids(include_inactive=include_inactive, visibility_filter=visibility, include_personal=False, search_query=q, personal_owner_email=user_email)
    else:
        # For non-admins, get user's teams + public teams logic?
        # get_user_teams gets all teams user is in.
        # discover_public_teams gets public teams.
        # unified search across them?
        # Simpler: just reuse list_teams logic but with huge limit?
        # Or, just return user's teams IDs filtering in memory (since user won't have millions of teams)
        all_teams = await team_service.get_user_teams(user_email, include_personal=True)
        # Apply filters
        # Note: get_user_teams includes visibility/inactive implicitly? No, it returns what they are member of.
        # But we might need public teams too?
        # Let's align with list_teams logic.

        filtered = []
        for t in all_teams:
            if not include_inactive and not t.is_active:
                continue
            if visibility and t.visibility != visibility:
                continue
            if q:
                if q.lower() not in t.name.lower() and q.lower() not in t.slug.lower():
                    continue
            filtered.append(t.id)
        team_ids = filtered

    return {"team_ids": team_ids, "count": len(team_ids)}


@admin_router.get("/teams/search", response_class=JSONResponse)
@require_permission("teams.read", allow_admin_bypass=False)
async def admin_search_teams(
    q: str = Query("", max_length=500, description="Search query"),
    include_inactive: bool = False,
    limit: int = Query(settings.pagination_default_page_size, ge=1, le=settings.pagination_max_page_size, description="Max results"),
    visibility: QueryVisibility = None,
    db: Session = Depends(get_db),
    user=Depends(get_current_user_with_permissions),
):
    """Search teams by name/slug/description.

    Args:
        q (str): Search query string.
        include_inactive (bool): Whether to include inactive teams.
        limit (int): Maximum number of results to return.
        visibility (Optional[str]): Filter by team visibility.
        db (Session): Database session dependency.
        user: Current authenticated user.

    Returns:
        JSONResponse: List of matching teams with basic info.
    """
    search_query = _normalize_search_query(q)
    team_service = TeamManagementService(db)
    user_email = get_user_email(user)

    auth_service = EmailAuthService(db)
    current_user = await auth_service.get_user_by_email(user_email)

    if not current_user:
        return []

    # Use list_teams logic
    # For admin: search globally
    # For user: search user teams (and maybe public?)
    # existing list_teams handles this via include_personal/logic?
    # list_teams handles admin vs user distinction?
    # Wait, list_teams in service doesn't know about user per se. It lists ALL teams based on query.
    # The CALLER (admin.py) distinguishes.

    if current_user.is_admin:
        # Admin sees all non-personal teams plus their own personal team (single query)
        result = await team_service.list_teams(
            page=1, per_page=limit, include_inactive=include_inactive, visibility_filter=visibility, include_personal=False, search_query=search_query, personal_owner_email=user_email
        )
        # Result is dict {data, pagination...} (since page provided)
        teams = result["data"]
    else:
        # Non-admin search
        # Reuse user team fetching
        all_teams = await team_service.get_user_teams(user_email, include_personal=True)
        # Filter in memory
        filtered = []
        for t in all_teams:
            if not include_inactive and not t.is_active:
                continue
            if visibility and t.visibility != visibility:
                continue
            if search_query:
                description_text = (t.description or "").lower()
                if search_query not in t.name.lower() and search_query not in t.slug.lower() and search_query not in description_text:
                    continue
            filtered.append(t)

        # Paginate manually
        teams = filtered[:limit]

    serialized_teams = [{"id": t.id, "name": t.name, "slug": t.slug, "description": t.description, "visibility": t.visibility, "is_active": t.is_active} for t in teams]
    return serialized_teams


@admin_router.get("/teams/partial")
@require_permission("teams.read", allow_admin_bypass=False)
async def admin_teams_partial_html(
    request: Request,
    page: int = Query(1, ge=1, description="Page number"),
    per_page: int = Query(settings.pagination_default_page_size, ge=1, le=settings.pagination_max_page_size, description="Items per page"),
    include_inactive: bool = Query(False, description="Include inactive teams"),
    visibility: QueryVisibilityCompact = None,
    render: QueryRenderModeControls = None,
    q: Optional[str] = Query(None, max_length=500, description="Search query"),
    relationship: QueryRelationship = None,
    db: Session = Depends(get_db),
    user=Depends(get_current_user_with_permissions),
) -> HTMLResponse:
    """Return HTML partial for paginated teams list (HTMX).

    Args:
        request (Request): FastAPI request object.
        page (int): Page number for pagination.
        per_page (int): Number of items per page.
        include_inactive (bool): Whether to include inactive teams.
        visibility (Optional[str]): Filter by team visibility.
        render (Optional[str]): Render mode, e.g., 'controls' for pagination controls only.
        q (Optional[str]): Search query string.
        relationship (Optional[str]): Filter by relationship: owner, member, public.
        db (Session): Database session dependency.
        user: Current authenticated user.

    Returns:
        HTMLResponse: Rendered HTML partial for teams list or pagination controls.

    """
    team_service = TeamManagementService(db)
    user_email = get_user_email(user)
    root_path = _resolve_root_path(request)

    # Base URL for pagination links - preserve search query and relationship filter
    base_url = f"{root_path}/admin/teams/partial"
    query_parts = []
    if q:
        query_parts.append(f"q={urllib.parse.quote(q, safe='')}")
    if relationship:
        query_parts.append(f"relationship={urllib.parse.quote(relationship, safe='')}")
    if query_parts:
        base_url += "?" + "&".join(query_parts)

    # Check permissions and get current user
    auth_service = EmailAuthService(db)
    current_user = await auth_service.get_user_by_email(user_email)

    if not current_user:
        return HTMLResponse(content='<div class="text-center py-8"><p class="text-red-500">User not found</p></div>', status_code=404)

    # Get user's teams and public teams for relationship info
    user_teams = await team_service.get_user_teams(user_email, include_personal=True)
    user_team_ids = {str(t.id) for t in user_teams}

    # Get user roles for owned/member distinction
    user_roles = team_service.get_user_roles_batch(user_email, list(user_team_ids))

    # Get public teams the user can join (not already a member)
    # NOTE: Limited to 500 for memory safety. Non-admin users with "public" filter
    # will only see up to 500 joinable teams. For deployments with >500 public teams,
    # consider implementing SQL-level pagination for non-admin users.
    public_teams_limit = 500
    public_teams = await team_service.discover_public_teams(user_email, limit=public_teams_limit)
    if len(public_teams) >= public_teams_limit:
        LOGGER.warning(f"Public teams discovery hit limit of {public_teams_limit} for user {user_email}. Some teams may not be visible.")

    if current_user.is_admin and not relationship:
        # Admin sees all non-personal teams plus their own personal team (single query, correct pagination)
        paginated_result = await team_service.list_teams(
            page=page, per_page=per_page, include_inactive=include_inactive, visibility_filter=visibility, base_url=base_url, include_personal=False, search_query=q, personal_owner_email=user_email
        )
        data = paginated_result["data"]
        pagination = paginated_result["pagination"]
        links = paginated_result["links"]
    else:
        # Filter by relationship or regular user view
        all_teams = []

        if relationship == "owner":
            # Only teams user owns
            all_teams = [t for t in user_teams if user_roles.get(str(t.id)) == "owner"]
        elif relationship == "member":
            # Only teams user is a member of (not owner)
            all_teams = [t for t in user_teams if user_roles.get(str(t.id)) == "member"]
        elif relationship == "public":
            # Only public teams user can join
            all_teams = list(public_teams)
        else:
            # All teams: user's teams + public teams they can join
            all_teams = list(user_teams) + list(public_teams)

        # Apply search filter
        if q:
            q_lower = q.lower()
            all_teams = [t for t in all_teams if q_lower in t.name.lower() or q_lower in (t.slug or "").lower() or q_lower in (t.description or "").lower()]

        # Apply visibility filter
        if visibility:
            all_teams = [t for t in all_teams if t.visibility == visibility]

        if not include_inactive:
            all_teams = [t for t in all_teams if t.is_active]

        total = len(all_teams)
        total_pages = math.ceil(total / per_page) if per_page else 1
        # Clamp page to valid range (matches offset_paginate behavior)
        if total_pages > 0:
            page = min(page, total_pages)
        start = (page - 1) * per_page
        end = start + per_page
        data = all_teams[start:end]

        pagination = PaginationMeta(page=page, per_page=per_page, total_items=total, total_pages=total_pages, has_next=end < total, has_prev=page > 1)
        links = None

    if render == "controls":
        # Return only pagination controls
        return request.app.state.templates.TemplateResponse(
            request,
            "pagination_controls.html",
            {
                "request": request,
                "pagination": pagination if isinstance(pagination, dict) else pagination.model_dump(),
                "links": links.model_dump() if links and not isinstance(links, dict) else links,
                "root_path": root_path,
                "hx_target": "#unified-teams-list",
                "hx_indicator": "#teams-loading",
                "query_params": {"include_inactive": include_inactive, "visibility": visibility, "q": q, "relationship": relationship},
                "base_url": base_url,
            },
        )

    if render == "selector":
        # Return team selector items for infinite scroll dropdown
        # Add member counts for display
        team_ids = [str(t.id) for t in data]
        counts = await team_service.get_member_counts_batch_cached(team_ids)
        for t in data:
            t.member_count = counts.get(str(t.id), 0)

        query_params_dict = {}
        if q:
            query_params_dict["q"] = q

        return request.app.state.templates.TemplateResponse(
            request,
            "teams_selector_items.html",
            {
                "request": request,
                "data": data,
                "pagination": pagination if isinstance(pagination, dict) else pagination.model_dump(),
                "root_path": root_path,
                "query_params": query_params_dict,
            },
        )

    # Batch count members
    team_ids = [str(t.id) for t in data]
    counts = await team_service.get_member_counts_batch_cached(team_ids)

    # Build enriched data with relationship info
    enriched_data = []
    for t in data:
        team_id = str(t.id)
        t.member_count = counts.get(team_id, 0)

        # Determine relationship
        t.relationship = "none"
        t.pending_request = None
        if t.is_personal:
            t.relationship = "personal"
        elif team_id in user_team_ids:
            role = user_roles.get(team_id)
            t.relationship = "owner" if role == "owner" else "member"
        elif getattr(t, "created_by", None) == user_email:
            # Safety net: creator should always see owner controls even if
            # membership cache lags behind team creation (Issue #3883)
            t.relationship = "owner"
        elif t.visibility == "public" and t.is_active:
            # Public teams show join button for ALL non-members (including admins)
            # This ensures platform admins go through the normal join request workflow
            # for public teams, respecting team ownership boundaries. Issue #3488
            t.relationship = "public"
        elif current_user.is_admin:
            # Admins get admin controls ONLY for non-public teams they're not members of
            # This allows emergency access to private teams for platform maintenance
            t.relationship = "none"  # Falls through to admin controls in template

        enriched_data.append(t)

    # Get pending join requests for all public teams on current page
    public_team_ids_on_page = [str(t.id) for t in enriched_data if t.relationship == "public"]
    pending_requests = team_service.get_pending_join_requests_batch(user_email, public_team_ids_on_page)
    for t in enriched_data:
        if t.relationship == "public":
            t.pending_request = pending_requests.get(str(t.id))

    # Build query params dict for pagination controls
    query_params_dict = {}
    if q:
        query_params_dict["q"] = q
    if relationship:
        query_params_dict["relationship"] = relationship
    if include_inactive:
        query_params_dict["include_inactive"] = "true"
    if visibility:
        query_params_dict["visibility"] = visibility

    response = request.app.state.templates.TemplateResponse(
        request,
        "teams_partial.html",
        {
            "request": request,
            "data": enriched_data,
            "pagination": pagination if isinstance(pagination, dict) else pagination.model_dump(),
            "links": links.model_dump() if links and not isinstance(links, dict) else links,
            "root_path": root_path,
            "query_params": query_params_dict,
        },
    )
    # Prevent nginx caching for real-time team updates
    response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
    response.headers["Pragma"] = "no-cache"
    response.headers["Expires"] = "0"
    return response


@admin_router.get("/teams")
@require_permission("teams.read", allow_admin_bypass=False)
async def admin_list_teams(
    request: Request,
    page: int = Query(1, ge=1, description="Page number"),
    per_page: int = Query(settings.pagination_default_page_size, ge=1, le=settings.pagination_max_page_size, description="Items per page"),
    q: Optional[str] = Query(None, max_length=500, description="Search query"),
    db: Session = Depends(get_db),
    user=Depends(get_current_user_with_permissions),
    unified: bool = False,
) -> HTMLResponse:
    """List teams for admin UI via HTMX.

    Args:
        request: FastAPI request object
        page: Page number
        per_page: Items per page
        q: Search query
        db: Database session
        user: Authenticated admin user
        unified: If True, return unified team view with relationship badges

    Returns:
        HTML response with teams list

    Raises:
        HTTPException: If email auth is disabled or user not found
    """
    if not getattr(settings, "email_auth_enabled", False):
        return HTMLResponse(content='<div class="text-center py-8"><p class="text-gray-500">Email authentication is disabled. Teams feature requires email auth.</p></div>', status_code=200)

    try:
        auth_service = EmailAuthService(db)
        team_service = TeamManagementService(db)

        # Get current user
        user_email = get_user_email(user)
        current_user = await auth_service.get_user_by_email(user_email)
        if not current_user:
            return HTMLResponse(content='<div class="text-center py-8"><p class="text-red-500">User not found</p></div>', status_code=200)

        root_path = _resolve_root_path(request)

        if unified:
            # Generate unified team view
            return await _generate_unified_teams_view(team_service, current_user, root_path)

        # Traditional admin view refactored to use partial logic
        # We can reuse the logic by calling the service directly or redirecting?
        # Redirection requires a round trip. Calling logic allows server-side render.
        # We'll re-use the logic by calling default params.

        # Call list_teams logic (similar to admin_teams_partial_html but inline)
        if current_user.is_admin:
            # Default first page
            base_url = f"{root_path}/admin/teams/partial"
            if q:
                base_url += f"?q={urllib.parse.quote(q, safe='')}"

            # Admin sees all non-personal teams plus their own personal team (single query, correct pagination)
            paginated_result = await team_service.list_teams(page=page, per_page=per_page, base_url=base_url, include_personal=False, search_query=q, personal_owner_email=user_email)
            data = paginated_result["data"]
            pagination = paginated_result["pagination"]
            links = paginated_result["links"]
        else:
            all_teams = await team_service.get_user_teams(current_user.email, include_personal=True)
            # Basic pagination for user view
            total = len(all_teams)
            start = (page - 1) * per_page
            end = start + per_page
            data = all_teams[start:end]
            pagination = PaginationMeta(page=page, per_page=per_page, total_items=total, total_pages=math.ceil(total / per_page) if per_page else 1, has_next=end < total, has_prev=page > 1)
            links = None

        # Batch counts
        team_ids = [str(t.id) for t in data]
        counts = await team_service.get_member_counts_batch_cached(team_ids)
        for t in data:
            t.member_count = counts.get(str(t.id), 0)

        # Render template
        return request.app.state.templates.TemplateResponse(
            request,
            "teams_partial.html",
            {
                "request": request,
                "data": data,
                "pagination": pagination if isinstance(pagination, dict) else pagination.model_dump(),
                "links": links.model_dump() if links and not isinstance(links, dict) else links,
                "root_path": root_path,
            },
        )

    except Exception as e:
        LOGGER.error(f"Error listing teams for admin {user}: {e}")
        return HTMLResponse(content=f'<div class="text-center py-8"><p class="text-red-500">Error loading teams: {html.escape(str(e))}</p></div>', status_code=200)


def _parse_form_max_members(raw: object) -> Optional[int]:
    """Parse and validate a max_members value from a form field.

    Args:
        raw: The raw form value (typically a string or None).

    Returns:
        The parsed integer, or ``None`` when the field is blank or non-numeric.

    Raises:
        ValueError: When the parsed integer is less than 1.
    """
    if raw and str(raw).strip().isdigit():
        parsed = int(str(raw).strip())
        if parsed < 1:
            raise ValueError("Maximum members must be at least 1")
        return parsed
    return None


@admin_router.post("/teams")
@require_permission("teams.create", allow_admin_bypass=False)
async def admin_create_team(
    request: Request,
    db: Session = Depends(get_db),
    user=Depends(get_current_user_with_permissions),
) -> HTMLResponse:
    """Create team via admin UI form submission.

    Args:
        request: FastAPI request object
        db: Database session
        user: Authenticated admin user

    Returns:
        HTML response with new team or error message

    Raises:
        HTTPException: If email auth is disabled or validation fails
    """
    if not getattr(settings, "email_auth_enabled", False):
        error_content = '<div class="text-red-500 p-3 bg-red-50 dark:bg-red-900/20 rounded-md">Email authentication is disabled</div>'
        response = HTMLResponse(content=error_content, status_code=403)
        return response

    if not getattr(settings, "allow_team_creation", True) and not (isinstance(user, dict) and user.get("is_admin")):
        return HTMLResponse(content='<div class="text-red-500 p-3 bg-red-50 dark:bg-red-900/20 rounded-md">Team creation is currently disabled</div>', status_code=403)

    try:
        form = await request.form()
        name = form.get("name")
        slug = form.get("slug") or None
        description = form.get("description") or None
        visibility = form.get("visibility", "private")
        max_members = _parse_form_max_members(form.get("max_members"))

        if not name:
            response = HTMLResponse(
                content='<div class="text-red-500 p-3 bg-red-50 dark:bg-red-900/20 rounded-md">Team name is required</div>',
                status_code=400,
            )
            return response

        # Create team
        # First-Party
        from mcpgateway.schemas import TeamCreateRequest  # pylint: disable=import-outside-toplevel

        team_service = TeamManagementService(db)

        team_data = TeamCreateRequest(name=name, slug=slug, description=description, visibility=visibility, max_members=max_members)

        # Extract user email from user dict
        user_email = get_user_email(user)

        is_admin = isinstance(user, dict) and user.get("is_admin")
        await team_service.create_team(
            name=team_data.name, description=team_data.description, created_by=user_email, visibility=team_data.visibility, max_members=team_data.max_members, skip_limits=bool(is_admin)
        )

        response = HTMLResponse(content="", status_code=201)
        return response

    except (ValidationError, CoreValidationError) as e:
        LOGGER.warning(f"Validation error creating team: {e}")
        # Extract user-friendly error message from Pydantic validation error
        error_messages = []
        for error in e.errors():
            msg = error.get("msg", "Invalid value")
            # Clean up common Pydantic prefixes
            if msg.startswith("Value error, "):
                msg = msg[13:]
            error_messages.append(f"{msg}")
        error_text = "; ".join(error_messages) if error_messages else "Invalid input"
        response = HTMLResponse(
            content=f'<div class="text-red-500 p-3 bg-red-50 dark:bg-red-900/20 rounded-md">{html.escape(error_text)}</div>',
            status_code=400,
        )
        return response
    except ValueError as e:
        LOGGER.warning(f"Validation error creating team: {e}")
        return HTMLResponse(
            content=f'<div class="text-red-500 p-3 bg-red-50 dark:bg-red-900/20 rounded-md">{html.escape(str(e))}</div>',
            status_code=400,
        )
    except IntegrityError as e:
        LOGGER.error(f"Error creating team for admin {user}: {e}")
        if "UNIQUE constraint failed: email_teams.slug" in str(e):
            error_content = '<div class="text-red-500 p-3 bg-red-50 dark:bg-red-900/20 rounded-md">A team with this name already exists. Please choose a different name.</div>'
        else:
            error_content = f'<div class="text-red-500 p-3 bg-red-50 dark:bg-red-900/20 rounded-md">Database error: {html.escape(str(e))}</div>'
        response = HTMLResponse(content=error_content, status_code=400)
        return response
    except Exception as e:
        LOGGER.error(f"Error creating team for admin {user}: {e}")
        response = HTMLResponse(
            content=f'<div class="text-red-500 p-3 bg-red-50 dark:bg-red-900/20 rounded-md">Error creating team: {html.escape(str(e))}</div>',
            status_code=400,
        )
        return response


@admin_router.get("/teams/{team_id}/members")
@require_permission("teams.read", allow_admin_bypass=False)
async def admin_view_team_members(
    team_id: str,
    request: Request,
    page: int = Query(1, ge=1, description="Page number (1-indexed)"),
    per_page: int = Query(settings.pagination_default_page_size, ge=1, le=settings.pagination_max_page_size, description="Items per page"),
    db: Session = Depends(get_db),
    user=Depends(get_current_user_with_permissions),
) -> HTMLResponse:
    """View and manage team members via admin UI (unified view).

    This replaces the old separate "view members" and "add members" screens with a unified
    interface that shows all users with checkboxes. Members are pre-checked and can be
    unchecked to remove them. Non-members can be checked to add them.

    Args:
        team_id: ID of the team to view members for
        request: FastAPI request object
        page: Page number (1-indexed).
        per_page: Items per page.
        db: Database session
        user: Current authenticated user context

    Returns:
        HTMLResponse: Rendered unified team members management view
    """
    if not settings.email_auth_enabled:
        response = HTMLResponse(
            content='<div class="text-red-500 p-3 bg-red-50 dark:bg-red-900/20 rounded-md mb-4">Email authentication is disabled</div>',
            status_code=403,
        )
        response.headers["HX-Retarget"] = "#edit-team-error"
        response.headers["HX-Reswap"] = "innerHTML"
        return response

    try:
        # Get root_path from request
        root_path = _resolve_root_path(request)

        # Get current user context for logging and authorization
        user_email = get_user_email(user)
        LOGGER.info(f"User {user_email} viewing/managing members for team {team_id}")

        # First-Party
        team_service = TeamManagementService(db)
        EmailAuthService(db)

        # Get team details
        team = await team_service.get_team_by_id(team_id)
        if not team:
            return HTMLResponse(content='<div class="text-red-500">Team not found</div>', status_code=404)

        # Check if current user is team owner
        current_user_role = await team_service.get_user_role_in_team(user_email, team_id)
        is_team_owner = current_user_role == "owner"

        # Escape team name to prevent XSS
        safe_team_name = html.escape(team.name)

        # Build the two-section management interface with form
        interface_html = f"""
        <div class="mb-4">
            <div class="flex justify-between items-center mb-4">
                <h3 class="text-lg font-medium text-gray-900 dark:text-white">
                    Team Members: {safe_team_name}
                </h3>
                <button onclick="document.getElementById('team-edit-modal').classList.add('hidden')"
                        class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
                    <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                        <path stroke-linecap="round" stroke-linejoin="round"
                            stroke-width="2" d="M6 18L18 6M6 6l12 12" />
                    </svg>
                </button>
            </div>

            <div class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
                <div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900">
                    <h4 class="text-sm font-semibold text-gray-900 dark:text-white">
                        Manage Team Members • Change roles • Add or remove members
                    </h4>
                </div>

                <form id="team-members-form-{team.id}" data-team-id="{team.id}"
                      hx-post="{root_path}/admin/teams/{team.id}/add-member"
                      hx-target="#team-edit-modal-content"
                      hx-swap="innerHTML"
                      class="px-6 py-4">

                    <!-- Current Members Section -->
                    <div class="mb-6">
                        <div class="flex items-center justify-between mb-2">
                            <h5 class="text-sm font-medium text-gray-700 dark:text-gray-300">Current Members</h5>
                            <input
                                type="text"
                                id="member-search-{team.id}"
                                placeholder="Search members..."
                                class="w-48 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded-md dark:bg-gray-700 dark:text-white"
                                oninput="Admin.debouncedMemberSearch('{team.id}', this.value)"
                            />
                        </div>
                        <div
                            id="team-members-container-{team.id}"
                            class="border border-gray-300 dark:border-gray-600 rounded-md p-3 max-h-64 overflow-y-auto dark:bg-gray-700"
                            data-per-page="{per_page}"
                            hx-get="{root_path}/admin/teams/{team.id}/members/partial?page={page}&per_page={per_page}"
                            hx-trigger="load delay:100ms"
                            hx-target="this"
                            hx-swap="innerHTML"
                        >
                            <!-- Current members will be loaded here via HTMX -->
                        </div>
                    </div>

                    <!-- Add Users Section -->
                    <div class="mb-4">
                        <div class="flex items-center justify-between mb-2">
                            <h5 class="text-sm font-medium text-gray-700 dark:text-gray-300">Add Users</h5>
                            <input
                                type="text"
                                id="non-member-search-{team.id}"
                                placeholder="Search users by name or email..."
                                class="w-64 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded-md dark:bg-gray-700 dark:text-white"
                                oninput="Admin.debouncedNonMemberSearch('{team.id}', this.value)"
                            />
                        </div>
                        <div
                            id="team-non-members-container-{team.id}"
                            class="border border-gray-300 dark:border-gray-600 rounded-md p-3 max-h-64 overflow-y-auto dark:bg-gray-700"
                            data-per-page="50"
                        >
                            <div class="text-center py-4 text-gray-500 dark:text-gray-400">Search for users by name or email to add them to this team.</div>
                        </div>
                    </div>

                    <!-- Submit button (only for team owners) -->
                    {
            ""
            if not is_team_owner
            else '''
                    <div class="flex justify-end space-x-3 pt-4 border-t border-gray-200 dark:border-gray-700">
                        <button type="submit"
                                class="px-4 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
                            Save Changes
                        </button>
                    </div>
                    '''
        }
                </form>
            </div>
        </div>
        """  # nosec B608 - HTML template f-string, not SQL (uses SQLAlchemy ORM for DB)

        response = HTMLResponse(content=interface_html)
        # Prevent nginx caching for real-time team member updates
        response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
        response.headers["Pragma"] = "no-cache"
        response.headers["Expires"] = "0"
        return response

    except Exception as e:
        LOGGER.error(f"Error viewing team members {team_id}: {e}")
        return HTMLResponse(content=f'<div class="text-red-500">Error loading members: {html.escape(str(e))}</div>', status_code=500)


@admin_router.get("/teams/{team_id}/members/add")
@require_permission("teams.manage_members", allow_admin_bypass=False)
async def admin_add_team_members_view(
    team_id: str,
    request: Request,
    db: Session = Depends(get_db),
    user=Depends(get_current_user_with_permissions),
) -> HTMLResponse:
    """Show add members interface with paginated user selector.

    Args:
        team_id: ID of the team to add members to
        request: FastAPI request object
        db: Database session
        user: Current authenticated user context

    Returns:
        HTMLResponse: Rendered add members interface
    """
    if not settings.email_auth_enabled:
        return HTMLResponse(content='<div class="text-red-500">Email authentication is disabled</div>', status_code=403)

    try:
        # Get root_path from request
        root_path = _resolve_root_path(request)

        # Get current user context for logging and authorization
        user_email = get_user_email(user)
        LOGGER.info(f"User {user_email} adding members to team {team_id}")

        # First-Party
        team_service = TeamManagementService(db)

        # Get team details
        team = await team_service.get_team_by_id(team_id)
        if not team:
            return HTMLResponse(content='<div class="text-red-500">Team not found</div>', status_code=404)

        # Check if current user is team owner
        current_user_role = await team_service.get_user_role_in_team(user_email, team_id)
        if current_user_role != "owner":
            return HTMLResponse(content='<div class="text-red-500">Only team owners can add members</div>', status_code=403)

        # Get current team members to exclude from selection
        team_members = await team_service.get_team_members(team_id)
        member_emails = {team_user.email for team_user, membership in team_members}
        # Use orjson to safely serialize the list for JavaScript consumption (prevents XSS/injection)
        member_emails_json = orjson.dumps(list(member_emails)).decode()  # nosec B105 - JSON array of emails, not password

        # Escape team name to prevent XSS
        safe_team_name = html.escape(team.name)

        # Build add members interface with paginated user selector
        add_members_html = f"""
        <div class="mb-4">
            <div class="flex justify-between items-center mb-4">
                <h3 class="text-lg font-medium text-gray-900 dark:text-white">Add Members to: {safe_team_name}</h3>
                <div class="flex items-center space-x-2">
                    <button onclick="loadTeamMembersView('{team.id}')" class="px-3 py-1 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700">
                        ← Back to Members
                    </button>
                    <button onclick="document.getElementById('team-edit-modal').classList.add('hidden')" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
                        <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                            <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
                        </svg>
                    </button>
                </div>
            </div>

            <div class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
                <div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900">
                    <h4 class="text-sm font-semibold text-gray-900 dark:text-white">Select Users to Add</h4>
                </div>

                <div class="px-6 py-4">
                    <form id="add-members-form-{team.id}" data-team-id="{team.id}" hx-post="{root_path}/admin/teams/{team.id}/add-member" hx-target="#team-edit-modal-content" hx-swap="innerHTML">
                        <!-- Search box -->
                        <div class="mb-4">
                            <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Search Users</label>
                            <input
                                type="text"
                                id="user-search-{team.id}"
                                data-team-id="{team.id}"
                                data-search-url="{root_path}/admin/users/search"
                                data-search-limit="10"
                                placeholder="Search by name or email..."
                                class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 text-gray-900 dark:text-white"
                                autocomplete="off"
                            />
                            <div id="user-search-loading-{team.id}" class="mt-2 text-sm text-gray-500 dark:text-gray-400 hidden">Searching...</div>
                            <div id="user-search-results-{team.id}" data-member-emails="{html.escape(member_emails_json)}" class="mt-2"></div>
                        </div>

                        <!-- User selector with infinite scroll -->
                        <div class="mb-4">
                            <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Available Users</label>
                            <div
                                id="user-selector-container-{team.id}"
                                class="border border-gray-300 dark:border-gray-600 rounded-md p-3 max-h-64 overflow-y-auto dark:bg-gray-700"
                                hx-get="{root_path}/admin/users/partial?page=1&per_page=20&render=selector&team_id={team.id}"
                                hx-trigger="load"
                                hx-swap="innerHTML"
                                hx-target="#user-selector-container-{team.id}"
                            >
                                <!-- User selector items will be loaded here via HTMX -->
                            </div>
                            <p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
                                Note: Users already in the team will be ignored if selected.
                            </p>
                        </div>

                        <!-- Action buttons -->
                        <div class="flex justify-between items-center">
                            <div id="selected-count-{team.id}" class="text-sm text-gray-600 dark:text-gray-400">
                                No users selected
                            </div>
                            <button
                                type="submit"
                                class="px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-md shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors duration-200"
                            >
                                Add Selected Members
                            </button>
                        </div>
                    </form>
                </div>
            </div>
        </div>
        """  # nosec B608 - HTML template f-string, not SQL (uses SQLAlchemy ORM for DB)

        return HTMLResponse(content=add_members_html)

    except Exception as e:
        LOGGER.error(f"Error loading add members view for team {team_id}: {e}")
        return HTMLResponse(content=f'<div class="text-red-500">Error loading add members view: {html.escape(str(e))}</div>', status_code=500)


@admin_router.get("/teams/{team_id}/edit")
@require_permission("teams.update", allow_admin_bypass=False)
async def admin_get_team_edit(
    team_id: str,
    _request: Request,
    db: Session = Depends(get_db),
    _user=Depends(get_current_user_with_permissions),
) -> HTMLResponse:
    """Get team edit form via admin UI.

    Args:
        team_id: ID of the team to edit
        db: Database session

    Returns:
        HTMLResponse: Rendered team edit form
    """
    if not settings.email_auth_enabled:
        return HTMLResponse(content='<div class="text-red-500">Email authentication is disabled</div>', status_code=403)

    try:
        # Get root path for URL construction
        root_path = _resolve_root_path(_request) if _request else ""
        team_service = TeamManagementService(db)

        team = await team_service.get_team_by_id(team_id)
        if not team:
            return HTMLResponse(content='<div class="text-red-500">Team not found</div>', status_code=404)

        # Personal teams cannot be updated (service rejects all personal team updates)
        if team.is_personal:
            return HTMLResponse(content='<div class="text-red-500">Personal teams cannot be edited</div>', status_code=403)

        safe_team_name = html.escape(team.name, quote=True)
        safe_description = html.escape(team.description or "")
        is_admin_edit = isinstance(_user, dict) and _user.get("is_admin")
        max_members_limit = settings.max_members_per_team
        current_exceeds_limit = bool(team.max_members is not None and team.max_members > max_members_limit)
        max_attr = "" if is_admin_edit else f'max="{max_members_limit}"'
        use_default_checked = "checked" if team.max_members is None else ""
        max_members_disabled = "disabled" if team.max_members is None else ""
        # When the existing value exceeds the configured limit for a non-admin,
        # show an empty field to avoid browser validation blocking form submission.
        # Submitting empty preserves the current value server-side.
        max_members_value: Union[str, int] = ""
        if team.max_members is None:
            max_members_value = ""
            max_members_hint = f"Currently using global default ({max_members_limit})."
        elif is_admin_edit:
            max_members_value = team.max_members
            max_members_hint = "Admins can set any limit."
        elif current_exceeds_limit:
            max_members_value = ""
            max_members_hint = f"Current: {team.max_members} (above max {max_members_limit}). Leave empty to keep, or set a new value \u2264 {max_members_limit}."
        else:
            max_members_value = team.max_members
            max_members_hint = f"Max {max_members_limit}."
        edit_form = rf"""
        <div class="space-y-4">
            <h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Edit Team</h3>
            <div id="edit-team-error"></div>
            <form method="post" action="{root_path}/admin/teams/{team_id}/update" hx-post="{root_path}/admin/teams/{team_id}/update" hx-target="#edit-team-error" hx-swap="innerHTML" class="space-y-4" data-team-validation="true">
                <div>
                    <label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Name</label>
                    <input type="text" name="name" value="{safe_team_name}" required
                           class="mt-1 block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 dark:bg-gray-700 text-gray-900 dark:text-white">
                    <p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Letters, numbers, spaces, underscores, periods, and dashes only</p>
                </div>
                <div>
                    <label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Slug</label>
                    <input type="text" name="slug" value="{team.slug}" readonly
                           class="mt-1 block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-white">
                    <p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Slug cannot be changed</p>
                </div>
                <div>
                    <label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Description</label>
                    <textarea name="description" rows="3"
                              class="mt-1 block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 dark:bg-gray-700 text-gray-900 dark:text-white">{safe_description}</textarea>
                </div>
                <div>
                    <label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Visibility</label>
                    <select name="visibility"
                            class="mt-1 block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 dark:bg-gray-700 text-gray-900 dark:text-white">
                        <option value="private" {"selected" if team.visibility == "private" else ""}>Private</option>
                        <option value="public" {"selected" if team.visibility == "public" else ""}>Public</option>
                    </select>
                </div>
                <div>
                    <label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Maximum Members</label>
                    <div class="flex items-center mt-1 mb-2">
                        <input type="checkbox" name="use_default_max_members" id="use-default-max-members" {use_default_checked}
                               onchange="var mi = this.closest('div').parentElement.querySelector('input[name=max_members]'); mi.disabled = this.checked; if(this.checked) mi.value = '';"
                               class="h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded">
                        <label for="use-default-max-members" class="ml-2 text-sm text-gray-700 dark:text-gray-300">Use global default ({max_members_limit})</label>
                    </div>
                    <input type="number" name="max_members" min="1" {max_attr} value="{max_members_value}" {max_members_disabled}
                           class="mt-1 block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 dark:bg-gray-700 text-gray-900 dark:text-white">
                    <p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{max_members_hint}</p>
                </div>
                <div class="flex justify-end space-x-3">
                    <button type="button" onclick="Admin.hideTeamEditModal()"
                            class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700">
                        Cancel
                    </button>
                    <button type="submit"
                            class="px-4 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
                        Update Team
                    </button>
                </div>
            </form>
        </div>
        """
        return HTMLResponse(content=edit_form)

    except Exception as e:
        LOGGER.error(f"Error getting team edit form for {team_id}: {e}")
        return HTMLResponse(content=f'<div class="text-red-500">Error loading team: {html.escape(str(e))}</div>', status_code=500)


@admin_router.post("/teams/{team_id}/update")
@require_permission("teams.update", allow_admin_bypass=False)
async def admin_update_team(
    team_id: str,
    request: Request,
    db: Session = Depends(get_db),
    user=Depends(get_current_user_with_permissions),
) -> Response:
    """Update team via admin UI.

    Args:
        team_id: ID of the team to update
        request: FastAPI request object
        db: Database session
        user: Current authenticated user context

    Returns:
        Response: Result of team update operation
    """
    # Ensure root_path is available for URL construction in all branches
    root_path = _resolve_root_path(request) if request else ""

    if not settings.email_auth_enabled:
        return HTMLResponse(content='<div class="text-red-500">Email authentication is disabled</div>', status_code=403)

    try:
        team_service = TeamManagementService(db)

        form = await request.form()
        name_val = form.get("name")
        desc_val = form.get("description")
        vis_val = form.get("visibility", "private")
        use_default_max_members = form.get("use_default_max_members")
        # Trim before presence check for consistent error messages
        name = name_val.strip() if isinstance(name_val, str) else None
        description = desc_val.strip() if isinstance(desc_val, str) and desc_val.strip() != "" else None
        visibility = vis_val if isinstance(vis_val, str) else "private"
        max_members = _parse_form_max_members(form.get("max_members"))

        if not name:
            is_htmx = request.headers.get("HX-Request") == "true"
            if is_htmx:
                response = HTMLResponse(
                    content='<div class="text-red-500 p-3 bg-red-50 dark:bg-red-900/20 rounded-md mb-4">Team name is required</div>',
                    status_code=400,
                )
                response.headers["HX-Retarget"] = "#edit-team-error"
                response.headers["HX-Reswap"] = "innerHTML"
                return response
            error_msg = urllib.parse.quote("Team name is required")
            return RedirectResponse(url=f"{root_path}/admin/?error={error_msg}#teams", status_code=303)

        # Validate name and description for XSS (same validation as schema)
        if not re.match(settings.validation_name_pattern, name):
            is_htmx = request.headers.get("HX-Request") == "true"
            if is_htmx:
                response = HTMLResponse(
                    content='<div class="text-red-500 p-3 bg-red-50 dark:bg-red-900/20 rounded-md mb-4">Team name can only contain letters, numbers, spaces, underscores, periods, and dashes</div>',
                    status_code=400,
                )
                response.headers["HX-Retarget"] = "#edit-team-error"
                response.headers["HX-Reswap"] = "innerHTML"
                return response
            error_msg = urllib.parse.quote("Team name contains invalid characters")
            return RedirectResponse(url=f"{root_path}/admin/?error={error_msg}#teams", status_code=303)

        try:
            SecurityValidator.validate_no_xss(name, "Team name")
            if re.search(SecurityValidator.DANGEROUS_JS_PATTERN, name, re.IGNORECASE):
                raise ValueError("Team name contains script patterns that may cause security issues")
            if description:
                SecurityValidator.validate_no_xss(description, "Team description")
                if re.search(SecurityValidator.DANGEROUS_JS_PATTERN, description, re.IGNORECASE):
                    raise ValueError("Team description contains script patterns that may cause security issues")
        except ValueError as ve:
            is_htmx = request.headers.get("HX-Request") == "true"
            if is_htmx:
                response = HTMLResponse(
                    content=f'<div class="text-red-500 p-3 bg-red-50 dark:bg-red-900/20 rounded-md mb-4">{html.escape(str(ve))}</div>',
                    status_code=400,
                )
                response.headers["HX-Retarget"] = "#edit-team-error"
                response.headers["HX-Reswap"] = "innerHTML"
                return response
            error_msg = urllib.parse.quote(str(ve))
            return RedirectResponse(url=f"{root_path}/admin/?error={error_msg}#teams", status_code=303)

        # Update team
        user_email = getattr(user, "email", None) or str(user)
        is_admin = isinstance(user, dict) and user.get("is_admin")
        # Three-way max_members resolution:
        #   checkbox checked  → None  (clear per-team override, revert to global default)
        #   number provided   → int   (set explicit per-team limit)
        #   neither           → UNSET (leave current value unchanged)
        if use_default_max_members:
            max_members_kwarg = None
        elif max_members is not None:
            max_members_kwarg = max_members
        else:
            max_members_kwarg = UNSET
        updated = await team_service.update_team(
            team_id=team_id, name=name, description=description, visibility=visibility, max_members=max_members_kwarg, updated_by=user_email, skip_limits=bool(is_admin)
        )

        if not updated:
            is_htmx = request.headers.get("HX-Request") == "true"
            error_html = '<div class="text-red-500 p-3 bg-red-50 dark:bg-red-900/20 rounded-md mb-4">Team cannot be updated</div>'
            if is_htmx:
                response = HTMLResponse(content=error_html, status_code=400)
                response.headers["HX-Retarget"] = "#edit-team-error"
                response.headers["HX-Reswap"] = "innerHTML"
                return response
            error_msg = urllib.parse.quote("Team cannot be updated")
            return RedirectResponse(url=f"{root_path}/admin/?error={error_msg}#teams", status_code=303)

        # Check if this is an HTMX request
        is_htmx = request.headers.get("HX-Request") == "true"

        if is_htmx:
            # Return success message with auto-close and refresh for HTMX
            success_html = """
            <div class="text-green-500 text-center p-4">
                <p>Team updated successfully</p>
            </div>
            """
            response = HTMLResponse(content=success_html)
            response.headers["HX-Trigger"] = orjson.dumps({"adminTeamAction": {"closeTeamEditModal": True, "refreshUnifiedTeamsList": True, "delayMs": 1500}}).decode()
            return response
        # For regular form submission, redirect to admin page with teams section
        return RedirectResponse(url=f"{root_path}/admin/#teams", status_code=303)

    except ValueError as e:
        # Rollback to discard any partial mutations (e.g. name/description set before max_members check failed)
        db.rollback()
        LOGGER.warning(f"Validation error updating team {team_id}: {e}")
        is_htmx = request.headers.get("HX-Request") == "true"
        if is_htmx:
            response = HTMLResponse(content=f'<div class="text-red-500 p-3 bg-red-50 dark:bg-red-900/20 rounded-md mb-4">{html.escape(str(e))}</div>', status_code=400)
            response.headers["HX-Retarget"] = "#edit-team-error"
            response.headers["HX-Reswap"] = "innerHTML"
            return response
        error_msg = urllib.parse.quote(str(e))
        return RedirectResponse(url=f"{root_path}/admin/?error={error_msg}#teams", status_code=303)
    except Exception as e:
        db.rollback()
        LOGGER.error(f"Error updating team {team_id}: {e}")

        # Check if this is an HTMX request for error handling too
        is_htmx = request.headers.get("HX-Request") == "true"

        if is_htmx:
            return HTMLResponse(content=f'<div class="text-red-500">Error updating team: {html.escape(str(e))}</div>', status_code=400)
        # For regular form submission, redirect to admin page with error parameter
        error_msg = urllib.parse.quote(f"Error updating team: {str(e)}")
        return RedirectResponse(url=f"{root_path}/admin/?error={error_msg}#teams", status_code=303)


@admin_router.delete("/teams/{team_id}")
@require_permission("teams.delete", allow_admin_bypass=False)
async def admin_delete_team(
    team_id: str,
    _request: Request,
    db: Session = Depends(get_db),
    user=Depends(get_current_user_with_permissions),
) -> HTMLResponse:
    """Delete team via admin UI.

    Args:
        team_id: ID of the team to delete
        db: Database session
        user: Current authenticated user context

    Returns:
        HTMLResponse: Success message or error response
    """
    if not settings.email_auth_enabled:
        return HTMLResponse(content='<div class="text-red-500">Email authentication is disabled</div>', status_code=403)

    try:
        team_service = TeamManagementService(db)

        # Get team name for success message
        team = await team_service.get_team_by_id(team_id)
        team_name = team.name if team else "Unknown"

        # Delete team (get user email from JWT payload)
        user_email = get_user_email(user)
        deleted = await team_service.delete_team(team_id, deleted_by=user_email)

        if not deleted:
            return HTMLResponse(content='<div class="text-red-500">Team cannot be deleted</div>', status_code=400)

        # Return success message with script to refresh teams list
        safe_team_name = html.escape(team_name)
        success_html = f"""
        <div class="text-green-500 text-center p-4">
            <p>Team "{safe_team_name}" deleted successfully</p>
        </div>
        """
        response = HTMLResponse(content=success_html)
        # Prevent nginx caching for real-time updates
        response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
        response.headers["Pragma"] = "no-cache"
        response.headers["Expires"] = "0"
        response.headers["HX-Trigger"] = orjson.dumps({"adminTeamAction": {"refreshUnifiedTeamsList": True, "delayMs": 1000}}).decode()
        return response

    except Exception as e:
        LOGGER.error(f"Error deleting team {team_id}: {e}")
        return HTMLResponse(content=f'<div class="text-red-500">Error deleting team: {html.escape(str(e))}</div>', status_code=400)


@admin_router.post("/teams/{team_id}/add-member")
@require_permission("teams.manage_members", allow_admin_bypass=False)
async def admin_add_team_members(
    team_id: str,
    request: Request,
    db: Session = Depends(get_db),
    user=Depends(get_current_user_with_permissions),
) -> HTMLResponse:
    """Add member(s) to team via admin UI.

    Supports both single user (user_email field) and multiple users (associatedUsers field).

    Args:
        team_id: ID of the team to add member(s) to
        request: FastAPI request object
        db: Database session
        user: Current authenticated user context

    Returns:
        HTMLResponse: Success message or error response
    """
    if not settings.email_auth_enabled:
        return HTMLResponse(content='<div class="text-red-500">Email authentication is disabled</div>', status_code=403)

    try:
        # First-Party
        team_service = TeamManagementService(db)
        auth_service = EmailAuthService(db)

        # Check if team exists and validate visibility
        team = await team_service.get_team_by_id(team_id)
        if not team:
            return HTMLResponse(content='<div class="text-red-500">Team not found</div>', status_code=404)

        # For private teams, only team owners can add members directly
        user_email_from_jwt = get_user_email(user)
        if team.visibility == "private":
            user_role = await team_service.get_user_role_in_team(user_email_from_jwt, team_id)
            if user_role != "owner":
                return HTMLResponse(content='<div class="text-red-500">Only team owners can add members to private teams. Use the invitation system instead.</div>', status_code=403)

        form = await request.form()

        # Get loaded members - these are members that were visible in the form (for safe removal with pagination)
        loaded_members_list = form.getlist("loadedMembers")
        loaded_members = {email.strip() for email in loaded_members_list if isinstance(email, str) and email.strip()}

        # Check if this is single user or multiple users
        single_user_email = form.get("user_email")
        multiple_user_emails = form.getlist("associatedUsers")

        # Determine which mode we're in
        if single_user_email:
            # Single user mode (legacy form) - get single role
            user_emails = [single_user_email] if isinstance(single_user_email, str) else []
            default_role = form.get("role", "member")
            default_role = default_role if isinstance(default_role, str) else "member"
        elif multiple_user_emails:
            # Multiple users mode (new paginated selector)
            seen = set()
            user_emails = []
            for email in multiple_user_emails:
                if not isinstance(email, str):
                    continue
                cleaned = email.strip()
                if not cleaned or cleaned in seen:
                    continue
                seen.add(cleaned)
                user_emails.append(cleaned)
            default_role = "member"  # Default if no per-user role specified
        else:
            return HTMLResponse(content='<div class="text-red-500">No users selected</div>', status_code=400)

        # Get current team members
        team_members = await team_service.get_team_members(team_id)
        existing_member_emails = {team_user.email for team_user, membership in team_members}

        # Build a map of existing member roles
        existing_member_roles = {}
        owner_count = team_service.count_team_owners(team_id)
        for team_user, membership in team_members:
            email = team_user.email
            is_last_owner = membership.role == "owner" and owner_count == 1
            existing_member_roles[email] = {"role": membership.role, "is_last_owner": is_last_owner}

        # Track results
        added = []
        updated = []
        removed = []
        errors = []

        # Process submitted users (checked boxes)
        submitted_user_emails = set(user_emails)

        # 1. Handle additions and updates for checked users
        for user_email in user_emails:
            user_email = user_email.strip()
            if not user_email:
                continue

            try:
                # Check if user exists
                target_user = await auth_service.get_user_by_email(user_email)
                if not target_user:
                    errors.append(f"{user_email} (user not found)")
                    continue

                # Get per-user role from form (format: role_<url-encoded-email>)
                encoded_email = urllib.parse.quote(user_email, safe="")
                user_role_key = f"role_{encoded_email}"
                user_role_val = form.get(user_role_key, default_role)
                user_role = user_role_val if isinstance(user_role_val, str) else default_role

                if user_email in existing_member_emails:
                    # User is already a member - check if role changed
                    current_role = existing_member_roles[user_email]["role"]
                    if current_role != user_role:
                        # Don't allow changing role of last owner
                        if existing_member_roles[user_email]["is_last_owner"]:
                            errors.append(f"{user_email} (cannot change role of last owner)")
                            continue
                        # Update role
                        await team_service.update_member_role(team_id=team_id, user_email=user_email, new_role=user_role, updated_by=user_email_from_jwt)
                        updated.append(f"{user_email} (role: {user_role})")
                else:
                    # New member - add them
                    await team_service.add_member_to_team(team_id=team_id, user_email=user_email, role=user_role, invited_by=user_email_from_jwt)
                    added.append(user_email)

            except Exception as member_error:
                LOGGER.error(f"Error processing {user_email} for team {team_id}: {member_error}")
                errors.append(f"{user_email} ({str(member_error)})")

        # 2. Handle removals - only remove members who were LOADED in the form AND unchecked
        # This prevents accidentally removing members from pages that weren't loaded yet (infinite scroll safety)
        for existing_email in existing_member_emails:
            # Only consider removal if the member was visible in the form (in loadedMembers)
            if existing_email not in loaded_members:
                continue  # Member wasn't loaded in form, skip (safe for pagination)
            if existing_email in submitted_user_emails:
                continue  # Member is checked, don't remove

            member_info = existing_member_roles.get(existing_email, {})

            # Validate removal is allowed - server-side protection
            # Current user cannot be removed
            if existing_email == user_email_from_jwt:
                errors.append(f"{existing_email} (cannot remove yourself)")
                continue
            # Last owner cannot be removed
            if member_info.get("is_last_owner", False):
                errors.append(f"{existing_email} (cannot remove last owner)")
                continue

            # This member was unchecked and removal is allowed - remove them
            try:
                await team_service.remove_member_from_team(team_id=team_id, user_email=existing_email, removed_by=user_email_from_jwt)
                removed.append(existing_email)
            except Exception as removal_error:
                LOGGER.error(f"Error removing {existing_email} from team {team_id}: {removal_error}")
                errors.append(f"{existing_email} (removal failed: {str(removal_error)})")

        # Build result message
        result_parts = []
        if added:
            result_parts.append(f'<p class="text-green-600 dark:text-green-400">✓ Added {len(added)} member(s)</p>')
        if updated:
            result_parts.append(f'<p class="text-blue-600 dark:text-blue-400">↻ Updated {len(updated)} member(s)</p>')
        if removed:
            result_parts.append(f'<p class="text-orange-600 dark:text-orange-400">− Removed {len(removed)} member(s)</p>')
        if errors:
            result_parts.append(f'<p class="text-red-600 dark:text-red-400">✗ {len(errors)} error(s)</p>')
            for error in errors[:5]:  # Show first 5 errors
                result_parts.append(f'<p class="text-xs text-red-500 dark:text-red-400 ml-4">• {error}</p>')
            if len(errors) > 5:
                result_parts.append(f'<p class="text-xs text-red-500 dark:text-red-400 ml-4">... and {len(errors) - 5} more</p>')

        if not result_parts:
            result_parts.append('<p class="text-gray-600 dark:text-gray-400">No changes made</p>')

        result_html = "\n".join(result_parts)

        # Return success message and close modal
        success_html = f"""
        <div class="text-center p-4">
            {result_html}
        </div>
        <script>
            // Close modal after showing success message briefly
            setTimeout(() => {{
                const modal = document.getElementById('team-edit-modal');
                if (modal) {{
                    modal.classList.add('hidden');
                }}
            }}, 1000);
        </script>
        """
        response = HTMLResponse(content=success_html)

        # Prevent nginx caching for real-time updates
        response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
        response.headers["Pragma"] = "no-cache"
        response.headers["Expires"] = "0"

        # Trigger refresh of teams list (but don't reopen modal)
        response.headers["HX-Trigger"] = orjson.dumps(
            {
                "adminTeamAction": {
                    "teamId": team_id,
                    "refreshUnifiedTeamsList": True,
                }
            }
        ).decode()
        return response

    except Exception as e:
        LOGGER.error(f"Error adding member(s) to team {team_id}: {e}")
        return HTMLResponse(content=f'<div class="text-red-500">Error adding member(s): {html.escape(str(e))}</div>', status_code=400)


@admin_router.post("/teams/{team_id}/update-member-role")
@require_permission("teams.manage_members", allow_admin_bypass=False)
async def admin_update_team_member_role(
    team_id: str,
    request: Request,
    db: Session = Depends(get_db),
    user=Depends(get_current_user_with_permissions),
) -> HTMLResponse:
    """Update team member role via admin UI.

    Args:
        team_id: ID of the team containing the member
        request: FastAPI request object
        db: Database session
        user: Current authenticated user context

    Returns:
        HTMLResponse: Success message or error response
    """
    if not settings.email_auth_enabled:
        return HTMLResponse(content='<div class="text-red-500">Email authentication is disabled</div>', status_code=403)

    try:
        team_service = TeamManagementService(db)

        # Check if team exists and validate user permissions
        team = await team_service.get_team_by_id(team_id)
        if not team:
            return HTMLResponse(content='<div class="text-red-500">Team not found</div>', status_code=404)

        # Only team owners can modify member roles
        user_email_from_jwt = get_user_email(user)
        user_role = await team_service.get_user_role_in_team(user_email_from_jwt, team_id)
        if user_role != "owner":
            return HTMLResponse(content='<div class="text-red-500">Only team owners can modify member roles</div>', status_code=403)

        form = await request.form()
        ue_val = form.get("user_email")
        nr_val = form.get("role", "member")
        user_email = ue_val if isinstance(ue_val, str) else None
        new_role = nr_val if isinstance(nr_val, str) else "member"

        if not user_email:
            return HTMLResponse(content='<div class="text-red-500">User email is required</div>', status_code=400)

        if not new_role:
            return HTMLResponse(content='<div class="text-red-500">Role is required</div>', status_code=400)

        # Update member role
        await team_service.update_member_role(team_id=team_id, user_email=user_email, new_role=new_role, updated_by=user_email_from_jwt)

        # Return success message with auto-close and refresh
        success_html = f"""
        <div class="text-green-500 text-center p-4">
            <p>Role updated successfully for {user_email}</p>
        </div>
        """
        response = HTMLResponse(content=success_html)
        response.headers["HX-Trigger"] = orjson.dumps(
            {
                "adminTeamAction": {
                    "teamId": team_id,
                    "refreshTeamMembers": True,
                    "refreshUnifiedTeamsList": True,
                    "closeRoleModal": True,
                    "delayMs": 1000,
                }
            }
        ).decode()
        return response

    except Exception as e:
        LOGGER.error(f"Error updating member role in team {team_id}: {e}")
        return HTMLResponse(content=f'<div class="text-red-500">Error updating role: {html.escape(str(e))}</div>', status_code=400)


@admin_router.post("/teams/{team_id}/remove-member")
@require_permission("teams.manage_members", allow_admin_bypass=False)
async def admin_remove_team_member(
    team_id: str,
    request: Request,
    db: Session = Depends(get_db),
    user=Depends(get_current_user_with_permissions),
) -> HTMLResponse:
    """Remove member from team via admin UI.

    Args:
        team_id: ID of the team to remove member from
        request: FastAPI request object
        db: Database session
        user: Current authenticated user context

    Returns:
        HTMLResponse: Success message or error response
    """
    if not settings.email_auth_enabled:
        return HTMLResponse(content='<div class="text-red-500">Email authentication is disabled</div>', status_code=403)

    try:
        team_service = TeamManagementService(db)

        # Check if team exists and validate user permissions
        team = await team_service.get_team_by_id(team_id)
        if not team:
            return HTMLResponse(content='<div class="text-red-500">Team not found</div>', status_code=404)

        # Only team owners can remove members
        user_email_from_jwt = get_user_email(user)
        user_role = await team_service.get_user_role_in_team(user_email_from_jwt, team_id)
        if user_role != "owner":
            return HTMLResponse(content='<div class="text-red-500">Only team owners can remove members</div>', status_code=403)

        form = await request.form()
        ue_val = form.get("user_email")
        user_email = ue_val if isinstance(ue_val, str) else None

        if not user_email:
            return HTMLResponse(content='<div class="text-red-500">User email is required</div>', status_code=400)

        # Remove member from team

        try:
            success = await team_service.remove_member_from_team(team_id=team_id, user_email=user_email, removed_by=user_email_from_jwt)
            if not success:
                return HTMLResponse(content='<div class="text-red-500">Failed to remove member from team</div>', status_code=400)
        except ValueError as e:
            # Handle specific business logic errors (like last owner)
            return HTMLResponse(content=f'<div class="text-red-500">{html.escape(str(e))}</div>', status_code=400)

        # Return success message with script to refresh modal
        success_html = f"""
        <div class="text-green-500 text-center p-4">
            <p>Member {user_email} removed successfully</p>
        </div>
        """
        response = HTMLResponse(content=success_html)
        response.headers["HX-Trigger"] = orjson.dumps(
            {
                "adminTeamAction": {
                    "teamId": team_id,
                    "refreshTeamMembers": True,
                    "refreshUnifiedTeamsList": True,
                    "delayMs": 1000,
                }
            }
        ).decode()
        return response

    except Exception as e:
        LOGGER.error(f"Error removing member from team {team_id}: {e}")
        return HTMLResponse(content=f'<div class="text-red-500">Error removing member: {html.escape(str(e))}</div>', status_code=400)


@admin_router.post("/teams/{team_id}/leave")
@require_permission("teams.join", allow_admin_bypass=False)  # Users who can join can also leave
async def admin_leave_team(
    team_id: str,
    request: Request,  # pylint: disable=unused-argument
    db: Session = Depends(get_db),
    user=Depends(get_current_user_with_permissions),
) -> HTMLResponse:
    """Leave a team via admin UI.

    Args:
        team_id: ID of the team to leave
        request: FastAPI request object
        db: Database session
        user: Current authenticated user context

    Returns:
        HTMLResponse: Success message or error response
    """
    if not settings.email_auth_enabled:
        return HTMLResponse(content='<div class="text-red-500">Email authentication is disabled</div>', status_code=403)

    try:
        team_service = TeamManagementService(db)

        # Check if team exists
        team = await team_service.get_team_by_id(team_id)
        if not team:
            return HTMLResponse(content='<div class="text-red-500">Team not found</div>', status_code=404)

        # Get current user email
        user_email = get_user_email(user)

        # Check if user is a member of the team
        user_role = await team_service.get_user_role_in_team(user_email, team_id)
        if not user_role:
            return HTMLResponse(content='<div class="text-red-500">You are not a member of this team</div>', status_code=400)

        # Prevent leaving personal teams
        if team.is_personal:
            return HTMLResponse(content='<div class="text-red-500">Cannot leave your personal team</div>', status_code=400)

        # Check if user is the last owner (use SQL COUNT instead of loading all members)
        if user_role == "owner":
            owner_count = team_service.count_team_owners(team_id)
            if owner_count <= 1:
                return HTMLResponse(content='<div class="text-red-500">Cannot leave team as the last owner. Transfer ownership or delete the team instead.</div>', status_code=400)

        # Remove user from team
        success = await team_service.remove_member_from_team(team_id=team_id, user_email=user_email, removed_by=user_email)
        if not success:
            return HTMLResponse(content='<div class="text-red-500">Failed to leave team</div>', status_code=400)

        # Return success message with redirect
        success_html = """
        <div class="text-green-500 text-center p-4">
            <p>Successfully left the team</p>
        </div>
        """
        response = HTMLResponse(content=success_html)
        response.headers["HX-Trigger"] = orjson.dumps({"adminTeamAction": {"refreshUnifiedTeamsList": True, "closeAllModals": True, "delayMs": 1500}}).decode()
        return response

    except Exception as e:
        LOGGER.error(f"Error leaving team {team_id}: {e}")
        return HTMLResponse(content=f'<div class="text-red-500">Error leaving team: {html.escape(str(e))}</div>', status_code=400)


# ============================================================================ #
#                         TEAM JOIN REQUEST ADMIN ROUTES                      #
# ============================================================================ #


@admin_router.post("/teams/{team_id}/join-request")
@require_permission("teams.join", allow_admin_bypass=False)
async def admin_create_join_request(
    team_id: str,
    request: Request,
    db: Session = Depends(get_db),
    user=Depends(get_current_user_with_permissions),
) -> HTMLResponse:
    """Create a join request for a team via admin UI.

    Args:
        team_id: ID of the team to request to join
        request: FastAPI request object
        db: Database session
        user: Authenticated user

    Returns:
        HTML response with success message or error
    """
    if not getattr(settings, "email_auth_enabled", False):
        return HTMLResponse(content='<div class="text-red-500">Email authentication is disabled</div>', status_code=403)

    if not getattr(settings, "allow_team_join_requests", True):
        return HTMLResponse(content='<div class="text-red-500">Team join requests are currently disabled</div>', status_code=403)

    try:
        team_service = TeamManagementService(db)
        user_email = get_user_email(user)

        # Get team to verify it's public
        team = await team_service.get_team_by_id(team_id)
        if not team:
            return HTMLResponse(content='<div class="text-red-500">Team not found</div>', status_code=404)

        if team.visibility != "public":
            return HTMLResponse(content='<div class="text-red-500">Can only request to join public teams</div>', status_code=400)

        # Check if user is already a member
        user_role = await team_service.get_user_role_in_team(user_email, team_id)
        if user_role:
            return HTMLResponse(content='<div class="text-red-500">You are already a member of this team</div>', status_code=400)

        # Check if user already has a pending request
        existing_requests = await team_service.get_user_join_requests(user_email, team_id)
        pending_request = next((req for req in existing_requests if req.status == "pending"), None)
        if pending_request:
            return HTMLResponse(
                content=f"""
            <div class="text-yellow-600">
                <p>You already have a pending request to join this team.</p>
                <button onclick="cancelJoinRequest('{team_id}', '{pending_request.id}')"
                        class="mt-2 px-3 py-1 text-sm font-medium text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300 border border-red-300 dark:border-red-600 hover:border-red-500 dark:hover:border-red-400 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500">
                    Cancel Request
                </button>
            </div>
            """,
                status_code=200,
            )

        # Get form data for optional message
        form = await request.form()
        msg_val = form.get("message", "")
        message = msg_val if isinstance(msg_val, str) else ""

        # Create join request
        join_request = await team_service.create_join_request(team_id=team_id, user_email=user_email, message=message)

        return HTMLResponse(
            content=f"""
        <div class="text-green-600">
            <p>Join request submitted successfully!</p>
            <button onclick="cancelJoinRequest('{team_id}', '{join_request.id}')"
                    class="mt-2 px-3 py-1 text-sm font-medium text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300 border border-red-300 dark:border-red-600 hover:border-red-500 dark:hover:border-red-400 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500">
                Cancel Request
            </button>
        </div>
        """,
            status_code=201,
        )

    except ValueError as e:
        # Handle validation errors with user-friendly HTML error
        error_msg = html.escape(str(e))
        return HTMLResponse(content=f'<div class="text-red-500">{error_msg}</div>', status_code=400)
    except Exception as e:
        LOGGER.error(f"Error creating join request for team {team_id}: {e}")
        return HTMLResponse(content=f'<div class="text-red-500">Error creating join request: {html.escape(str(e))}</div>', status_code=400)


@admin_router.delete("/teams/{team_id}/join-request/{request_id}")
@require_permission("teams.join", allow_admin_bypass=False)
async def admin_cancel_join_request(
    team_id: str,
    request_id: str,
    db: Session = Depends(get_db),
    user=Depends(get_current_user_with_permissions),
) -> HTMLResponse:
    """Cancel a join request via admin UI.

    Args:
        team_id: ID of the team
        request_id: ID of the join request to cancel
        db: Database session
        user: Authenticated user

    Returns:
        HTML response with updated button state
    """
    if not getattr(settings, "email_auth_enabled", False):
        return HTMLResponse(content='<div class="text-red-500">Email authentication is disabled</div>', status_code=403)

    try:
        team_service = TeamManagementService(db)
        user_email = get_user_email(user)

        # Cancel the join request
        success = await team_service.cancel_join_request(request_id, user_email)
        if not success:
            return HTMLResponse(content='<div class="text-red-500">Failed to cancel join request</div>', status_code=400)

        # Return the "Request to Join" button with HX-Trigger for list refresh
        # Check if join requests are currently enabled
        allow_join_requests = getattr(settings, "allow_team_join_requests", True)

        if allow_join_requests:
            button_html = f"""
        <button data-team-id="{team_id}" data-team-name="Team" onclick="requestToJoinTeamSafe(this)"
                class="px-3 py-1 text-sm font-medium text-indigo-600 dark:text-indigo-400 hover:text-indigo-800 dark:hover:text-indigo-300 border border-indigo-300 dark:border-indigo-600 hover:border-indigo-500 dark:hover:border-indigo-400 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
            Request to Join
        </button>
        """
        else:
            button_html = """
        <button disabled
                class="px-3 py-1 text-sm font-medium text-gray-400 dark:text-gray-600 border border-gray-300 dark:border-gray-600 rounded-md cursor-not-allowed opacity-50"
                title="Team join requests are currently disabled">
            Request to Join (Disabled)
        </button>
        """

        response = HTMLResponse(content=button_html, status_code=200)
        response.headers["HX-Trigger"] = orjson.dumps({"adminTeamAction": {"refreshUnifiedTeamsList": True, "delayMs": 1000}}).decode()
        return response

    except Exception as e:
        LOGGER.error(f"Error canceling join request {request_id}: {e}")
        return HTMLResponse(content=f'<div class="text-red-500">Error canceling join request: {html.escape(str(e))}</div>', status_code=400)


@admin_router.get("/teams/{team_id}/join-requests")
@require_permission("teams.manage_members", allow_admin_bypass=False)
async def admin_list_join_requests(
    team_id: str,
    _request: Request,
    db: Session = Depends(get_db),
    user=Depends(get_current_user_with_permissions),
) -> HTMLResponse:
    """List join requests for a team via admin UI.

    Args:
        team_id: ID of the team
        _request: FastAPI request object (unused, required by route signature)
        db: Database session
        user: Authenticated user

    Returns:
        HTML response with join requests list
    """
    if not getattr(settings, "email_auth_enabled", False):
        return HTMLResponse(content='<div class="text-red-500">Email authentication is disabled</div>', status_code=403)

    try:
        team_service = TeamManagementService(db)
        user_email = get_user_email(user)
        # Get team and verify ownership
        team = await team_service.get_team_by_id(team_id)
        if not team:
            return HTMLResponse(content='<div class="text-red-500">Team not found</div>', status_code=404)

        user_role = await team_service.get_user_role_in_team(user_email, team_id)
        if user_role != "owner":
            return HTMLResponse(content='<div class="text-red-500">Only team owners can view join requests</div>', status_code=403)

        # Get join requests
        join_requests = await team_service.list_join_requests(team_id)

        if not join_requests:
            return HTMLResponse(
                content="""
            <div class="text-center py-8">
                <p class="text-gray-500 dark:text-gray-400">No pending join requests</p>
            </div>
            """,
                status_code=200,
            )

        requests_html = ""
        for req in join_requests:
            safe_email = html.escape(req.user_email)
            safe_message = html.escape(req.message) if req.message else ""
            safe_status = html.escape(req.status.upper())
            requests_html += f"""
            <div class="flex justify-between items-center p-4 border border-gray-200 dark:border-gray-600 rounded-lg mb-3">
                <div>
                    <p class="font-medium text-gray-900 dark:text-white">{safe_email}</p>
                    <p class="text-sm text-gray-600 dark:text-gray-400">Requested: {req.requested_at.strftime("%Y-%m-%d %H:%M") if req.requested_at else "Unknown"}</p>
                    {f'<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">Message: {safe_message}</p>' if req.message else ""}
                    <span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300">{safe_status}</span>
                </div>
                <div class="flex gap-2">
                    <button onclick="approveJoinRequest('{team_id}', '{req.id}')"
                            class="px-3 py-1 text-sm font-medium text-green-600 dark:text-green-400 hover:text-green-800 dark:hover:text-green-300 border border-green-300 dark:border-green-600 hover:border-green-500 dark:hover:border-green-400 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500">
                        Approve
                    </button>
                    <button onclick="rejectJoinRequest('{team_id}', '{req.id}')"
                            class="px-3 py-1 text-sm font-medium text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300 border border-red-300 dark:border-red-600 hover:border-red-500 dark:hover:border-red-400 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500">
                        Reject
                    </button>
                </div>
            </div>
            """

        safe_team_name = html.escape(team.name)
        return HTMLResponse(
            content=f"""
        <div class="space-y-4">
            <h3 class="text-lg font-medium text-gray-900 dark:text-white mb-4">Join Requests for {safe_team_name}</h3>
            {requests_html}
        </div>
        """,
            status_code=200,
        )

    except Exception as e:
        LOGGER.error(f"Error listing join requests for team {team_id}: {e}")
        return HTMLResponse(content=f'<div class="text-red-500">Error loading join requests: {html.escape(str(e))}</div>', status_code=400)


@admin_router.post("/teams/{team_id}/join-requests/{request_id}/approve")
@require_permission("teams.manage_members", allow_admin_bypass=False)
async def admin_approve_join_request(
    team_id: str,
    request_id: str,
    db: Session = Depends(get_db),
    user=Depends(get_current_user_with_permissions),
) -> HTMLResponse:
    """Approve a join request via admin UI.

    Args:
        team_id: ID of the team
        request_id: ID of the join request to approve
        db: Database session
        user: Authenticated user

    Returns:
        HTML response with success message
    """
    if not getattr(settings, "email_auth_enabled", False):
        return HTMLResponse(content='<div class="text-red-500">Email authentication is disabled</div>', status_code=403)

    try:
        team_service = TeamManagementService(db)
        user_email = get_user_email(user)

        # Verify team ownership
        user_role = await team_service.get_user_role_in_team(user_email, team_id)
        if user_role != "owner":
            return HTMLResponse(content='<div class="text-red-500">Only team owners can approve join requests</div>', status_code=403)

        # Approve join request
        member = await team_service.approve_join_request(request_id, approved_by=user_email)
        if not member:
            return HTMLResponse(content='<div class="text-red-500">Join request not found</div>', status_code=404)

        response = HTMLResponse(
            content=f"""
        <div class="text-green-600 text-center p-4">
            <p>Join request approved! {member.user_email} is now a team member.</p>
        </div>
        """,
            status_code=200,
        )
        response.headers["HX-Trigger"] = orjson.dumps({"adminTeamAction": {"teamId": team_id, "refreshJoinRequests": True, "delayMs": 1000}}).decode()
        return response

    except Exception as e:
        LOGGER.error(f"Error approving join request {request_id}: {e}")
        return HTMLResponse(content=f'<div class="text-red-500">Error approving join request: {html.escape(str(e))}</div>', status_code=400)


@admin_router.post("/teams/{team_id}/join-requests/{request_id}/reject")
@require_permission("teams.manage_members", allow_admin_bypass=False)
async def admin_reject_join_request(
    team_id: str,
    request_id: str,
    db: Session = Depends(get_db),
    user=Depends(get_current_user_with_permissions),
) -> HTMLResponse:
    """Reject a join request via admin UI.

    Args:
        team_id: ID of the team
        request_id: ID of the join request to reject
        db: Database session
        user: Authenticated user

    Returns:
        HTML response with success message
    """
    if not getattr(settings, "email_auth_enabled", False):
        return HTMLResponse(content='<div class="text-red-500">Email authentication is disabled</div>', status_code=403)

    try:
        team_service = TeamManagementService(db)
        user_email = get_user_email(user)

        # Verify team ownership
        user_role = await team_service.get_user_role_in_team(user_email, team_id)
        if user_role != "owner":
            return HTMLResponse(content='<div class="text-red-500">Only team owners can reject join requests</div>', status_code=403)

        # Reject join request
        success = await team_service.reject_join_request(request_id, rejected_by=user_email)
        if not success:
            return HTMLResponse(content='<div class="text-red-500">Join request not found</div>', status_code=404)

        response = HTMLResponse(
            content="""
        <div class="text-green-600 text-center p-4">
            <p>Join request rejected.</p>
        </div>
        """,
            status_code=200,
        )
        response.headers["HX-Trigger"] = orjson.dumps({"adminTeamAction": {"teamId": team_id, "refreshJoinRequests": True, "delayMs": 1000}}).decode()
        return response

    except Exception as e:
        LOGGER.error(f"Error rejecting join request {request_id}: {e}")
        return HTMLResponse(content=f'<div class="text-red-500">Error rejecting join request: {html.escape(str(e))}</div>', status_code=400)


# ============================================================================ #
#                         USER MANAGEMENT ADMIN ROUTES                        #
# ============================================================================ #


def _render_user_card_html(user_obj, current_user_email: str, admin_count: int, root_path: str) -> str:
    """Render a single user card HTML snippet matching the users list template.

    Args:
        user_obj: User record to render.
        current_user_email: Email of the current user for "You" badge logic.
        admin_count: Count of active admins to protect the last admin.
        root_path: Application root path for HTMX endpoints.

    Returns:
        HTML snippet for the user card.
    """
    encoded_email = urllib.parse.quote(user_obj.email, safe="")
    display_name = html.escape(user_obj.full_name or "N/A")
    safe_email = html.escape(user_obj.email)
    auth_provider = html.escape(user_obj.auth_provider or "unknown")
    created_at = user_obj.created_at.strftime("%Y-%m-%d %H:%M") if user_obj.created_at else "Unknown"

    is_current_user = user_obj.email == current_user_email
    is_last_admin = bool(user_obj.is_admin and user_obj.is_active and admin_count == 1)
    is_locked = user_obj.is_account_locked()
    locked_until = getattr(user_obj, "locked_until", None)
    failed_attempts = int(getattr(user_obj, "failed_login_attempts", 0) or 0)
    lock_until_text = locked_until.strftime("%Y-%m-%d %H:%M") if locked_until else "N/A"

    badges = []
    if user_obj.is_admin:
        badges.append('<span class="px-2 py-1 text-xs font-semibold bg-purple-100 text-purple-800 rounded-full ' + 'dark:bg-purple-900 dark:text-purple-200">Admin</span>')
    if user_obj.is_active:
        badges.append('<span class="px-2 py-1 text-xs font-semibold text-green-600 bg-gray-100 dark:bg-gray-700 rounded-full">Active</span>')
    else:
        badges.append('<span class="px-2 py-1 text-xs font-semibold text-red-600 bg-gray-100 dark:bg-gray-700 rounded-full">Inactive</span>')
    if is_current_user:
        badges.append('<span class="px-2 py-1 text-xs font-semibold bg-blue-100 text-blue-800 rounded-full ' + 'dark:bg-blue-900 dark:text-blue-200">You</span>')
    if is_last_admin:
        badges.append('<span class="px-2 py-1 text-xs font-semibold bg-yellow-100 text-yellow-800 rounded-full ' + 'dark:bg-yellow-900 dark:text-yellow-200">Last Admin</span>')
    if user_obj.password_change_required:
        badges.append(
            '<span class="px-2 py-1 text-xs font-semibold bg-orange-100 text-orange-800 rounded-full '
            'dark:bg-orange-900 dark:text-orange-200"><i class="fas fa-key mr-1"></i>Password Change Required</span>'
        )
    if is_locked:
        badges.append('<span class="px-2 py-1 text-xs font-semibold bg-red-100 text-red-800 rounded-full ' + 'dark:bg-red-900 dark:text-red-200"><i class="fas fa-lock mr-1"></i>Locked</span>')

    actions = [
        f'<button class="px-3 py-1 text-sm font-medium text-blue-600 dark:text-blue-400 hover:text-blue-800 '
        f"dark:hover:text-blue-300 border border-blue-300 dark:border-blue-600 hover:border-blue-500 "
        f"dark:hover:border-blue-400 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 "
        f'focus:ring-blue-500" hx-get="{root_path}/admin/users/{encoded_email}/edit" '
        f'hx-target="#user-edit-modal-content">Edit</button>'
    ]

    if not is_current_user and not is_last_admin:
        if is_locked:
            actions.append(
                f'<button class="px-3 py-1 text-sm font-medium text-indigo-600 dark:text-indigo-400 hover:text-indigo-800 '
                f"dark:hover:text-indigo-300 border border-indigo-300 dark:border-indigo-600 hover:border-indigo-500 "
                f"dark:hover:border-indigo-400 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 "
                f'focus:ring-indigo-500" hx-post="{root_path}/admin/users/{encoded_email}/unlock" '
                f'hx-confirm="Unlock this user account?" hx-target="closest .user-card" hx-swap="outerHTML">Unlock</button>'
            )

        if user_obj.is_active:
            actions.append(
                f'<button class="px-3 py-1 text-sm font-medium text-orange-600 dark:text-orange-400 hover:text-orange-800 '
                f"dark:hover:text-orange-300 border border-orange-300 dark:border-orange-600 hover:border-orange-500 "
                f"dark:hover:border-orange-400 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 "
                f'focus:ring-orange-500" hx-post="{root_path}/admin/users/{encoded_email}/deactivate" '
                f'hx-confirm="Deactivate this user?" hx-target="closest .user-card" hx-swap="outerHTML">Deactivate</button>'
            )
        else:
            actions.append(
                f'<button class="px-3 py-1 text-sm font-medium text-green-600 dark:text-green-400 hover:text-green-800 '
                f"dark:hover:text-green-300 border border-green-300 dark:border-green-600 hover:border-green-500 "
                f"dark:hover:border-green-400 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 "
                f'focus:ring-green-500" hx-post="{root_path}/admin/users/{encoded_email}/activate" '
                f'hx-confirm="Activate this user?" hx-target="closest .user-card" hx-swap="outerHTML">Activate</button>'
            )

        if user_obj.password_change_required:
            actions.append(
                '<span class="px-3 py-1 text-sm font-medium text-orange-600 dark:text-orange-400 bg-orange-50 '
                'dark:bg-orange-900/20 border border-orange-300 dark:border-orange-600 rounded-md">Password Change Required</span>'
            )
        else:
            actions.append(
                f'<button class="px-3 py-1 text-sm font-medium text-yellow-600 dark:text-yellow-400 hover:text-yellow-800 '
                f"dark:hover:text-yellow-300 border border-yellow-300 dark:border-yellow-600 hover:border-yellow-500 "
                f"dark:hover:border-yellow-400 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 "
                f'focus:ring-yellow-500" hx-post="{root_path}/admin/users/{encoded_email}/force-password-change" '
                f'hx-confirm="Force this user to change their password on next login?" hx-target="closest .user-card" '
                f'hx-swap="outerHTML">Force Password Change</button>'
            )

        actions.append(
            f'<button class="px-3 py-1 text-sm font-medium text-red-600 dark:text-red-400 hover:text-red-800 '
            f"dark:hover:text-red-300 border border-red-300 dark:border-red-600 hover:border-red-500 "
            f"dark:hover:border-red-400 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 "
            f'focus:ring-red-500" hx-delete="{root_path}/admin/users/{encoded_email}" '
            f'hx-confirm="Are you sure you want to delete this user? This action cannot be undone." '
            f'hx-target="closest .user-card" hx-swap="outerHTML" '
            f'hx-on::after-request="handleDeleteUserError(event)">Delete</button>'
        )

    return f"""
    <div class="user-card border border-gray-200 dark:border-gray-700 rounded-lg p-4 bg-white dark:bg-gray-800">
      <div class="flex justify-between items-start">
        <div class="flex-1">
          <div class="flex items-center gap-2 mb-2">
            <h3 class="text-lg font-semibold text-gray-900 dark:text-white">{display_name}</h3>
            {" ".join(badges)}
          </div>
          <p class="text-sm text-gray-600 dark:text-gray-400 mb-2">📧 {safe_email}</p>
          <p class="text-sm text-gray-600 dark:text-gray-400 mb-2">🔐 Provider: {auth_provider}</p>
          <p class="text-sm text-gray-600 dark:text-gray-400 mb-2">⚠️ Failed attempts: {failed_attempts}</p>
          <p class="text-sm text-gray-600 dark:text-gray-400 mb-2">🔒 Locked until: {lock_until_text}</p>
          <p class="text-sm text-gray-600 dark:text-gray-400">📅 Created: {created_at}</p>
        </div>
        <div class="flex gap-2 ml-4">
          {" ".join(actions)}
        </div>
      </div>
    </div>
    """


@admin_router.get("/users")
@require_permission("admin.user_management", allow_admin_bypass=False)
async def admin_list_users(
    request: Request,
    page: int = Query(1, ge=1, description="Page number (1-indexed)"),
    per_page: int = Query(settings.pagination_default_page_size, ge=1, le=settings.pagination_max_page_size, description="Items per page"),
    db: Session = Depends(get_db),
    user=Depends(get_current_user_with_permissions),
) -> Response:
    """
    List users for the admin UI with pagination support.

    This endpoint retrieves a paginated list of users from the database.
    Uses offset-based (page/per_page) pagination.
    Supports JSON response for dropdown population when format=json query parameter is provided.

    Args:
        request: FastAPI request object
        page: Page number (1-indexed). Default: 1.
        per_page: Items per page. Default: 50.
        db: Database session dependency
        user: Authenticated user dependency

    Returns:
        Dict with 'data', 'pagination', and 'links' keys containing paginated users,
        or JSON response for dropdown population.
    """
    if not settings.email_auth_enabled:
        return HTMLResponse(
            content='<div class="text-center py-8"><p class="text-gray-500">Email authentication is disabled. User management requires email auth.</p></div>',
            status_code=200,
        )

    LOGGER.debug(f"User {get_user_email(user)} requested user list (page={page}, per_page={per_page})")

    auth_service = EmailAuthService(db)

    # Check if JSON response is requested (for dropdown population)
    accept_header = request.headers.get("accept", "")
    is_json_request = "application/json" in accept_header or request.query_params.get("format") == "json"

    if is_json_request:
        # Return JSON for dropdown population - always return first page with 100 users
        paginated_result = await auth_service.list_users(page=1, per_page=100)
        users_data = [{"email": user_obj.email, "full_name": user_obj.full_name, "is_active": user_obj.is_active, "is_admin": user_obj.is_admin} for user_obj in paginated_result.data]
        return ORJSONResponse(content={"users": users_data})

    # List users with page-based pagination
    paginated_result = await auth_service.list_users(page=page, per_page=per_page)

    # End the read-only transaction early to avoid idle-in-transaction under load
    db.commit()

    # Return standardized paginated response (for legacy compatibility)
    return ORJSONResponse(
        content={
            "data": [{"email": u.email, "full_name": u.full_name, "is_active": u.is_active, "is_admin": u.is_admin} for u in paginated_result.data],
            "pagination": paginated_result.pagination.model_dump() if paginated_result.pagination else None,
            "links": paginated_result.links.model_dump() if paginated_result.links else None,
        }
    )


@admin_router.get("/users/partial", response_class=HTMLResponse)
@require_permission("admin.user_management", allow_admin_bypass=False)
async def admin_users_partial_html(
    request: Request,
    page: int = Query(1, ge=1, description="Page number (1-indexed)"),
    per_page: int = Query(settings.pagination_default_page_size, ge=1, le=settings.pagination_max_page_size, description="Items per page"),
    render: QueryRenderModeUserSelector = None,
    team_id: Optional[str] = Depends(_validated_team_id_param),
    db: Session = Depends(get_db),
    user=Depends(get_current_user_with_permissions),
) -> Response:
    """
    Return paginated users as HTML partial for HTMX requests.

    This endpoint returns rendered HTML for the users list with pagination controls,
    designed for HTMX-based dynamic updates.

    Args:
        request: FastAPI request object
        page: Page number (1-indexed). Default: 1.
        per_page: Items per page. Default: 50.
        render: Render mode - 'selector' returns user selector items, 'controls' returns pagination controls.
        team_id: Optional team ID to pre-select members in selector mode
        db: Database session
        user: Current authenticated user context

    Returns:
        Response: HTML response with users list and pagination controls
    """
    try:
        if not settings.email_auth_enabled:
            return HTMLResponse(
                content='<div class="text-center py-8"><p class="text-gray-500">Email authentication is disabled. User management requires email auth.</p></div>',
                status_code=200,
            )

        auth_service = EmailAuthService(db)

        # List users with page-based pagination
        paginated_result = await auth_service.list_users(page=page, per_page=per_page)
        users_db = paginated_result.data
        pagination = typing_cast(PaginationMeta, paginated_result.pagination)

        # Get current user email
        current_user_email = get_user_email(user)

        # Check how many active admins we have
        admin_count = await auth_service.count_active_admin_users()

        # Prepare user data for template with additional flags
        users_data = []
        for user_obj in users_db:
            is_current_user = user_obj.email == current_user_email
            is_last_admin = user_obj.is_admin and user_obj.is_active and admin_count == 1

            users_data.append(
                {
                    "email": user_obj.email,
                    "full_name": user_obj.full_name,
                    "is_active": user_obj.is_active,
                    "is_admin": user_obj.is_admin,
                    "auth_provider": user_obj.auth_provider,
                    "created_at": user_obj.created_at,
                    "password_change_required": user_obj.password_change_required,
                    "is_locked": user_obj.is_account_locked(),
                    "failed_login_attempts": int(getattr(user_obj, "failed_login_attempts", 0) or 0),
                    "locked_until": getattr(user_obj, "locked_until", None),
                    "is_current_user": is_current_user,
                    "is_last_admin": is_last_admin,
                }
            )

        # Get team members if team_id is provided (for pre-selection in team member addition)
        team_member_emails = set()
        team_member_data = {}
        current_user_is_team_owner = False

        if team_id and render == "selector":
            team_service = TeamManagementService(db)
            try:
                team_members = await team_service.get_team_members(team_id)
                team_member_emails = {team_user.email for team_user, membership in team_members}

                # Build enhanced member data from the same query result (no extra DB calls!)
                # Count owners in-memory
                owner_count = sum(1 for _, membership in team_members if membership.role == "owner")

                # Build member data dict and find current user's role
                for team_user, membership in team_members:
                    email = team_user.email
                    is_last_owner = membership.role == "owner" and owner_count == 1
                    team_member_data[email] = type("MemberData", (), {"role": membership.role, "joined_at": membership.joined_at, "is_last_owner": is_last_owner})()

                    # Check if current user is owner (in-memory check)
                    if email == current_user_email and membership.role == "owner":
                        current_user_is_team_owner = True

            except Exception as e:
                LOGGER.warning(f"Could not fetch team members for team {team_id}: {e}")

        # End the read-only transaction early to avoid idle-in-transaction under load
        db.commit()

        if render == "selector":
            response = request.app.state.templates.TemplateResponse(
                request,
                "team_members_selector.html",
                {
                    "request": request,
                    "data": users_data,
                    "pagination": pagination.model_dump(),
                    "root_path": _resolve_root_path(request),
                    "team_member_emails": team_member_emails,
                    "team_member_data": team_member_data,
                    "current_user_email": current_user_email,
                    "current_user_is_team_owner": current_user_is_team_owner,
                    "team_id": team_id,
                },
            )
        elif render == "controls":
            base_url = f"{_resolve_root_path(request)}/admin/users/partial"
            response = request.app.state.templates.TemplateResponse(
                request,
                "pagination_controls.html",
                {
                    "request": request,
                    "pagination": pagination.model_dump(),
                    "base_url": base_url,
                    "hx_target": "#users-list-container",
                    "hx_indicator": "#users-loading",
                    "hx_swap": "outerHTML",
                    "query_params": {},
                    "root_path": _resolve_root_path(request),
                },
            )
        else:
            # Render template with paginated data
            response = request.app.state.templates.TemplateResponse(
                request,
                "users_partial.html",
                {
                    "request": request,
                    "data": users_data,
                    "pagination": pagination.model_dump(),
                    "root_path": _resolve_root_path(request),
                    "current_user_email": current_user_email,
                },
            )

        # Prevent stale partials after create/update/delete actions.
        response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
        response.headers["Pragma"] = "no-cache"
        response.headers["Expires"] = "0"
        return response

    except Exception as e:
        LOGGER.error(f"Error loading users partial for admin {user}: {e}")
        return HTMLResponse(content=f'<div class="text-center py-8"><p class="text-red-500">Error loading users: {html.escape(str(e))}</p></div>', status_code=200)


@admin_router.get("/teams/{team_id}/members/partial", response_class=HTMLResponse)
@require_permission("teams.manage_members", allow_admin_bypass=False)
async def admin_team_members_partial_html(
    team_id: str,
    request: Request,
    page: int = Query(1, ge=1, description="Page number (1-indexed)"),
    per_page: int = Query(settings.pagination_default_page_size, ge=1, le=settings.pagination_max_page_size, description="Items per page"),
    search: str = Query("", max_length=255, description="Search term to filter members by name or email"),
    db: Session = Depends(get_db),
    user=Depends(get_current_user_with_permissions),
) -> Response:
    """Return paginated team members for two-section layout (top section).

    Args:
        team_id: Team identifier.
        request: FastAPI request object.
        page: Page number (1-indexed). Default: 1.
        per_page: Items per page. Default: 50.
        search: Search term to filter members by name or email.
        db: Database session.
        user: Current authenticated user context.

    Returns:
        Response: HTML response with team members and pagination data.
    """
    try:
        if not settings.email_auth_enabled:
            return HTMLResponse(
                content='<div class="text-center py-8"><p class="text-gray-500">Email authentication is disabled.</p></div>',
                status_code=200,
            )

        team_service = TeamManagementService(db)
        current_user_email = get_user_email(user)

        try:
            team_id = _normalize_team_id(team_id)
        except ValueError:
            return HTMLResponse(content='<div class="text-red-500">Invalid team ID</div>', status_code=400)

        team = await team_service.get_team_by_id(team_id)
        if not team:
            return HTMLResponse(content='<div class="text-red-500">Team not found</div>', status_code=404)

        current_user_role = await team_service.get_user_role_in_team(current_user_email, team_id)
        if current_user_role != "owner":
            return HTMLResponse(content='<div class="text-red-500">Only team owners can manage members</div>', status_code=403)

        # Get paginated team members with optional search filter
        search_term = search.strip() if search else ""
        paginated_result = await team_service.get_team_members(team_id, page=page, per_page=per_page, search=search_term or None)
        members = paginated_result["data"]
        pagination = paginated_result["pagination"]

        # Count owners for is_last_owner check - must count ALL owners, not just current page
        owner_count = team_service.count_team_owners(team_id)

        # End the read-only transaction early
        db.commit()

        root_path = _resolve_root_path(request)
        search_param = f"&search={urllib.parse.quote(search_term)}" if search_term else ""
        next_page_url = f"{root_path}/admin/teams/{team_id}/members/partial?page={pagination.page + 1}&per_page={pagination.per_page}{search_param}"
        response = request.app.state.templates.TemplateResponse(
            request,
            "team_users_selector.html",
            {
                "request": request,
                "data": members,  # List of (user, membership) tuples
                "pagination": pagination.model_dump(),
                "root_path": root_path,
                "current_user_email": current_user_email,
                "current_user_is_team_owner": True,  # Already verified above
                "owner_count": owner_count,
                "team_id": team_id,
                "is_members_list": True,
                "scroll_trigger_id": "members-scroll-trigger",
                "next_page_url": next_page_url,
            },
        )
        # Prevent nginx caching for real-time member list updates
        response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
        response.headers["Pragma"] = "no-cache"
        response.headers["Expires"] = "0"
        return response

    except Exception as e:
        LOGGER.error(f"Error loading team members partial for team {team_id}: {e}")
        return HTMLResponse(content=f'<div class="text-center py-8"><p class="text-red-500">Error loading members: {html.escape(str(e))}</p></div>', status_code=200)


@admin_router.get("/teams/{team_id}/non-members/partial", response_class=HTMLResponse)
@require_permission("teams.manage_members", allow_admin_bypass=False)
async def admin_team_non_members_partial_html(
    team_id: str,
    request: Request,
    page: int = Query(1, ge=1, description="Page number (1-indexed)"),
    per_page: int = Query(50, ge=1, le=50, description="Items per page (max 50 for non-members)"),
    search: str = Query("", max_length=255, description="Search term to filter non-members by name or email"),
    db: Session = Depends(get_db),
    user=Depends(get_current_user_with_permissions),
) -> Response:
    """Return paginated non-members for two-section layout (bottom section).

    Non-members are only returned when a search term with at least 2 characters
    is provided. Without a search term, returns an empty placeholder prompting
    the user to search.

    Args:
        team_id: Team identifier.
        request: FastAPI request object.
        page: Page number (1-indexed). Default: 1.
        per_page: Items per page (capped at 50). Default: 50.
        search: Search term to filter non-members by name or email.
        db: Database session.
        user: Current authenticated user context.

    Returns:
        Response: HTML response with non-members and pagination data.
    """
    try:
        if not settings.email_auth_enabled:
            return HTMLResponse(
                content='<div class="text-center py-8"><p class="text-gray-500">Email authentication is disabled.</p></div>',
                status_code=200,
            )

        auth_service = EmailAuthService(db)
        team_service = TeamManagementService(db)
        current_user_email = get_user_email(user)

        try:
            team_id = _normalize_team_id(team_id)
        except ValueError:
            return HTMLResponse(content='<div class="text-red-500">Invalid team ID</div>', status_code=400)

        team = await team_service.get_team_by_id(team_id)
        if not team:
            return HTMLResponse(content='<div class="text-red-500">Team not found</div>', status_code=404)

        current_user_role = await team_service.get_user_role_in_team(current_user_email, team_id)
        if current_user_role != "owner":
            return HTMLResponse(content='<div class="text-red-500">Only team owners can manage members</div>', status_code=403)

        # Require a search term - do not load all non-members by default
        search_term = search.strip() if search else ""
        if not search_term:
            return HTMLResponse(
                content='<div class="text-center py-4 text-gray-500 dark:text-gray-400">Search for users by name or email to add them to this team.</div>',
                status_code=200,
            )
        if len(search_term) < 2:
            return HTMLResponse(
                content='<div class="text-center py-4 text-gray-500 dark:text-gray-400">Type at least 2 characters to search for users.</div>',
                status_code=200,
            )

        # Cap per_page at 50 for non-members to prevent DOM overload
        per_page = min(per_page, 50)

        # Get paginated non-members with search filter
        paginated_result = await auth_service.list_users_not_in_team(team_id, page=page, per_page=per_page, search=search_term)
        users = paginated_result.data
        pagination = typing_cast(PaginationMeta, paginated_result.pagination)

        # End the read-only transaction early
        db.commit()

        root_path = _resolve_root_path(request)
        search_param = f"&search={urllib.parse.quote(search_term)}" if search_term else ""
        next_page_url = f"{root_path}/admin/teams/{team_id}/non-members/partial?page={pagination.page + 1}&per_page={pagination.per_page}{search_param}"
        response = request.app.state.templates.TemplateResponse(
            request,
            "team_users_selector.html",
            {
                "request": request,
                "data": users,  # List of user objects
                "pagination": pagination.model_dump(),
                "root_path": root_path,
                "current_user_email": current_user_email,
                "current_user_is_team_owner": True,  # Already verified above
                "owner_count": 0,  # Not relevant for non-members
                "team_id": team_id,
                "is_members_list": False,
                "scroll_trigger_id": "non-members-scroll-trigger",
                "next_page_url": next_page_url,
            },
        )
        # Prevent nginx caching for real-time non-member list updates
        response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
        response.headers["Pragma"] = "no-cache"
        response.headers["Expires"] = "0"
        return response

    except Exception as e:
        LOGGER.error(f"Error loading team non-members partial for team {team_id}: {e}")
        return HTMLResponse(content=f'<div class="text-center py-8"><p class="text-red-500">Error loading non-members: {html.escape(str(e))}</p></div>', status_code=200)


@admin_router.get("/users/search", response_class=JSONResponse)
@require_any_permission(["admin.user_management", "teams.manage_members"], allow_admin_bypass=False)
async def admin_search_users(
    q: str = Query("", max_length=500, description="Search query"),
    limit: int = Query(settings.pagination_default_page_size, ge=1, le=settings.pagination_max_page_size, description="Maximum number of results to return"),
    db: Session = Depends(get_db),
    user=Depends(get_current_user_with_permissions),
):
    """
    Search users by email or full name.

    This endpoint searches users for use in search functionality like team member selection.

    Args:
        q (str): Search query string to match against email or full name
        limit (int): Maximum number of results to return
        db (Session): Database session dependency
        user: Current user making the request

    Returns:
        JSONResponse: Dictionary containing list of matching users and count
    """
    search_query = _normalize_search_query(q)
    if not settings.email_auth_enabled:
        return _build_search_response(entity_key="users", entity_type="users", items=[], query=search_query, tags="", tag_groups=[])

    user_email = get_user_email(user)

    if not search_query:
        return _build_search_response(entity_key="users", entity_type="users", items=[], query=search_query, tags="", tag_groups=[])

    LOGGER.debug(f"User {user_email} searching users with query: {search_query}")

    auth_service = EmailAuthService(db)

    # Use list_users with search parameter
    users_result = await auth_service.list_users(search=search_query, limit=limit)
    users_list = users_result.data

    # Format results for JSON response
    results = [
        {
            "id": user_obj.email,
            "name": user_obj.full_name or user_obj.email,
            "email": user_obj.email,
            "full_name": user_obj.full_name or "",
            "is_active": user_obj.is_active,
            "is_admin": user_obj.is_admin,
        }
        for user_obj in users_list
    ]

    return _build_search_response(entity_key="users", entity_type="users", items=results, query=search_query, tags="", tag_groups=[])


@admin_router.post("/users")
@require_permission("admin.user_management", allow_admin_bypass=False)
async def admin_create_user(
    request: Request,
    db: Session = Depends(get_db),
    user=Depends(get_current_user_with_permissions),
) -> HTMLResponse:
    """Create a new user via admin UI.

    Args:
        request: FastAPI request object
        db: Database session
        user: Current authenticated user context

    Returns:
        HTMLResponse: Success message or error response
    """
    try:
        form = await request.form()

        # Validate password strength
        password = str(form.get("password", ""))
        email_val = str(form.get("email", ""))
        is_admin_val = form.get("is_admin") == "on"
        if password:
            is_valid, error_msg = validate_password_strength(password, email_val, is_admin_val)
            if not is_valid:
                return HTMLResponse(content=f'<div class="text-red-500">Password validation failed: {error_msg}</div>', status_code=400)

        # First-Party

        auth_service = EmailAuthService(db)

        # Create new user
        new_user = await auth_service.create_user(
            email=email_val,
            password=password,
            full_name=str(form.get("full_name", "")),
            is_admin=is_admin_val,
            auth_provider="local",
            granted_by=get_user_email(user),  # Pass current admin user for audit trail
        )

        # If the user was created with the default password, optionally force password change
        if (
            settings.password_change_enforcement_enabled and getattr(settings, "require_password_change_for_default_password", True) and password == settings.default_user_password.get_secret_value()
        ):  # nosec B105
            new_user.password_change_required = True
            db.commit()

        LOGGER.info(f"Admin {user} created user: {new_user.email}")

        # Return HX-Trigger header to refresh the users list
        # This will trigger a reload of the users-list-container
        response = HTMLResponse(content='<div class="text-green-500">User created successfully!</div>', status_code=201)
        response.headers["HX-Trigger"] = "userCreated"
        return response

    except Exception as e:
        LOGGER.error(f"Error creating user by admin {user}: {e}")
        return HTMLResponse(content=f'<div class="text-red-500">Error creating user: {html.escape(str(e))}</div>', status_code=400)


@admin_router.get("/users/{user_email}/edit")
@require_permission("admin.user_management", allow_admin_bypass=False)
async def admin_get_user_edit(
    user_email: str,
    _request: Request,
    db: Session = Depends(get_db),
    _user=Depends(get_current_user_with_permissions),
) -> HTMLResponse:
    """Get user edit form via admin UI.

    Args:
        user_email: Email of user to edit
        db: Database session

    Returns:
        HTMLResponse: User edit form HTML
    """
    if not settings.email_auth_enabled:
        return HTMLResponse(content='<div class="text-red-500">Email authentication is disabled</div>', status_code=403)

    try:
        # Get root path for URL construction
        root_path = _resolve_root_path(_request) if _request else ""

        # First-Party

        auth_service = EmailAuthService(db)

        # URL decode the email

        decoded_email = urllib.parse.unquote(user_email)

        user_obj = await auth_service.get_user_by_email(decoded_email)
        if not user_obj:
            return HTMLResponse(content='<div class="text-red-500">User not found</div>', status_code=404)

        # Get current user's email to check if editing self
        current_user_email = get_user_email(_user)
        is_editing_self = current_user_email.lower() == decoded_email.lower()

        # Build Password Requirements HTML separately to avoid backslash issues inside f-strings
        if settings.password_require_uppercase or settings.password_require_lowercase or settings.password_require_numbers or settings.password_require_special:
            pr_lines = []
            pr_lines.append(f"""                <!-- Password Requirements -->
                <div class="bg-blue-50 dark:bg-blue-900 border border-blue-200 dark:border-blue-700 rounded-md p-4">
                    <div class="flex items-start">
                        <svg class="h-5 w-5 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5" viewBox="0 0 20 20" fill="currentColor">
                            <path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"/>
                        </svg>
                        <div class="ml-3 flex-1">
                            <h3 class="text-sm font-semibold text-blue-900 dark:text-blue-200">Password Requirements</h3>
                            <div class="mt-2 text-sm text-blue-800 dark:text-blue-300 space-y-1">
                                <div class="flex items-center" id="edit-req-length">
                                    <span class="inline-flex items-center justify-center w-4 h-4 bg-gray-400 text-white rounded-full text-xs mr-2">✗</span>
                                    <span>At least {settings.password_min_length} characters long</span>
                                </div>
            """)
            if settings.password_require_uppercase:
                pr_lines.append("""
                                <div class="flex items-center" id="edit-req-uppercase"><span class="inline-flex items-center justify-center w-4 h-4 bg-gray-400 text-white rounded-full text-xs mr-2">✗</span><span>Contains uppercase letters (A-Z)</span></div>
                """)
            if settings.password_require_lowercase:
                pr_lines.append("""
                                <div class="flex items-center" id="edit-req-lowercase"><span class="inline-flex items-center justify-center w-4 h-4 bg-gray-400 text-white rounded-full text-xs mr-2">✗</span><span>Contains lowercase letters (a-z)</span></div>
                """)
            if settings.password_require_numbers:
                pr_lines.append("""
                                <div class="flex items-center" id="edit-req-numbers"><span class="inline-flex items-center justify-center w-4 h-4 bg-gray-400 text-white rounded-full text-xs mr-2">✗</span><span>Contains numbers (0-9)</span></div>
                """)
            if settings.password_require_special:
                pr_lines.append("""
                                <div class="flex items-center" id="edit-req-special"><span class="inline-flex items-center justify-center w-4 h-4 bg-gray-400 text-white rounded-full text-xs mr-2">✗</span><span>Contains special characters (!@#$%^&amp;*(),.?&quot;:{{}}|&lt;&gt;)</span></div>
                """)
            pr_lines.append("""
                            </div>
                        </div>
                    </div>
                </div>
            """)
            password_requirements_html = "".join(pr_lines)
        else:
            # Intentionally an empty string for HTML insertion when no requirements apply.
            # This is not a password value; suppress Bandit false positive B105.
            password_requirements_html = ""  # nosec B105

        # Create edit form HTML
        edit_form = f"""
        <div id="user-edit-modal-content" class="space-y-4">
            <h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Edit User</h3>
            <div id="edit-user-error"></div>
            <form hx-post="{root_path}/admin/users/{user_email}/update" hx-target="#edit-user-error" hx-swap="innerHTML" class="space-y-4">
                <div>
                    <label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Email</label>
                    <input type="email" name="email" value="{user_obj.email}" readonly
                           class="mt-1 block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-white">
                </div>
                <div>
                    <label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Full Name</label>
                    <input type="text" name="full_name" value="{user_obj.full_name or ""}" required
                           class="mt-1 block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 dark:bg-gray-700 text-gray-900 dark:text-white">
                </div>
                {"" if is_editing_self else f'''<div>
                    <label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
                        <input type="checkbox" name="is_admin" {"checked" if user_obj.is_admin else ""}
                               class="mr-2"> Administrator
                    </label>
                </div>'''}
                <div>
                    <label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
                        <input type="checkbox" name="email_verified" {"checked" if user_obj.is_email_verified() else ""}
                               class="mr-2"> Email Verified
                    </label>
                </div>
                <div>
                    <label class="block text-sm font-medium text-gray-700 dark:text-gray-300">New Password (leave empty to keep current)</label>
                    <input type="password" name="password" id="password-field"
                           class="mt-1 block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 dark:bg-gray-700 text-gray-900 dark:text-white"
                           oninput="Admin.validatePasswordRequirements(); Admin.validatePasswordMatch();">
                </div>
                <div>
                    <label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Confirm New Password</label>
                    <input type="password" name="confirm_password" id="confirm-password-field"
                           class="mt-1 block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 dark:bg-gray-700 text-gray-900 dark:text-white"
                           oninput="Admin.validatePasswordMatch()">
                    <div id="password-match-message" class="mt-1 text-sm text-red-600 hidden">Passwords do not match</div>
                </div>
                {password_requirements_html}
                <div
                    id="edit-password-policy-data"
                    class="hidden"
                    data-min-length="{settings.password_min_length}"
                    data-require-uppercase="{"true" if settings.password_require_uppercase else "false"}"
                    data-require-lowercase="{"true" if settings.password_require_lowercase else "false"}"
                    data-require-numbers="{"true" if settings.password_require_numbers else "false"}"
                    data-require-special="{"true" if settings.password_require_special else "false"}"
                ></div>
                <div class="flex justify-end space-x-3">
                    <button type="button" onclick="Admin.hideUserEditModal()"
                            class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700">
                        Cancel
                    </button>
                    <button type="submit"
                            class="px-4 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
                        Update User
                    </button>
                </div>
            </form>
        </div>
        """
        return HTMLResponse(content=edit_form)

    except Exception as e:
        LOGGER.error(f"Error getting user edit form for {user_email}: {e}")
        return HTMLResponse(content=f'<div class="text-red-500">Error loading user: {html.escape(str(e))}</div>', status_code=500)


@admin_router.post("/users/{user_email}/update")
@require_permission("admin.user_management", allow_admin_bypass=False)
async def admin_update_user(
    user_email: str,
    request: Request,
    db: Session = Depends(get_db),
    _user=Depends(get_current_user_with_permissions),
) -> HTMLResponse:
    """Update user via admin UI.

    Args:
        user_email: Email of user to update
        request: FastAPI request object
        db: Database session

    Returns:
        HTMLResponse: Success message or error response
    """
    if not settings.email_auth_enabled:
        return HTMLResponse(content='<div class="text-red-500">Email authentication is disabled</div>', status_code=403)

    try:
        # First-Party

        auth_service = EmailAuthService(db)

        # URL decode the email

        decoded_email = urllib.parse.unquote(user_email)

        form = await request.form()
        full_name = form.get("full_name")
        is_admin = form.get("is_admin") == "on"
        email_verified = form.get("email_verified") == "on"
        password = form.get("password")
        confirm_password = form.get("confirm_password")

        # Validate password confirmation if password is being changed
        if password and password != confirm_password:
            return HTMLResponse(content='<div class="text-red-500">Passwords do not match</div>', status_code=400, headers={"HX-Retarget": "#edit-user-error"})

        # Get current user's email to prevent self-demotion
        current_user_email = get_user_email(_user)

        # Check if trying to remove admin privileges from last admin
        user_obj = await auth_service.get_user_by_email(decoded_email)

        # When editing self, preserve current admin status (checkbox is hidden in UI)
        if user_obj and current_user_email.lower() == decoded_email.lower():
            is_admin = user_obj.is_admin

        if user_obj and user_obj.is_admin and not is_admin:
            # This user is currently an admin and we're trying to remove admin privileges
            if await auth_service.is_last_active_admin(decoded_email):
                return HTMLResponse(
                    content='<div class="text-red-500">Cannot remove administrator privileges from the last remaining admin user</div>', status_code=400, headers={"HX-Retarget": "#edit-user-error"}
                )

        # Update user
        fn_val = form.get("full_name")
        pw_val = form.get("password")
        full_name = fn_val if isinstance(fn_val, str) else None
        password = pw_val.strip() if isinstance(pw_val, str) and pw_val.strip() else None

        # Validate password if provided
        if password:
            is_valid, error_msg = validate_password_strength(password, decoded_email, is_admin)
            if not is_valid:
                return HTMLResponse(content=f'<div class="text-red-500">Password validation failed: {error_msg}</div>', status_code=400, headers={"HX-Retarget": "#edit-user-error"})

        await auth_service.update_user(email=decoded_email, full_name=full_name, is_admin=is_admin, email_verified=email_verified, password=password, admin_origin_source="ui")

        # Return success message with auto-close and refresh
        success_html = """
        <div class="text-green-500 text-center p-4">
            <p>User updated successfully</p>
            <button type="button" onclick="Admin.hideUserEditModal()" class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700">
                Close
            </button>
        </div>
        """
        response = HTMLResponse(content=success_html)
        response.headers["HX-Trigger"] = orjson.dumps({"adminUserAction": {"closeUserEditModal": True, "refreshUsersList": True, "delayMs": 1500}}).decode()
        return response

    except Exception as e:
        LOGGER.error(f"Error updating user {user_email}: {e}")
        return HTMLResponse(content=f'<div class="text-red-500">Error updating user: {html.escape(str(e))}</div>', status_code=400, headers={"HX-Retarget": "#edit-user-error"})


@admin_router.post("/users/{user_email}/activate")
@require_permission("admin.user_management", allow_admin_bypass=False)
async def admin_activate_user(
    user_email: str,
    _request: Request,
    db: Session = Depends(get_db),
    user=Depends(get_current_user_with_permissions),
) -> HTMLResponse:
    """Activate user via admin UI.

    Args:
        user_email: Email of user to activate
        db: Database session
        user: Current authenticated user context

    Returns:
        HTMLResponse: Success message or error response
    """
    if not settings.email_auth_enabled:
        return HTMLResponse(content='<div class="text-red-500">Email authentication is disabled</div>', status_code=403)

    try:
        # Get root path for URL construction
        root_path = _resolve_root_path(_request) if _request else ""

        # First-Party

        auth_service = EmailAuthService(db)

        # URL decode the email

        decoded_email = urllib.parse.unquote(user_email)

        # Get current user email from JWT (used for logging purposes)
        current_user_email = get_user_email(user)

        user_obj = await auth_service.activate_user(decoded_email)
        admin_count = await auth_service.count_active_admin_users()
        return HTMLResponse(content=_render_user_card_html(user_obj, current_user_email, admin_count, root_path))

    except Exception as e:
        LOGGER.error(f"Error activating user {user_email}: {e}")
        return HTMLResponse(content=f'<div class="text-red-500">Error activating user: {html.escape(str(e))}</div>', status_code=400)


@admin_router.post("/users/{user_email}/deactivate")
@require_permission("admin.user_management", allow_admin_bypass=False)
async def admin_deactivate_user(
    user_email: str,
    _request: Request,
    db: Session = Depends(get_db),
    user=Depends(get_current_user_with_permissions),
) -> HTMLResponse:
    """Deactivate user via admin UI.

    Args:
        user_email: Email of user to deactivate
        db: Database session
        user: Current authenticated user context

    Returns:
        HTMLResponse: Success message or error response
    """
    if not settings.email_auth_enabled:
        return HTMLResponse(content='<div class="text-red-500">Email authentication is disabled</div>', status_code=403)

    try:
        # Get root path for URL construction
        root_path = _resolve_root_path(_request) if _request else ""

        # First-Party

        auth_service = EmailAuthService(db)

        # URL decode the email

        decoded_email = urllib.parse.unquote(user_email)

        # Get current user email from JWT
        current_user_email = get_user_email(user)

        # Prevent self-deactivation
        if decoded_email == current_user_email:
            return HTMLResponse(content='<div class="text-red-500">Cannot deactivate your own account</div>', status_code=400)

        # Prevent deactivating the last active admin user
        if await auth_service.is_last_active_admin(decoded_email):
            return HTMLResponse(content='<div class="text-red-500">Cannot deactivate the last remaining admin user</div>', status_code=400)

        user_obj = await auth_service.deactivate_user(decoded_email)
        admin_count = await auth_service.count_active_admin_users()
        return HTMLResponse(content=_render_user_card_html(user_obj, current_user_email, admin_count, root_path))

    except Exception as e:
        LOGGER.error(f"Error deactivating user {user_email}: {e}")
        return HTMLResponse(content=f'<div class="text-red-500">Error deactivating user: {html.escape(str(e))}</div>', status_code=400)


@admin_router.delete("/users/{user_email}")
@require_permission("admin.user_management", allow_admin_bypass=False)
async def admin_delete_user(
    user_email: str,
    _request: Request,
    db: Session = Depends(get_db),
    user=Depends(get_current_user_with_permissions),
) -> HTMLResponse:
    """Delete user via admin UI.

    Args:
        user_email: Email address of user to delete
        _request: FastAPI request object (unused)
        db: Database session
        user: Current authenticated user context

    Returns:
        HTMLResponse: Success/error message
    """
    if not settings.email_auth_enabled:
        return HTMLResponse(content='<div class="text-red-500">Email authentication is disabled</div>', status_code=403)

    try:
        # First-Party

        auth_service = EmailAuthService(db)

        # URL decode the email

        decoded_email = urllib.parse.unquote(user_email)

        # Get current user email from JWT
        current_user_email = get_user_email(user)

        # Prevent self-deletion
        if decoded_email == current_user_email:
            return HTMLResponse(content='<div class="text-red-500">Cannot delete your own account</div>', status_code=400)

        # Prevent deleting the last active admin user
        if await auth_service.is_last_active_admin(decoded_email):
            return HTMLResponse(content='<div class="text-red-500">Cannot delete the last remaining admin user</div>', status_code=400)

        await auth_service.delete_user(decoded_email)

        # Return empty content to remove the user from the list
        return HTMLResponse(content="", status_code=200)

    except Exception as e:
        LOGGER.error(f"Error deleting user {user_email}: {e}")
        return HTMLResponse(content=f'<div class="text-red-500">Error deleting user: {html.escape(str(e))}</div>', status_code=400)


@admin_router.post("/users/{user_email}/unlock")
@require_permission("admin.user_management", allow_admin_bypass=False)
async def admin_unlock_user(
    user_email: str,
    _request: Request,
    db: Session = Depends(get_db),
    user=Depends(get_current_user_with_permissions),
) -> HTMLResponse:
    """Unlock a user account from the admin UI.

    Args:
        user_email: URL-encoded email for the user to unlock.
        _request: Incoming HTTP request.
        db: Database session dependency.
        user: Current authenticated user context.

    Returns:
        HTMLResponse: Updated user card HTML or error snippet.
    """
    if not settings.email_auth_enabled:
        return HTMLResponse(content='<div class="text-red-500">Email authentication is disabled</div>', status_code=403)

    try:
        root_path = _resolve_root_path(_request) if _request else ""
        auth_service = EmailAuthService(db)
        decoded_email = urllib.parse.unquote(user_email)
        current_user_email = get_user_email(user)

        user_obj = await auth_service.unlock_user_account(decoded_email, unlocked_by=current_user_email)
        admin_count = await auth_service.count_active_admin_users()
        return HTMLResponse(content=_render_user_card_html(user_obj, current_user_email, admin_count, root_path))
    except ValueError as exc:
        return HTMLResponse(content=f'<div class="text-red-500">{html.escape(str(exc))}</div>', status_code=404)
    except Exception as exc:
        LOGGER.error("Error unlocking user %s: %s", user_email, exc)
        return HTMLResponse(content=f'<div class="text-red-500">Error unlocking user: {html.escape(str(exc))}</div>', status_code=400)


@admin_router.post("/users/{user_email}/force-password-change")
@require_permission("admin.user_management", allow_admin_bypass=False)
async def admin_force_password_change(
    user_email: str,
    _request: Request,
    db: Session = Depends(get_db),
    user=Depends(get_current_user_with_permissions),
) -> HTMLResponse:
    """Force user to change password on next login.

    Args:
        user_email: Email of user to force password change
        _request: FastAPI request object
        db: Database session
        user: Current authenticated user context

    Returns:
        HTMLResponse: Updated user card with success message

    Examples:
        >>> from unittest.mock import MagicMock, AsyncMock
        >>> from fastapi import Request
        >>> from fastapi.responses import HTMLResponse
        >>>
        >>> # Mock request
        >>> mock_request = MagicMock(spec=Request)
        >>> mock_request.scope = {"root_path": "/test"}
        >>>
        >>> # Mock database
        >>> mock_db = MagicMock()
        >>>
        >>> # Mock user context
        >>> mock_user = MagicMock()
        >>> mock_user.email = "admin@example.com"
        >>>
        >>> import asyncio
        >>> async def test_force_password_change():
        ...     # Note: Full test requires email_auth_enabled and valid user
        ...     return True  # Simplified test due to dependencies
        >>>
        >>> asyncio.run(test_force_password_change())
        True
    """
    if not settings.email_auth_enabled:
        return HTMLResponse(content='<div class="text-red-500">Email authentication is disabled</div>', status_code=403)

    try:
        # Get root path for URL construction
        root_path = _resolve_root_path(_request) if _request else ""

        auth_service = EmailAuthService(db)

        # URL decode the email
        decoded_email = urllib.parse.unquote(user_email)

        # Get current user email from JWT
        current_user_email = get_user_email(user)

        # Get the user to update
        user_obj = await auth_service.get_user_by_email(decoded_email)
        if not user_obj:
            return HTMLResponse(content='<div class="text-red-500">User not found</div>', status_code=404)

        # Set password_change_required flag
        user_obj.password_change_required = True
        db.commit()

        LOGGER.info(f"Admin {current_user_email} forced password change for user {decoded_email}")

        admin_count = await auth_service.count_active_admin_users()
        return HTMLResponse(content=_render_user_card_html(user_obj, current_user_email, admin_count, root_path))

    except Exception as e:
        LOGGER.error(f"Error forcing password change for user {user_email}: {e}")
        return HTMLResponse(content=f'<div class="text-red-500">Error forcing password change: {html.escape(str(e))}</div>', status_code=400)


@admin_router.get("/tools", response_model=PaginatedResponse)
@require_permission("tools.read", allow_admin_bypass=False)
async def admin_list_tools(
    page: int = Query(1, ge=1, description="Page number (1-indexed)"),
    per_page: int = Query(settings.pagination_default_page_size, ge=1, le=settings.pagination_max_page_size, description="Items per page"),
    include_inactive: bool = False,
    db: Session = Depends(get_db),
    user=Depends(get_current_user_with_permissions),
) -> Dict[str, Any]:
    """
    List tools for the admin UI with pagination support.

    This endpoint retrieves a paginated list of tools from the database, optionally
    including those that are inactive. Uses offset-based (page/per_page) pagination.

    Args:
        page (int): Page number (1-indexed). Default: 1.
        per_page (int): Items per page. Default: 50.
        include_inactive (bool): Whether to include inactive tools in the results.
        db (Session): Database session dependency.
        user (str): Authenticated user dependency.

    Returns:
        Dict with 'data', 'pagination', and 'links' keys containing paginated tools.

    """
    LOGGER.debug(f"User {get_user_email(user)} requested tool list (page={page}, per_page={per_page})")
    user_email = get_user_email(user)
    _is_admin = bool(user.get("is_admin", False) if isinstance(user, dict) else getattr(user, "is_admin", False))
    _team_roles = _get_user_team_roles(db, user_email) if not _is_admin else {}

    # Call tool_service.list_tools with page-based pagination
    paginated_result = await tool_service.list_tools(
        db=db,
        include_inactive=include_inactive,
        page=page,
        per_page=per_page,
        user_email=user_email,
        requesting_user_email=user_email,
        requesting_user_is_admin=_is_admin,
        requesting_user_team_roles=_team_roles,
    )

    # End the read-only transaction early to avoid idle-in-transaction under load.
    db.commit()

    # Return standardized paginated response
    return {
        "data": [tool.model_dump(by_alias=True) for tool in paginated_result["data"]],
        "pagination": paginated_result["pagination"].model_dump(),
        "links": paginated_result["links"].model_dump() if paginated_result["links"] else None,
    }


@admin_router.get("/tools/partial", response_class=HTMLResponse)
@require_permission("tools.read", allow_admin_bypass=False)
async def admin_tools_partial_html(
    request: Request,
    page: int = Query(1, ge=1, description="Page number (1-indexed)"),
    per_page: int = Query(settings.pagination_default_page_size, ge=1, le=settings.pagination_max_page_size, description="Items per page"),
    q: str = Query("", max_length=500, description="Search query"),
    tags: QueryTagsFilter = None,
    include_inactive: bool = False,
    render: QueryRenderModeControls = None,
    gateway_id: QueryGatewayIdList = None,
    team_id: Optional[str] = Depends(_validated_team_id_param),
    include_public: bool = False,
    db: Session = Depends(get_db),
    user=Depends(get_current_user_with_permissions),
):
    """
    Return HTML partial for paginated tools list (HTMX endpoint).

    This endpoint returns only the table body rows and pagination controls
    for HTMX-based pagination in the admin UI.

    Args:
        request (Request): FastAPI request object.
        page (int): Page number (1-indexed). Default: 1.
        per_page (int): Items per page. Default: 50.
        q (str): Free-text query string.
        tags (Optional[str]): Tag filter expression (comma=OR, plus=AND).
        include_inactive (bool): Whether to include inactive tools in the results.
        gateway_id (Optional[str]): Filter by gateway ID(s), comma-separated.
        team_id (Optional[str]): Filter by team ID.
        include_public (bool): Whether to include all public tools in the results.
        render (str): Render mode - 'controls' returns only pagination controls.
        db (Session): Database session dependency.
        user (str): Authenticated user dependency.

    Returns:
        HTMLResponse with tools table rows and pagination controls.
    """
    user_email = get_user_email(user)
    search_query = _normalize_search_query(q)
    normalized_tags = _normalize_tags_query(tags)
    tag_groups = _parse_tag_filter_groups(normalized_tags)
    LOGGER.debug(f"🔧 TOOLS PARTIAL REQUEST - User: {user_email}, team_id: {team_id}, page: {page}, render: {render}, referer: {request.headers.get('referer', 'none')}")

    # Build base query using tool_service's team filtering logic
    team_ids = await _get_user_team_ids(user, db)

    # Build query with eager loading for email_team to avoid N+1 queries
    query = select(DbTool).options(joinedload(DbTool.email_team))

    # Apply gateway filter if provided. Support special sentinel 'null' to
    # request tools with NULL gateway_id (e.g., RestTool/no gateway).
    if gateway_id:
        gateway_ids = [gid.strip() for gid in gateway_id.split(",") if gid.strip()]
        if gateway_ids:
            # Treat literal 'null' (case-insensitive) as a request for NULL gateway_id
            null_requested = any(gid.lower() == "null" for gid in gateway_ids)
            non_null_ids = [gid for gid in gateway_ids if gid.lower() != "null"]
            if non_null_ids and null_requested:
                query = query.where(or_(DbTool.gateway_id.in_(non_null_ids), DbTool.gateway_id.is_(None)))
                LOGGER.debug(f"Filtering tools by gateway IDs (including NULL): {non_null_ids} + NULL")
            elif null_requested:
                query = query.where(DbTool.gateway_id.is_(None))
                LOGGER.debug("Filtering tools by NULL gateway_id (RestTool)")
            else:
                query = query.where(DbTool.gateway_id.in_(non_null_ids))
                LOGGER.debug(f"Filtering tools by gateway IDs: {non_null_ids}")

    # Apply active/inactive filter
    if not include_inactive:
        query = query.where(DbTool.enabled.is_(True))

    # Build access conditions
    # When team_id is specified, show ONLY items from that team (simpler, team-scoped view)
    # When team_id + include_public, show team items PLUS public items from all teams
    # When team_id is NOT specified, show all accessible items (owned + team + public)
    if team_id:
        # Team-specific view: only show tools from the specified team if user is a member
        if team_id in team_ids:
            # Apply visibility check: team/public resources + user's own resources (including private)
            team_access = [
                and_(DbTool.team_id == team_id, DbTool.visibility.in_(["team", "public"])),
                and_(DbTool.team_id == team_id, DbTool.owner_email == user_email),
            ]
            if include_public:
                # Include all globally public items from any team.
                # Items with visibility='team' or 'private' from other teams are
                # blocked by the other conditions (which require team_id == selected team).
                team_access.append(DbTool.visibility == "public")
            query = query.where(or_(*team_access))
            LOGGER.debug(f"Filtering tools by team_id: {team_id}{' (include_public)' if include_public else ''}")
        else:
            # User is not a member of this team, return no results
            LOGGER.warning(f"User {user_email} attempted to filter by team {team_id} but is not a member")
            query = query.where(false())
    else:
        # All Teams view: apply standard access conditions
        access_conditions = []

        # 1. User's personal tools (owner_email matches)
        access_conditions.append(_owner_access_condition(DbTool.owner_email, DbTool.team_id, user_email=user_email, team_ids=team_ids, user=user))

        # 2. Team tools where user is member
        if team_ids:
            access_conditions.append(and_(DbTool.team_id.in_(team_ids), DbTool.visibility.in_(["team", "public"])))

        # 3. Public tools
        access_conditions.append(DbTool.visibility == "public")

        query = query.where(or_(*access_conditions))

    if search_query:
        query = query.where(
            or_(
                _like_contains(func.lower(DbTool.id), search_query),
                _like_contains(func.lower(DbTool.original_name), search_query),
                _like_contains(func.lower(coalesce(DbTool.display_name, "")), search_query),
                _like_contains(func.lower(coalesce(DbTool.custom_name, "")), search_query),
                _like_contains(func.lower(coalesce(DbTool.description, "")), search_query),
                _like_contains(func.lower(coalesce(DbTool.url, "")), search_query),
            )
        )

    query = _apply_tag_filter_groups(query, db, DbTool.tags, tag_groups)

    # Apply sorting: alphabetical by URL, then name, then ID (for UI display)
    # Different from JSON endpoint which uses created_at DESC
    query = query.order_by(DbTool.url, DbTool.original_name, DbTool.id)

    # Use unified pagination function (offset-based for UI compatibility)
    root_path = _resolve_root_path(request)
    base_url = f"{root_path}/admin/tools/partial"
    query_params_dict = {}
    if include_inactive:
        query_params_dict["include_inactive"] = "true"
    if gateway_id:
        query_params_dict["gateway_id"] = gateway_id
    if team_id:
        query_params_dict["team_id"] = team_id
    if search_query:
        query_params_dict["q"] = search_query
    if normalized_tags:
        query_params_dict["tags"] = normalized_tags

    paginated_result = await paginate_query(
        db=db,
        query=query,
        page=page,
        per_page=per_page,
        cursor=None,  # UI uses offset pagination only
        base_url=base_url,
        query_params=query_params_dict,
        use_cursor_threshold=False,  # Disable auto-cursor switching for UI
    )

    # Extract paginated tools (DbTool objects)
    tools_db = paginated_result["data"]
    pagination = paginated_result["pagination"]
    links = paginated_result["links"]

    # Team names are loaded via joinedload(DbTool.email_team) in the query
    # Batch convert to Pydantic models using tool service
    # This eliminates the N+1 query problem from calling get_tool() in a loop
    _is_admin = bool(user.get("is_admin", False) if isinstance(user, dict) else getattr(user, "is_admin", False))
    _team_roles = _get_user_team_roles(db, user_email) if not _is_admin else {}
    tools_pydantic = []
    failed_count = 0
    for t in tools_db:
        try:
            tools_pydantic.append(
                tool_service.convert_tool_to_read(
                    t,
                    include_metrics=False,
                    include_auth=False,
                    requesting_user_email=user_email,
                    requesting_user_is_admin=_is_admin,
                    requesting_user_team_roles=_team_roles,
                )
            )
        except (ValidationError, ValueError, KeyError, TypeError, binascii.Error) as e:
            failed_count += 1
            LOGGER.exception(f"Failed to convert tool {getattr(t, 'id', 'unknown')} ({getattr(t, 'name', 'unknown')}): {e}")
    _adjust_pagination_for_conversion_failures(pagination, failed_count, len(tools_pydantic))

    # Serialize tools
    data = jsonable_encoder(tools_pydantic)

    # End the read-only transaction before template rendering to avoid idle-in-transaction timeouts.
    db.commit()

    # If render=controls, return only pagination controls
    if render == "controls":
        return request.app.state.templates.TemplateResponse(
            request,
            "pagination_controls.html",
            {
                "request": request,
                "pagination": pagination.model_dump(),
                "base_url": base_url,
                "hx_target": "#tools-table-body",
                "hx_indicator": "#tools-loading",
                "query_params": query_params_dict,
                "root_path": _resolve_root_path(request),
            },
        )

    # If render=selector, return tool selector items for infinite scroll
    if render == "selector":
        return request.app.state.templates.TemplateResponse(
            request,
            "tools_selector_items.html",
            {
                "request": request,
                "data": data,
                "pagination": pagination.model_dump(),
                "root_path": _resolve_root_path(request),
                "gateway_id": gateway_id,
                "team_id": team_id,
                "include_public": include_public,
            },
        )

    # Render template with paginated data
    return request.app.state.templates.TemplateResponse(
        request,
        "tools_partial.html",
        {
            "request": request,
            "data": data,
            "pagination": pagination.model_dump(),
            "links": links.model_dump() if links else None,
            "root_path": _resolve_root_path(request),
            "include_inactive": include_inactive,
            "query_params": query_params_dict,
            "current_user_email": user_email,
            "is_admin": _is_admin,
            "user_team_roles": _team_roles,
        },
    )


@admin_router.get("/tool-ops/partial", response_class=HTMLResponse)
@require_permission("tools.read", allow_admin_bypass=False)
async def admin_tool_ops_partial(
    request: Request,
    page: int = Query(1, ge=1, description="Page number"),
    per_page: int = Query(settings.pagination_default_page_size, ge=1, le=settings.pagination_max_page_size, description="Items per page"),
    include_inactive: bool = False,
    gateway_id: QueryGatewayIdList = None,
    team_id: Optional[str] = Depends(_validated_team_id_param),
    db: Session = Depends(get_db),
    user=Depends(get_current_user_with_permissions),
):
    """
    Return HTML partial for tool operations table.

    Args:
        request (Request): The request object.
        page (int): The page number. Defaults to 1.
        per_page (int): The number of items per page. Defaults to settings.pagination_default_page_size.
        include_inactive (bool): Whether to include inactive items. Defaults to False.
        gateway_id (Optional[str]): The gateway ID to filter by. Defaults to None.
        team_id (Optional[str]): The team ID to filter by. Defaults to None.
        db (Session): The database session. Defaults to Depends(get_db).
        user (Any): The current user. Defaults to Depends(get_current_user_with_permissions).

    Returns:
        HTMLResponse: The HTML partial for the tool operations table.
    """
    user_email = get_user_email(user)
    LOGGER.debug(f"Tool ops partial request - team_id: {team_id}, page: {page}")
    team_ids = await _get_user_team_ids(user, db)

    query = select(DbTool).options(joinedload(DbTool.email_team))

    if gateway_id:
        gateway_ids = [gid.strip() for gid in gateway_id.split(",") if gid.strip()]
        if gateway_ids:
            null_requested = any(gid.lower() == "null" for gid in gateway_ids)
            non_null_ids = [gid for gid in gateway_ids if gid.lower() != "null"]
            if non_null_ids and null_requested:
                query = query.where(or_(DbTool.gateway_id.in_(non_null_ids), DbTool.gateway_id.is_(None)))
                LOGGER.debug(f"Filtering tools by gateway IDs (including NULL): {non_null_ids} + NULL")
            elif null_requested:
                query = query.where(DbTool.gateway_id.is_(None))
                LOGGER.debug("Filtering tools by NULL gateway_id (RestTool)")
            else:
                query = query.where(DbTool.gateway_id.in_(non_null_ids))
                LOGGER.debug(f"Filtering tools by gateway IDs: {non_null_ids}")

    if not include_inactive:
        query = query.where(DbTool.enabled.is_(True))

    if team_id:
        if team_id in team_ids:
            team_access = [
                and_(DbTool.team_id == team_id, DbTool.visibility.in_(["team", "public"])),
                and_(DbTool.team_id == team_id, DbTool.owner_email == user_email),
            ]
            query = query.where(or_(*team_access))
            LOGGER.debug(f"Filtering tools by team_id: {team_id}")
        else:
            LOGGER.warning(f"User {user_email} attempted to filter by team {team_id} but is not a member")
            query = query.where(false())
    else:
        access_conditions = []
        access_conditions.append(_owner_access_condition(DbTool.owner_email, DbTool.team_id, user_email=user_email, team_ids=team_ids, user=user))
        if team_ids:
            access_conditions.append(and_(DbTool.team_id.in_(team_ids), DbTool.visibility.in_(["team", "public"])))
        access_conditions.append(DbTool.visibility == "public")
        query = query.where(or_(*access_conditions))

    query = query.order_by(DbTool.url, DbTool.original_name, DbTool.id)

    paginated_result = await paginate_query(
        db=db,
        query=query,
        page=page,
        per_page=per_page,
        cursor=None,
        base_url=f"{_resolve_root_path(request)}/admin/tool-ops/partial",
        query_params={
            "include_inactive": "true" if include_inactive else "false",
            "gateway_id": gateway_id or "",
            "team_id": team_id or "",
        },
        use_cursor_threshold=False,
    )

    tools_db = paginated_result["data"]
    _is_admin = bool(user.get("is_admin", False) if isinstance(user, dict) else getattr(user, "is_admin", False))
    _team_roles = _get_user_team_roles(db, user_email) if not _is_admin else {}
    tools_pydantic = [
        tool_service.convert_tool_to_read(
            t,
            include_metrics=False,
            include_auth=False,
            requesting_user_email=user_email,
            requesting_user_is_admin=_is_admin,
            requesting_user_team_roles=_team_roles,
        )
        for t in tools_db
    ]
    db.commit()

    return request.app.state.templates.TemplateResponse(
        request,
        "toolops_partial.html",
        {
            "request": request,
            "tools": tools_pydantic,
            "root_path": _resolve_root_path(request),
            "current_user_email": user_email,
            "is_admin": _is_admin,
            "user_team_roles": _team_roles,
        },
    )


@admin_router.get("/tools/ids", response_class=JSONResponse)
@require_permission("tools.read", allow_admin_bypass=False)
async def admin_get_all_tool_ids(
    q: str = Query("", max_length=500, description="Search query"),
    include_inactive: bool = False,
    gateway_id: QueryGatewayIdList = None,
    team_id: Optional[str] = Depends(_validated_team_id_param),
    include_public: bool = False,
    db: Session = Depends(get_db),
    user=Depends(get_current_user_with_permissions),
):
    """
    Return all tool IDs accessible to the current user.

    This is used by "Select All" to get all tool IDs without loading full data.

    Args:
        q (str): Search query to filter tools by name, ID, or description
        include_inactive (bool): Whether to include inactive tools in the results
        gateway_id (Optional[str]): Filter by gateway ID(s), comma-separated. Accepts the literal value 'null' to indicate NULL gateway_id (local tools).
        team_id (Optional[str]): Filter by team ID.
        include_public (bool): Whether to include all platform-public tools when filtering by team.
        db (Session): Database session dependency
        user: Current user making the request

    Returns:
        JSONResponse: List of tool IDs accessible to the user
    """
    user_email = get_user_email(user)

    # Build base query
    team_ids = await _get_user_team_ids(user, db)

    query = select(DbTool.id)

    if not include_inactive:
        query = query.where(DbTool.enabled.is_(True))

    # Apply search filter if provided
    if q:
        search_query = _normalize_search_query(q)
        if search_query:
            search_conditions = [
                _like_contains(func.lower(DbTool.id), search_query),
                _like_contains(func.lower(DbTool.original_name), search_query),
                _like_contains(func.lower(coalesce(DbTool.display_name, "")), search_query),
                _like_contains(func.lower(coalesce(DbTool.custom_name, "")), search_query),
                _like_contains(func.lower(coalesce(DbTool.description, "")), search_query),
                _like_contains(func.lower(coalesce(DbTool.url, "")), search_query),
            ]
            query = query.where(or_(*search_conditions))
            LOGGER.debug(f"Filtering tool IDs by search query: {search_query}")

    # Apply optional gateway/server scoping (comma-separated ids). Accepts the
    # literal value 'null' to indicate NULL gateway_id (local tools).
    if gateway_id:
        gateway_ids = [gid.strip() for gid in gateway_id.split(",") if gid.strip()]
        if gateway_ids:
            null_requested = any(gid.lower() == "null" for gid in gateway_ids)
            non_null_ids = [gid for gid in gateway_ids if gid.lower() != "null"]
            if non_null_ids and null_requested:
                query = query.where(or_(DbTool.gateway_id.in_(non_null_ids), DbTool.gateway_id.is_(None)))
                LOGGER.debug(f"Filtering tools by gateway IDs (including NULL): {non_null_ids} + NULL")
            elif null_requested:
                query = query.where(DbTool.gateway_id.is_(None))
                LOGGER.debug("Filtering tools by NULL gateway_id (local tools)")
            else:
                query = query.where(DbTool.gateway_id.in_(non_null_ids))
                LOGGER.debug(f"Filtering tools by gateway IDs: {non_null_ids}")

    # Build access conditions
    # When team_id is specified, show items from that team; optionally include
    # platform-public items when include_public is set (mirrors the partial endpoint).
    # Otherwise, show all accessible items (All Teams view).
    if team_id:
        if team_id in team_ids:
            team_access = [
                and_(DbTool.team_id == team_id, DbTool.visibility.in_(["team", "public"])),
                and_(DbTool.team_id == team_id, DbTool.owner_email == user_email),
            ]
            if include_public:
                team_access.append(DbTool.visibility == "public")
            query = query.where(or_(*team_access))
            LOGGER.debug(f"Filtering tool IDs by team_id: {team_id}{' (include_public)' if include_public else ''}")
        else:
            LOGGER.warning(f"User {user_email} attempted to filter tool IDs by team {team_id} but is not a member")
            query = query.where(false())
    else:
        # All Teams view: apply standard access conditions (owner, team, public)
        access_conditions = []
        access_conditions.append(_owner_access_condition(DbTool.owner_email, DbTool.team_id, user_email=user_email, team_ids=team_ids, user=user))
        access_conditions.append(DbTool.visibility == "public")
        if team_ids:
            access_conditions.append(and_(DbTool.team_id.in_(team_ids), DbTool.visibility.in_(["team", "public"])))
        query = query.where(or_(*access_conditions))

    # Get all IDs
    tool_ids = [row[0] for row in db.execute(query).all()]

    return {"tool_ids": tool_ids, "count": len(tool_ids)}


@admin_router.get("/tools/search", response_class=JSONResponse)
@require_permission("tools.read", allow_admin_bypass=False)
async def admin_search_tools(
    q: str = Query("", max_length=500, description="Search query"),
    tags: QueryTagsFilter = None,
    include_inactive: bool = False,
    limit: int = Query(settings.pagination_default_page_size, ge=1, le=settings.pagination_max_page_size, description="Maximum number of results to return"),
    gateway_id: QueryGatewayIdList = None,
    team_id: Optional[str] = Depends(_validated_team_id_param),
    include_public: bool = False,
    db: Session = Depends(get_db),
    user=Depends(get_current_user_with_permissions),
):
    """
    Search tools by name, ID, or description.

    This endpoint searches tools across all accessible tools for the current user,
    returning both IDs and names for use in search functionality like the Add Server page.

    Args:
        q (str): Search query string to match against tool names, IDs, or descriptions.
        tags (Optional[str]): Tag filter expression (comma=OR, plus=AND).
        include_inactive (bool): Whether to include inactive tools in the search results.
        limit (int): Maximum number of results to return.
        gateway_id (Optional[str]): Filter by gateway ID(s), comma-separated.
        team_id (Optional[str]): Filter by team ID.
        include_public (bool): Whether to include all public tools in the results.
        db (Session): Database session.
        user: Current user with permissions.

    Returns:
        JSONResponse: A JSON response containing a list of matching tools.
    """
    user_email = get_user_email(user)
    search_query = _normalize_search_query(q)
    normalized_tags = _normalize_tags_query(tags)
    tag_groups = _parse_tag_filter_groups(normalized_tags)

    if not search_query and not tag_groups:
        return _build_search_response(entity_key="tools", entity_type="tools", items=[], query=search_query, tags=normalized_tags, tag_groups=tag_groups)

    # Build base query
    team_ids = await _get_user_team_ids(user, db)

    query = select(DbTool.id, DbTool.original_name, DbTool.custom_name, DbTool.display_name, DbTool.description)

    # Apply gateway filter if provided. Support special sentinel 'null' to
    # request tools with NULL gateway_id (e.g., RestTool/no gateway).
    if gateway_id:
        gateway_ids = [gid.strip() for gid in gateway_id.split(",") if gid.strip()]
        if gateway_ids:
            # Treat literal 'null' (case-insensitive) as a request for NULL gateway_id
            null_requested = any(gid.lower() == "null" for gid in gateway_ids)
            non_null_ids = [gid for gid in gateway_ids if gid.lower() != "null"]
            if non_null_ids and null_requested:
                query = query.where(or_(DbTool.gateway_id.in_(non_null_ids), DbTool.gateway_id.is_(None)))
                LOGGER.debug(f"Filtering tool search by gateway IDs (including NULL): {non_null_ids} + NULL")
            elif null_requested:
                query = query.where(DbTool.gateway_id.is_(None))
                LOGGER.debug("Filtering tool search by NULL gateway_id (RestTool)")
            else:
                query = query.where(DbTool.gateway_id.in_(non_null_ids))
                LOGGER.debug(f"Filtering tool search by gateway IDs: {non_null_ids}")

    if not include_inactive:
        query = query.where(DbTool.enabled.is_(True))

    # Build access conditions
    # When team_id is specified, show ONLY items from that team (team-scoped view)
    # When team_id + include_public, show team items PLUS public items from all teams
    # Otherwise, show all accessible items (All Teams view)
    if team_id:
        if team_id in team_ids:
            # Apply visibility check: team/public resources + user's own resources (including private)
            team_access = [
                and_(DbTool.team_id == team_id, DbTool.visibility.in_(["team", "public"])),
                and_(DbTool.team_id == team_id, DbTool.owner_email == user_email),
            ]
            if include_public:
                team_access.append(DbTool.visibility == "public")
            query = query.where(or_(*team_access))
            LOGGER.debug(f"Filtering tool search by team_id: {team_id}{' (include_public)' if include_public else ''}")
        else:
            LOGGER.warning(f"User {user_email} attempted to filter tool search by team {team_id} but is not a member")
            query = query.where(false())
    else:
        # All Teams view: apply standard access conditions (owner, team, public)
        access_conditions = []
        access_conditions.append(_owner_access_condition(DbTool.owner_email, DbTool.team_id, user_email=user_email, team_ids=team_ids, user=user))
        access_conditions.append(DbTool.visibility == "public")
        if team_ids:
            access_conditions.append(and_(DbTool.team_id.in_(team_ids), DbTool.visibility.in_(["team", "public"])))
        query = query.where(or_(*access_conditions))

    # Add search conditions - search in display fields and description
    # Using the same priority as display: displayName -> customName -> original_name
    if search_query:
        search_conditions = [
            _like_contains(func.lower(DbTool.id), search_query),
            _like_contains(func.lower(DbTool.original_name), search_query),
            _like_contains(func.lower(coalesce(DbTool.display_name, "")), search_query),
            _like_contains(func.lower(coalesce(DbTool.custom_name, "")), search_query),
            _like_contains(func.lower(coalesce(DbTool.description, "")), search_query),
            _like_contains(func.lower(coalesce(DbTool.url, "")), search_query),
        ]
        query = query.where(or_(*search_conditions))

    query = _apply_tag_filter_groups(query, db, DbTool.tags, tag_groups)

    # Order by relevance - prioritize matches at start of names
    if search_query:
        query = query.order_by(
            case(
                (func.lower(DbTool.original_name).startswith(search_query), 1),
                (func.lower(coalesce(DbTool.custom_name, "")).startswith(search_query), 1),
                (func.lower(coalesce(DbTool.display_name, "")).startswith(search_query), 1),
                else_=2,
            ),
            func.lower(DbTool.original_name),
        )
    else:
        query = query.order_by(func.lower(DbTool.original_name))
    query = query.limit(limit)

    # Execute query
    results = db.execute(query).all()

    # Format results
    tools = []
    for row in results:
        tools.append({"id": row.id, "name": row.original_name, "display_name": row.display_name, "custom_name": row.custom_name})  # original_name for search matching

    return _build_search_response(entity_key="tools", entity_type="tools", items=tools, query=search_query, tags=normalized_tags, tag_groups=tag_groups)


@admin_router.get("/prompts/partial", response_class=HTMLResponse)
@require_permission("prompts.read", allow_admin_bypass=False)
async def admin_prompts_partial_html(
    request: Request,
    page: int = Query(1, ge=1, description="Page number (1-indexed)"),
    per_page: int = Query(settings.pagination_default_page_size, ge=1, le=settings.pagination_max_page_size, description="Items per page"),
    q: str = Query("", max_length=500, description="Search query"),
    tags: QueryTagsFilter = None,
    include_inactive: bool = False,
    render: QueryRenderMode = None,
    gateway_id: QueryGatewayIdList = None,
    team_id: Optional[str] = Depends(_validated_team_id_param),
    include_public: bool = False,
    db: Session = Depends(get_db),
    user=Depends(get_current_user_with_permissions),
):
    """Return paginated prompts HTML partials for the admin UI.

    This HTMX endpoint returns only the partial HTML used by the admin UI for
    prompts. It supports three render modes:

    - default: full table partial (rows + controls)
    - ``render="controls"``: return only pagination controls
    - ``render="selector"``: return selector items for infinite scroll

    Args:
        request (Request): FastAPI request object used by the template engine.
        page (int): Page number (1-indexed).
        per_page (int): Number of items per page (bounded by settings).
        q (str): Free-text query string.
        tags (Optional[str]): Tag filter expression (comma=OR, plus=AND).
        include_inactive (bool): If True, include inactive prompts in results.
        render (Optional[str]): Render mode; one of None, "controls", "selector".
        gateway_id (Optional[str]): Filter by gateway ID(s), comma-separated.
        team_id (Optional[str]): Filter by team ID.
        include_public (bool): Whether to include all public prompts in the results.
        db (Session): Database session (dependency-injected).
        user: Authenticated user object from dependency injection.

    Returns:
        Union[HTMLResponse, TemplateResponse]: A rendered template response
        containing either the table partial, pagination controls, or selector
        items depending on ``render``. The response contains JSON-serializable
        encoded prompt data when templates expect it.
    """
    LOGGER.debug(
        f"User {get_user_email(user)} requested prompts HTML partial (page={page}, per_page={per_page}, include_inactive={include_inactive}, render={render}, gateway_id={gateway_id}, team_id={team_id})"
    )
    search_query = _normalize_search_query(q)
    normalized_tags = _normalize_tags_query(tags)
    tag_groups = _parse_tag_filter_groups(normalized_tags)
    # Normalize per_page within configured bounds
    per_page = max(settings.pagination_min_page_size, min(per_page, settings.pagination_max_page_size))

    user_email = get_user_email(user)

    # Team scoping
    team_ids = await _get_user_team_ids(user, db)

    # Build base query
    query = select(DbPrompt)

    # Apply gateway filter if provided
    if gateway_id:
        gateway_ids = [gid.strip() for gid in gateway_id.split(",") if gid.strip()]
        if gateway_ids:
            null_requested = any(gid.lower() == "null" for gid in gateway_ids)
            non_null_ids = [gid for gid in gateway_ids if gid.lower() != "null"]
            if non_null_ids and null_requested:
                query = query.where(or_(DbPrompt.gateway_id.in_(non_null_ids), DbPrompt.gateway_id.is_(None)))
                LOGGER.debug(f"Filtering prompts by gateway IDs (including NULL): {non_null_ids} + NULL")
            elif null_requested:
                query = query.where(DbPrompt.gateway_id.is_(None))
                LOGGER.debug("Filtering prompts by NULL gateway_id (RestTool)")
            else:
                query = query.where(DbPrompt.gateway_id.in_(non_null_ids))
                LOGGER.debug(f"Filtering prompts by gateway IDs: {non_null_ids}")

    if not include_inactive:
        query = query.where(DbPrompt.enabled.is_(True))

    # Build access conditions
    # When team_id is specified, show ONLY items from that team (team-scoped view)
    # When team_id + include_public, show team items PLUS public items from all teams
    # Otherwise, show all accessible items (All Teams view)
    if team_id:
        # Team-specific view: only show prompts from the specified team
        if team_id in team_ids:
            # Apply visibility check: team/public resources + user's own resources (including private)
            team_access = [
                and_(DbPrompt.team_id == team_id, DbPrompt.visibility.in_(["team", "public"])),
                and_(DbPrompt.team_id == team_id, DbPrompt.owner_email == user_email),
            ]
            if include_public:
                # Include all globally public items from any team.
                # Items with visibility='team' or 'private' from other teams are
                # blocked by the other conditions (which require team_id == selected team).
                team_access.append(DbPrompt.visibility == "public")
            query = query.where(or_(*team_access))
            LOGGER.debug(f"Filtering prompts by team_id: {team_id}{' (include_public)' if include_public else ''}")
        else:
            # User is not a member of this team, return no results using SQLAlchemy's false()
            LOGGER.warning(f"User {user_email} attempted to filter by team {team_id} but is not a member")
            query = query.where(false())
    else:
        # All Teams view: apply standard access conditions (owner, team, public)
        access_conditions = []
        access_conditions.append(_owner_access_condition(DbPrompt.owner_email, DbPrompt.team_id, user_email=user_email, team_ids=team_ids, user=user))
        if team_ids:
            access_conditions.append(and_(DbPrompt.team_id.in_(team_ids), DbPrompt.visibility.in_(["team", "public"])))
        access_conditions.append(DbPrompt.visibility == "public")
        query = query.where(or_(*access_conditions))

    if search_query:
        query = query.where(
            or_(
                _like_contains(func.lower(DbPrompt.id), search_query),
                _like_contains(func.lower(DbPrompt.original_name), search_query),
                _like_contains(func.lower(coalesce(DbPrompt.display_name, "")), search_query),
                _like_contains(func.lower(coalesce(DbPrompt.description, "")), search_query),
            )
        )

    query = _apply_tag_filter_groups(query, db, DbPrompt.tags, tag_groups)

    # Apply pagination ordering for cursor support
    query = query.order_by(desc(DbPrompt.created_at), desc(DbPrompt.id))

    # Build query params for pagination links
    query_params = {}
    if include_inactive:
        query_params["include_inactive"] = "true"
    if gateway_id:
        query_params["gateway_id"] = gateway_id
    if team_id:
        query_params["team_id"] = team_id
    if search_query:
        query_params["q"] = search_query
    if normalized_tags:
        query_params["tags"] = normalized_tags

    # Use unified pagination function
    root_path = _resolve_root_path(request)
    base_url = f"{root_path}/admin/prompts/partial"
    paginated_result = await paginate_query(
        db=db,
        query=query,
        page=page,
        per_page=per_page,
        cursor=None,  # HTMX partials use page-based navigation
        base_url=base_url,
        query_params=query_params,
        use_cursor_threshold=False,  # Disable auto-cursor switching for UI
    )

    # Extract paginated prompts (DbPrompt objects)
    prompts_db = paginated_result["data"]
    pagination = paginated_result["pagination"]
    links = paginated_result["links"]

    # Batch fetch team names for the prompts to avoid N+1 queries
    team_ids_set = {p.team_id for p in prompts_db if p.team_id}
    team_map = {}
    if team_ids_set:
        teams = db.execute(select(EmailTeam.id, EmailTeam.name).where(EmailTeam.id.in_(team_ids_set), EmailTeam.is_active.is_(True))).all()
        team_map = {team.id: team.name for team in teams}

    # Apply team names to DB objects before conversion
    for p in prompts_db:
        p.team = team_map.get(p.team_id) if p.team_id else None

    # Batch convert to Pydantic models using prompt service
    # This eliminates the N+1 query problem from calling get_prompt_details() in a loop
    prompts_pydantic = []
    failed_count = 0
    for p in prompts_db:
        try:
            prompts_pydantic.append(prompt_service.convert_prompt_to_read(p, include_metrics=False))
        except (ValidationError, ValueError, KeyError, TypeError, binascii.Error) as e:
            failed_count += 1
            LOGGER.exception(f"Failed to convert prompt {getattr(p, 'id', 'unknown')} ({getattr(p, 'name', 'unknown')}): {e}")
    _adjust_pagination_for_conversion_failures(pagination, failed_count, len(prompts_pydantic))

    data = jsonable_encoder(prompts_pydantic)

    # End the read-only transaction before template rendering to avoid idle-in-transaction timeouts.
    db.commit()

    if render == "controls":
        return request.app.state.templates.TemplateResponse(
            request,
            "pagination_controls.html",
            {
                "request": request,
                "pagination": pagination.model_dump(),
                "base_url": base_url,
                "hx_target": "#prompts-table-body",
                "hx_indicator": "#prompts-loading",
                "query_params": query_params,
                "root_path": _resolve_root_path(request),
            },
        )

    if render == "selector":
        return request.app.state.templates.TemplateResponse(
            request,
            "prompts_selector_items.html",
            {
                "request": request,
                "data": data,
                "pagination": pagination.model_dump(),
                "root_path": _resolve_root_path(request),
                "gateway_id": gateway_id,
                "team_id": team_id,
                "include_public": include_public,
            },
        )

    _is_admin = bool(user.get("is_admin", False) if isinstance(user, dict) else getattr(user, "is_admin", False))
    _team_roles = _get_user_team_roles(db, user_email) if not _is_admin else {}
    return request.app.state.templates.TemplateResponse(
        request,
        "prompts_partial.html",
        {
            "request": request,
            "data": data,
            "pagination": pagination.model_dump(),
            "links": links.model_dump() if links else None,
            "root_path": _resolve_root_path(request),
            "include_inactive": include_inactive,
            "query_params": query_params,
            "current_user_email": user_email,
            "is_admin": _is_admin,
            "user_team_roles": _team_roles,
        },
    )


@admin_router.get("/gateways/partial", response_class=HTMLResponse)
@require_permission("gateways.read", allow_admin_bypass=False)
async def admin_gateways_partial_html(
    request: Request,
    page: int = Query(1, ge=1, description="Page number (1-indexed)"),
    per_page: int = Query(settings.pagination_default_page_size, ge=1, le=settings.pagination_max_page_size, description="Items per page"),
    q: str = Query("", max_length=500, description="Search query"),
    tags: QueryTagsFilter = None,
    include_inactive: bool = True,
    render: QueryRenderMode = None,
    team_id: Optional[str] = Depends(_validated_team_id_param),
    include_public: bool = False,
    db: Session = Depends(get_db),
    user=Depends(get_current_user_with_permissions),
):
    """Return paginated gateways HTML partials for the admin UI.

    This HTMX endpoint returns only the partial HTML used by the admin UI for
    gateways. It supports three render modes:

    - default: full table partial (rows + controls)
    - ``render="controls"``: return only pagination controls
    - ``render="selector"``: return selector items for infinite scroll

    Args:
        request (Request): FastAPI request object used by the template engine.
        page (int): Page number (1-indexed).
        per_page (int): Number of items per page (bounded by settings).
        q (str): Free-text query string.
        tags (Optional[str]): Tag filter expression (comma=OR, plus=AND).
        include_inactive (bool): If True, include inactive gateways in results.
        render (Optional[str]): Render mode; one of None, "controls", "selector".
        team_id (Optional[str]): Filter by team ID.
        include_public (bool): Whether to include all public gateways in the results.
        db (Session): Database session (dependency-injected).
        user: Authenticated user object from dependency injection.

    Returns:
        Union[HTMLResponse, TemplateResponse]: A rendered template response
        containing either the table partial, pagination controls, or selector
        items depending on ``render``. The response contains JSON-serializable
        encoded gateway data when templates expect it.
    """
    user_email = get_user_email(user)
    search_query = _normalize_search_query(q)
    normalized_tags = _normalize_tags_query(tags)
    tag_groups = _parse_tag_filter_groups(normalized_tags)
    LOGGER.debug(f"🔷 GATEWAYS PARTIAL REQUEST - User: {user_email}, team_id: {team_id}, page: {page}, render: {render}, referer: {request.headers.get('referer', 'none')}")
    # Normalize per_page within configured bounds
    per_page = max(settings.pagination_min_page_size, min(per_page, settings.pagination_max_page_size))

    # Team scoping
    team_ids = await _get_user_team_ids(user, db)

    # Build base query
    query = select(DbGateway).options(joinedload(DbGateway.email_team), selectinload(DbGateway.tools))

    if not include_inactive:
        query = query.where(DbGateway.enabled.is_(True))

    # Build access conditions
    # When team_id is specified, show ONLY items from that team (simpler, team-scoped view)
    # When team_id + include_public, show team items PLUS public items from all teams
    # When team_id is NOT specified, show all accessible items (owned + team + public)
    if team_id:
        # Team-specific view: only show gateways from the specified team if user is a member
        if team_id in team_ids:
            # Apply visibility check: team/public resources + user's own resources (including private)
            team_access = [
                and_(DbGateway.team_id == team_id, DbGateway.visibility.in_(["team", "public"])),
                and_(DbGateway.team_id == team_id, DbGateway.owner_email == user_email),
            ]
            if include_public:
                # Include all globally public items from any team.
                # Items with visibility='team' or 'private' from other teams are
                # blocked by the other conditions (which require team_id == selected team).
                team_access.append(DbGateway.visibility == "public")
            query = query.where(or_(*team_access))
            LOGGER.debug(f"Filtering gateways by team_id: {team_id}{' (include_public)' if include_public else ''}")
        else:
            # User is not a member of this team, return no results
            LOGGER.warning(f"User {user_email} attempted to filter by team {team_id} but is not a member")
            query = query.where(false())
    else:
        # All Teams view: apply standard access conditions
        access_conditions = []
        access_conditions.append(_owner_access_condition(DbGateway.owner_email, DbGateway.team_id, user_email=user_email, team_ids=team_ids, user=user))
        if team_ids:
            access_conditions.append(and_(DbGateway.team_id.in_(team_ids), DbGateway.visibility.in_(["team", "public"])))
        access_conditions.append(DbGateway.visibility == "public")

        query = query.where(or_(*access_conditions))

    if search_query:
        query = query.where(
            or_(
                _like_contains(func.lower(DbGateway.id), search_query),
                _like_contains(func.lower(DbGateway.name), search_query),
                _like_contains(func.lower(coalesce(DbGateway.url, "")), search_query),
                _like_contains(func.lower(coalesce(DbGateway.description, "")), search_query),
            )
        )

    query = _apply_tag_filter_groups(query, db, DbGateway.tags, tag_groups)

    # Apply pagination ordering for cursor support
    query = query.order_by(desc(DbGateway.created_at), desc(DbGateway.id))

    # Build query params for pagination links
    query_params = {}
    if include_inactive:
        query_params["include_inactive"] = "true"
    if team_id:
        query_params["team_id"] = team_id
    if search_query:
        query_params["q"] = search_query
    if normalized_tags:
        query_params["tags"] = normalized_tags

    # Use unified pagination function
    root_path = _resolve_root_path(request)
    base_url = f"{root_path}/admin/gateways/partial"
    paginated_result = await paginate_query(
        db=db,
        query=query,
        page=page,
        per_page=per_page,
        cursor=None,  # HTMX partials use page-based navigation
        base_url=base_url,
        query_params=query_params,
        use_cursor_threshold=False,  # Disable auto-cursor switching for UI
    )

    # Extract paginated gateways (DbGateway objects)
    gateways_db = paginated_result["data"]
    pagination = paginated_result["pagination"]
    links = paginated_result["links"]

    # Batch convert to Pydantic models using gateway service
    # This eliminates the N+1 query problem from calling get_gateway_details() in a loop
    gateways_pydantic = []
    failed_count = 0
    for g in gateways_db:
        try:
            gateways_pydantic.append(gateway_service.convert_gateway_to_read(g))
        except (ValidationError, ValueError, KeyError, TypeError, binascii.Error) as e:
            failed_count += 1
            LOGGER.exception(f"Failed to convert gateway {getattr(g, 'id', 'unknown')} ({getattr(g, 'name', 'unknown')}): {e}")
    _adjust_pagination_for_conversion_failures(pagination, failed_count, len(gateways_pydantic))
    data = jsonable_encoder(gateways_pydantic)

    # End the read-only transaction before template rendering to avoid idle-in-transaction timeouts.
    db.commit()

    LOGGER.info(f"🔷 GATEWAYS PARTIAL RESPONSE - Returning {len(data)} gateways, render mode: {render or 'default'}, team_id used in query: {team_id}")

    if render == "controls":
        return request.app.state.templates.TemplateResponse(
            request,
            "pagination_controls.html",
            {
                "request": request,
                "pagination": pagination.model_dump(),
                "base_url": base_url,
                "hx_target": "#gateways-table-body",
                "hx_indicator": "#gateways-loading",
                "query_params": query_params,
                "root_path": _resolve_root_path(request),
            },
        )

    if render == "selector":
        return request.app.state.templates.TemplateResponse(
            request,
            "gateways_selector_items.html",
            {"request": request, "data": data, "pagination": pagination.model_dump(), "root_path": _resolve_root_path(request), "team_id": team_id, "include_public": include_public},
        )

    _is_admin = bool(user.get("is_admin", False) if isinstance(user, dict) else getattr(user, "is_admin", False))
    _team_roles = _get_user_team_roles(db, user_email) if not _is_admin else {}
    return request.app.state.templates.TemplateResponse(
        request,
        "gateways_partial.html",
        {
            "request": request,
            "data": data,
            "pagination": pagination.model_dump(),
            "links": links.model_dump() if links else None,
            "root_path": _resolve_root_path(request),
            "include_inactive": include_inactive,
            "query_params": query_params,
            "current_user_email": user_email,
            "is_admin": _is_admin,
            "user_team_roles": _team_roles,
        },
    )


@admin_router.get("/gateways/ids", response_class=JSONResponse)
@require_permission("gateways.read", allow_admin_bypass=False)
async def admin_get_all_gateways_ids(
    include_inactive: bool = False,
    team_id: Optional[str] = Depends(_validated_team_id_param),
    include_public: bool = False,
    db: Session = Depends(get_db),
    user=Depends(get_current_user_with_permissions),
):
    """Return all gateway IDs accessible to the current user (select-all helper).

    This endpoint is used by UI "Select All" helpers to fetch only the IDs
    of gateways the requesting user can access (owner, team, or public).

    Args:
        include_inactive (bool): When True include prompts that are inactive.
        team_id (Optional[str]): Filter by team ID.
        include_public (bool): Whether to include all public gateways in the results.
        db (Session): Database session (injected dependency).
        user: Authenticated user object from dependency injection.

    Returns:
        dict: A dictionary containing two keys:
            - "prompt_ids": List[str] of accessible prompt IDs.
            - "count": int number of IDs returned.
    """
    user_email = get_user_email(user)
    team_ids = await _get_user_team_ids(user, db)

    query = select(DbGateway.id)

    if not include_inactive:
        query = query.where(DbGateway.enabled.is_(True))

    # Build access conditions
    # When team_id is specified, show ONLY items from that team (team-scoped view)
    # When team_id + include_public, show team items PLUS public items from all teams
    # Otherwise, show all accessible items (All Teams view)
    if team_id:
        if team_id in team_ids:
            # Apply visibility check: team/public resources + user's own resources (including private)
            team_access = [
                and_(DbGateway.team_id == team_id, DbGateway.visibility.in_(["team", "public"])),
                and_(DbGateway.team_id == team_id, DbGateway.owner_email == user_email),
            ]
            if include_public:
                team_access.append(DbGateway.visibility == "public")
            query = query.where(or_(*team_access))
            LOGGER.debug(f"Filtering gateway IDs by team_id: {team_id}{' (include_public)' if include_public else ''}")
        else:
            LOGGER.warning(f"User {user_email} attempted to filter gateway IDs by team {team_id} but is not a member")
            query = query.where(false())
    else:
        # All Teams view: apply standard access conditions (owner, team, public)
        access_conditions = []
        access_conditions.append(_owner_access_condition(DbGateway.owner_email, DbGateway.team_id, user_email=user_email, team_ids=team_ids, user=user))
        access_conditions.append(DbGateway.visibility == "public")
        if team_ids:
            access_conditions.append(and_(DbGateway.team_id.in_(team_ids), DbGateway.visibility.in_(["team", "public"])))
        query = query.where(or_(*access_conditions))

    gateway_ids = [row[0] for row in db.execute(query).all()]
    return {"gateway_ids": gateway_ids, "count": len(gateway_ids)}


@admin_router.get("/gateways/search", response_class=JSONResponse)
@require_permission("gateways.read", allow_admin_bypass=False)
async def admin_search_gateways(
    q: str = Query("", max_length=500, description="Search query"),
    tags: QueryTagsFilter = None,
    include_inactive: bool = False,
    limit: int = Query(settings.pagination_default_page_size, ge=1, le=settings.pagination_max_page_size),
    team_id: Optional[str] = Depends(_validated_team_id_param),
    include_public: bool = False,
    db: Session = Depends(get_db),
    user=Depends(get_current_user_with_permissions),
):
    """Search gateways by name or description for selector search.

    Performs a case-insensitive search over prompt names and descriptions
    and returns a limited list of matching gateways suitable for selector
    UIs (id, name, description).

    Args:
        q (str): Search query string.
        tags (Optional[str]): Tag filter expression (comma=OR, plus=AND).
        include_inactive (bool): When True include gateways that are inactive.
        limit (int): Maximum number of results to return (bounded by the query parameter).
        team_id (Optional[str]): Filter by team ID.
        include_public (bool): Whether to include all public gateways in the results.
        db (Session): Database session (injected dependency).
        user: Authenticated user object from dependency injection.

    Returns:
        dict: A dictionary containing:
            - "gateways": List[dict] where each dict has keys "id", "name", "description".
            - "count": int number of matched gateways returned.
    """
    user_email = get_user_email(user)
    search_query = _normalize_search_query(q)
    normalized_tags = _normalize_tags_query(tags)
    tag_groups = _parse_tag_filter_groups(normalized_tags)
    if not search_query and not tag_groups:
        return _build_search_response(entity_key="gateways", entity_type="gateways", items=[], query=search_query, tags=normalized_tags, tag_groups=tag_groups)

    team_ids = await _get_user_team_ids(user, db)

    query = select(DbGateway.id, DbGateway.name, DbGateway.url, DbGateway.description)

    if not include_inactive:
        query = query.where(DbGateway.enabled.is_(True))

    # Build access conditions
    # When team_id is specified, show ONLY items from that team (team-scoped view)
    # When team_id + include_public, show team items PLUS public items from all teams
    # Otherwise, show all accessible items (All Teams view)
    if team_id:
        if team_id in team_ids:
            # Apply visibility check: team/public resources + user's own resources (including private)
            team_access = [
                and_(DbGateway.team_id == team_id, DbGateway.visibility.in_(["team", "public"])),
                and_(DbGateway.team_id == team_id, DbGateway.owner_email == user_email),
            ]
            if include_public:
                team_access.append(DbGateway.visibility == "public")
            query = query.where(or_(*team_access))
            LOGGER.debug(f"Filtering gateway search by team_id: {team_id}{' (include_public)' if include_public else ''}")
        else:
            LOGGER.warning(f"User {user_email} attempted to filter gateway search by team {team_id} but is not a member")
            query = query.where(false())
    else:
        # All Teams view: apply standard access conditions (owner, team, public)
        access_conditions = []
        access_conditions.append(_owner_access_condition(DbGateway.owner_email, DbGateway.team_id, user_email=user_email, team_ids=team_ids, user=user))
        access_conditions.append(DbGateway.visibility == "public")
        if team_ids:
            access_conditions.append(and_(DbGateway.team_id.in_(team_ids), DbGateway.visibility.in_(["team", "public"])))
        query = query.where(or_(*access_conditions))

    if search_query:
        search_conditions = [
            _like_contains(func.lower(DbGateway.id), search_query),
            _like_contains(func.lower(DbGateway.name), search_query),
            _like_contains(func.lower(coalesce(DbGateway.url, "")), search_query),
            _like_contains(func.lower(coalesce(DbGateway.description, "")), search_query),
        ]
        query = query.where(or_(*search_conditions))

    query = _apply_tag_filter_groups(query, db, DbGateway.tags, tag_groups)

    if search_query:
        query = query.order_by(
            case(
                (func.lower(DbGateway.name).startswith(search_query), 1),
                (func.lower(coalesce(DbGateway.url, "")).startswith(search_query), 1),
                else_=2,
            ),
            func.lower(DbGateway.name),
        )
    else:
        query = query.order_by(func.lower(DbGateway.name))
    query = query.limit(limit)

    results = db.execute(query).all()
    gateways = []
    for row in results:
        gateways.append(
            {
                "id": row.id,
                "name": row.name,
                "url": row.url,
                "description": row.description,
            }
        )

    return _build_search_response(entity_key="gateways", entity_type="gateways", items=gateways, query=search_query, tags=normalized_tags, tag_groups=tag_groups)


@admin_router.get("/servers/ids", response_class=JSONResponse)
@require_permission("servers.read", allow_admin_bypass=False)
async def admin_get_all_server_ids(
    include_inactive: bool = False,
    team_id: Optional[str] = Depends(_validated_team_id_param),
    db: Session = Depends(get_db),
    user=Depends(get_current_user_with_permissions),
):
    """Return all server IDs accessible to the current user (select-all helper).

    This endpoint is used by UI "Select All" helpers to fetch only the IDs
    of servers the requesting user can access (owner, team, or public).

    Args:
        include_inactive (bool): When True include servers that are inactive.
        team_id (Optional[str]): Filter by team ID.
        db (Session): Database session (injected dependency).
        user: Authenticated user object from dependency injection.

    Returns:
        dict: A dictionary containing two keys:
            - "server_ids": List[str] of accessible server IDs.
            - "count": int number of IDs returned.
    """
    user_email = get_user_email(user)
    team_ids = await _get_user_team_ids(user, db)

    query = select(DbServer.id)

    if not include_inactive:
        query = query.where(DbServer.enabled.is_(True))

    # Build access conditions
    # When team_id is specified, show ONLY items from that team (team-scoped view)
    # Otherwise, show all accessible items (All Teams view)
    if team_id:
        # Team-specific view: only show servers from the specified team
        if team_id in team_ids:
            # Apply visibility check: team/public resources + user's own resources (including private)
            team_access = [
                and_(DbServer.team_id == team_id, DbServer.visibility.in_(["team", "public"])),
                and_(DbServer.team_id == team_id, DbServer.owner_email == user_email),
            ]
            query = query.where(or_(*team_access))
            LOGGER.debug(f"Filtering server IDs by team_id: {team_id}")
        else:
            # User is not a member of this team, return no results using SQLAlchemy's false()
            LOGGER.warning(f"User {user_email} attempted to filter server IDs by team {team_id} but is not a member")
            query = query.where(false())
    else:
        # All Teams view: apply standard access conditions (owner, team, public)
        access_conditions = []
        access_conditions.append(_owner_access_condition(DbServer.owner_email, DbServer.team_id, user_email=user_email, team_ids=team_ids, user=user))
        if team_ids:
            access_conditions.append(and_(DbServer.team_id.in_(team_ids), DbServer.visibility.in_(["team", "public"])))
        access_conditions.append(DbServer.visibility == "public")
        query = query.where(or_(*access_conditions))

    server_ids = [row[0] for row in db.execute(query).all()]
    return {"server_ids": server_ids, "count": len(server_ids)}


@admin_router.get("/servers/search", response_class=JSONResponse)
@require_permission("servers.read", allow_admin_bypass=False)
async def admin_search_servers(
    q: str = Query("", max_length=500, description="Search query"),
    tags: QueryTagsFilter = None,
    include_inactive: bool = False,
    limit: int = Query(settings.pagination_default_page_size, ge=1, le=settings.pagination_max_page_size),
    team_id: Optional[str] = Depends(_validated_team_id_param),
    db: Session = Depends(get_db),
    user=Depends(get_current_user_with_permissions),
):
    """Search servers by name or description for selector search.

    Performs a case-insensitive search over prompt names and descriptions
    and returns a limited list of matching servers suitable for selector
    UIs (id, name, description).

    Args:
        q (str): Search query string.
        tags (Optional[str]): Tag filter expression (comma=OR, plus=AND).
        include_inactive (bool): When True include servers that are inactive.
        limit (int): Maximum number of results to return (bounded by the query parameter).
        team_id (Optional[str]): Filter by team ID.
        db (Session): Database session (injected dependency).
        user: Authenticated user object from dependency injection.

    Returns:
        dict: A dictionary containing:
            - "servers": List[dict] where each dict has keys "id", "name", "description".
            - "count": int number of matched servers returned.
    """
    user_email = get_user_email(user)
    search_query = _normalize_search_query(q)
    normalized_tags = _normalize_tags_query(tags)
    tag_groups = _parse_tag_filter_groups(normalized_tags)
    if not search_query and not tag_groups:
        return _build_search_response(entity_key="servers", entity_type="servers", items=[], query=search_query, tags=normalized_tags, tag_groups=tag_groups)

    team_ids = await _get_user_team_ids(user, db)

    query = select(DbServer.id, DbServer.name, DbServer.description)

    if not include_inactive:
        query = query.where(DbServer.enabled.is_(True))

    # Build access conditions
    # When team_id is specified, show ONLY items from that team (team-scoped view)
    # Otherwise, show all accessible items (All Teams view)
    if team_id:
        # Team-specific view: only show servers from the specified team
        if team_id in team_ids:
            # Apply visibility check: team/public resources + user's own resources (including private)
            team_access = [
                and_(DbServer.team_id == team_id, DbServer.visibility.in_(["team", "public"])),
                and_(DbServer.team_id == team_id, DbServer.owner_email == user_email),
            ]
            query = query.where(or_(*team_access))
            LOGGER.debug(f"Filtering server search by team_id: {team_id}")
        else:
            # User is not a member of this team, return no results using SQLAlchemy's false()
            LOGGER.warning(f"User {user_email} attempted to filter server search by team {team_id} but is not a member")
            query = query.where(false())
    else:
        # All Teams view: apply standard access conditions (owner, team, public)
        access_conditions = []
        access_conditions.append(_owner_access_condition(DbServer.owner_email, DbServer.team_id, user_email=user_email, team_ids=team_ids, user=user))
        if team_ids:
            access_conditions.append(and_(DbServer.team_id.in_(team_ids), DbServer.visibility.in_(["team", "public"])))
        access_conditions.append(DbServer.visibility == "public")
        query = query.where(or_(*access_conditions))

    if search_query:
        search_conditions = [
            _like_contains(func.lower(DbServer.id), search_query),
            _like_contains(func.lower(DbServer.name), search_query),
            _like_contains(func.lower(coalesce(DbServer.description, "")), search_query),
        ]
        query = query.where(or_(*search_conditions))

    query = _apply_tag_filter_groups(query, db, DbServer.tags, tag_groups)

    if search_query:
        query = query.order_by(
            case(
                (func.lower(DbServer.name).startswith(search_query), 1),
                else_=2,
            ),
            func.lower(DbServer.name),
        )
    else:
        query = query.order_by(func.lower(DbServer.name))
    query = query.limit(limit)

    results = db.execute(query).all()
    servers = []
    for row in results:
        servers.append(
            {
                "id": row.id,
                "name": row.name,
                "description": row.description,
            }
        )

    return _build_search_response(entity_key="servers", entity_type="servers", items=servers, query=search_query, tags=normalized_tags, tag_groups=tag_groups)


@admin_router.get("/resources/partial", response_class=HTMLResponse)
@require_permission("resources.read", allow_admin_bypass=False)
async def admin_resources_partial_html(
    request: Request,
    page: int = Query(1, ge=1, description="Page number (1-indexed)"),
    per_page: int = Query(settings.pagination_default_page_size, ge=1, le=settings.pagination_max_page_size, description="Items per page"),
    q: str = Query("", max_length=500, description="Search query"),
    tags: QueryTagsFilter = None,
    include_inactive: bool = False,
    render: QueryRenderModeControls = None,
    gateway_id: QueryGatewayIdList = None,
    team_id: Optional[str] = Depends(_validated_team_id_param),
    include_public: bool = False,
    db: Session = Depends(get_db),
    user=Depends(get_current_user_with_permissions),
):
    """Return HTML partial for paginated resources list (HTMX endpoint).

    This endpoint mirrors the behavior of the tools and prompts partial
    endpoints. It returns a template fragment suitable for HTMX-based
    pagination/infinite-scroll within the admin UI.

    Args:
        request (Request): FastAPI request object used by the template engine.
        page (int): Page number (1-indexed).
        per_page (int): Number of items per page (bounded by settings).
        q (str): Free-text query string.
        tags (Optional[str]): Tag filter expression (comma=OR, plus=AND).
        include_inactive (bool): If True, include inactive resources in results.
        render (Optional[str]): Render mode; when set to "controls" returns only
            pagination controls. Other supported value: "selector" for selector
            items used by infinite scroll selectors.
        gateway_id (Optional[str]): Filter by gateway ID(s), comma-separated.
        team_id (Optional[str]): Filter by team ID.
        include_public (bool): Whether to include all public resources in the results.
        db (Session): Database session (dependency-injected).
        user: Authenticated user object from dependency injection.

    Returns:
        Union[HTMLResponse, TemplateResponse]: Rendered template response with the
        resources partial (rows + controls), pagination controls only, or selector
        items depending on the ``render`` parameter.
    """

    LOGGER.debug(
        f"[RESOURCES FILTER DEBUG] User {get_user_email(user)} requested resources HTML partial (page={page}, per_page={per_page}, render={render}, gateway_id={gateway_id}, team_id={team_id})"
    )
    search_query = _normalize_search_query(q)
    normalized_tags = _normalize_tags_query(tags)
    tag_groups = _parse_tag_filter_groups(normalized_tags)

    # Normalize per_page
    per_page = max(settings.pagination_min_page_size, min(per_page, settings.pagination_max_page_size))

    user_email = get_user_email(user)

    # Team scoping
    team_ids = await _get_user_team_ids(user, db)

    # Build base query
    query = select(DbResource)

    # Apply gateway filter if provided
    if gateway_id:
        gateway_ids = [gid.strip() for gid in gateway_id.split(",") if gid.strip()]
        if gateway_ids:
            null_requested = any(gid.lower() == "null" for gid in gateway_ids)
            non_null_ids = [gid for gid in gateway_ids if gid.lower() != "null"]
            if non_null_ids and null_requested:
                query = query.where(or_(DbResource.gateway_id.in_(non_null_ids), DbResource.gateway_id.is_(None)))
                LOGGER.debug(f"[RESOURCES FILTER DEBUG] Filtering resources by gateway IDs (including NULL): {non_null_ids} + NULL")
            elif null_requested:
                query = query.where(DbResource.gateway_id.is_(None))
                LOGGER.debug("[RESOURCES FILTER DEBUG] Filtering resources by NULL gateway_id (RestTool)")
            else:
                query = query.where(DbResource.gateway_id.in_(non_null_ids))
                LOGGER.debug(f"[RESOURCES FILTER DEBUG] Filtering resources by gateway IDs: {non_null_ids}")
    else:
        LOGGER.debug("[RESOURCES FILTER DEBUG] No gateway_id filter provided, showing all resources")

    # Apply active/inactive filter
    if not include_inactive:
        query = query.where(DbResource.enabled.is_(True))

    # Build access conditions
    # When team_id is specified, show ONLY items from that team (team-scoped view)
    # When team_id + include_public, show team items PLUS public items from all teams
    # Otherwise, show all accessible items (All Teams view)
    if team_id:
        # Team-specific view: only show resources from the specified team
        if team_id in team_ids:
            # Apply visibility check: team/public resources + user's own resources (including private)
            team_access = [
                and_(DbResource.team_id == team_id, DbResource.visibility.in_(["team", "public"])),
                and_(DbResource.team_id == team_id, DbResource.owner_email == user_email),
            ]
            if include_public:
                # Include all globally public items from any team.
                # Items with visibility='team' or 'private' from other teams are
                # blocked by the other conditions (which require team_id == selected team).
                team_access.append(DbResource.visibility == "public")
            query = query.where(or_(*team_access))
            LOGGER.debug(f"Filtering resources by team_id: {team_id}{' (include_public)' if include_public else ''}")
        else:
            # User is not a member of this team, return no results using SQLAlchemy's false()
            LOGGER.warning(f"User {user_email} attempted to filter by team {team_id} but is not a member")
            query = query.where(false())
    else:
        # All Teams view: apply standard access conditions (owner, team, public)
        access_conditions = []
        access_conditions.append(_owner_access_condition(DbResource.owner_email, DbResource.team_id, user_email=user_email, team_ids=team_ids, user=user))
        if team_ids:
            access_conditions.append(and_(DbResource.team_id.in_(team_ids), DbResource.visibility.in_(["team", "public"])))
        access_conditions.append(DbResource.visibility == "public")
        query = query.where(or_(*access_conditions))

    if search_query:
        query = query.where(
            or_(
                _like_contains(func.lower(DbResource.id), search_query),
                _like_contains(func.lower(DbResource.name), search_query),
                _like_contains(func.lower(coalesce(DbResource.uri, "")), search_query),
                _like_contains(func.lower(coalesce(DbResource.description, "")), search_query),
            )
        )

    query = _apply_tag_filter_groups(query, db, DbResource.tags, tag_groups)

    # Add sorting for consistent pagination
    query = query.order_by(desc(DbResource.created_at), desc(DbResource.id))

    # Build query params for pagination links
    query_params = {}
    if include_inactive:
        query_params["include_inactive"] = "true"
    if gateway_id:
        query_params["gateway_id"] = gateway_id
    if team_id:
        query_params["team_id"] = team_id
    if search_query:
        query_params["q"] = search_query
    if normalized_tags:
        query_params["tags"] = normalized_tags

    # Use unified pagination function
    root_path = _resolve_root_path(request)
    base_url = f"{root_path}/admin/resources/partial"
    paginated_result = await paginate_query(
        db=db,
        query=query,
        page=page,
        per_page=per_page,
        cursor=None,  # HTMX partials use page-based navigation
        base_url=base_url,
        query_params=query_params,
        use_cursor_threshold=False,  # Disable auto-cursor switching for UI
    )

    # Extract paginated resources (DbResource objects)
    resources_db = paginated_result["data"]
    pagination = paginated_result["pagination"]
    links = paginated_result["links"]

    # Batch fetch team names for the resources to avoid N+1 queries
    team_ids_set = {r.team_id for r in resources_db if r.team_id}
    team_map = {}
    if team_ids_set:
        teams = db.execute(select(EmailTeam.id, EmailTeam.name).where(EmailTeam.id.in_(team_ids_set), EmailTeam.is_active.is_(True))).all()
        team_map = {team.id: team.name for team in teams}

    # Apply team names to DB objects before conversion
    for r in resources_db:
        r.team = team_map.get(r.team_id) if r.team_id else None

    # Batch convert to Pydantic models using resource service
    resources_pydantic = []
    failed_count = 0
    for r in resources_db:
        try:
            resources_pydantic.append(resource_service.convert_resource_to_read(r, include_metrics=False))
        except (ValidationError, ValueError, KeyError, TypeError, binascii.Error) as e:
            failed_count += 1
            LOGGER.exception(f"Failed to convert resource {getattr(r, 'id', 'unknown')} ({getattr(r, 'name', 'unknown')}): {e}")
    _adjust_pagination_for_conversion_failures(pagination, failed_count, len(resources_pydantic))

    data = jsonable_encoder(resources_pydantic)

    # End the read-only transaction before template rendering to avoid idle-in-transaction timeouts.
    db.commit()

    if render == "controls":
        return request.app.state.templates.TemplateResponse(
            request,
            "pagination_controls.html",
            {
                "request": request,
                "pagination": pagination.model_dump(),
                "base_url": base_url,
                "hx_target": "#resources-table-body",
                "hx_indicator": "#resources-loading",
                "query_params": query_params,
                "root_path": _resolve_root_path(request),
            },
        )

    if render == "selector":
        return request.app.state.templates.TemplateResponse(
            request,
            "resources_selector_items.html",
            {
                "request": request,
                "data": data,
                "pagination": pagination.model_dump(),
                "root_path": _resolve_root_path(request),
                "gateway_id": gateway_id,
                "team_id": team_id,
                "include_public": include_public,
            },
        )

    _is_admin = bool(user.get("is_admin", False) if isinstance(user, dict) else getattr(user, "is_admin", False))
    _team_roles = _get_user_team_roles(db, user_email) if not _is_admin else {}
    return request.app.state.templates.TemplateResponse(
        request,
        "resources_partial.html",
        {
            "request": request,
            "data": data,
            "pagination": pagination.model_dump(),
            "links": links.model_dump() if links else None,
            "root_path": _resolve_root_path(request),
            "include_inactive": include_inactive,
            "query_params": query_params,
            "current_user_email": user_email,
            "is_admin": _is_admin,
            "user_team_roles": _team_roles,
        },
    )


@admin_router.get("/prompts/ids", response_class=JSONResponse)
@require_permission("prompts.read", allow_admin_bypass=False)
async def admin_get_all_prompt_ids(
    q: str = Query("", max_length=500, description="Search query"),
    include_inactive: bool = False,
    gateway_id: QueryGatewayIdList = None,
    team_id: Optional[str] = Depends(_validated_team_id_param),
    include_public: bool = False,
    db: Session = Depends(get_db),
    user=Depends(get_current_user_with_permissions),
):
    """Return all prompt IDs accessible to the current user (select-all helper).

    This endpoint is used by UI "Select All" helpers to fetch only the IDs
    of prompts the requesting user can access (owner, team, or public).

    Args:
        q (str): Search query to filter prompts by name or description.
        include_inactive (bool): When True include prompts that are inactive.
        gateway_id (Optional[str]): Filter by gateway ID(s), comma-separated. Accepts the literal value 'null' to indicate NULL gateway_id (local prompts).
        team_id (Optional[str]): Filter by team ID.
        include_public (bool): Whether to include all platform-public prompts when filtering by team.
        db (Session): Database session (injected dependency).
        user: Authenticated user object from dependency injection.

    Returns:
        dict: A dictionary containing two keys:
            - "prompt_ids": List[str] of accessible prompt IDs.
            - "count": int number of IDs returned.
    """
    user_email = get_user_email(user)
    team_ids = await _get_user_team_ids(user, db)

    query = select(DbPrompt.id)

    if not include_inactive:
        query = query.where(DbPrompt.enabled.is_(True))

    # Apply search filter if provided
    if q:
        search_query = _normalize_search_query(q)
        if search_query:
            search_conditions = [
                _like_contains(func.lower(DbPrompt.id), search_query),
                _like_contains(func.lower(DbPrompt.original_name), search_query),
                _like_contains(func.lower(coalesce(DbPrompt.display_name, "")), search_query),
                _like_contains(func.lower(coalesce(DbPrompt.description, "")), search_query),
            ]
            query = query.where(or_(*search_conditions))
            LOGGER.debug(f"Filtering prompt IDs by search query: {search_query}")

    # Apply optional gateway/server scoping
    if gateway_id:
        gateway_ids = [gid.strip() for gid in gateway_id.split(",") if gid.strip()]
        if gateway_ids:
            null_requested = any(gid.lower() == "null" for gid in gateway_ids)
            non_null_ids = [gid for gid in gateway_ids if gid.lower() != "null"]
            if non_null_ids and null_requested:
                query = query.where(or_(DbPrompt.gateway_id.in_(non_null_ids), DbPrompt.gateway_id.is_(None)))
                LOGGER.debug(f"Filtering prompts by gateway IDs (including NULL): {non_null_ids} + NULL")
            elif null_requested:
                query = query.where(DbPrompt.gateway_id.is_(None))
                LOGGER.debug("Filtering prompts by NULL gateway_id (RestTool)")
            else:
                query = query.where(DbPrompt.gateway_id.in_(non_null_ids))
                LOGGER.debug(f"Filtering prompts by gateway IDs: {non_null_ids}")

    # Build access conditions
    # When team_id is specified, show items from that team; optionally include
    # platform-public items when include_public is set (mirrors the partial endpoint).
    # Otherwise, show all accessible items (All Teams view).
    if team_id:
        if team_id in team_ids:
            team_access = [
                and_(DbPrompt.team_id == team_id, DbPrompt.visibility.in_(["team", "public"])),
                and_(DbPrompt.team_id == team_id, DbPrompt.owner_email == user_email),
            ]
            if include_public:
                team_access.append(DbPrompt.visibility == "public")
            query = query.where(or_(*team_access))
            LOGGER.debug(f"Filtering prompt IDs by team_id: {team_id}{' (include_public)' if include_public else ''}")
        else:
            # User is not a member of this team, return no results using SQLAlchemy's false()
            LOGGER.warning(f"User {user_email} attempted to filter prompt IDs by team {team_id} but is not a member")
            query = query.where(false())
    else:
        # All Teams view: apply standard access conditions (owner, team, public)
        access_conditions = []
        access_conditions.append(_owner_access_condition(DbPrompt.owner_email, DbPrompt.team_id, user_email=user_email, team_ids=team_ids, user=user))
        if team_ids:
            access_conditions.append(and_(DbPrompt.team_id.in_(team_ids), DbPrompt.visibility.in_(["team", "public"])))
        access_conditions.append(DbPrompt.visibility == "public")
        query = query.where(or_(*access_conditions))

    prompt_ids = [row[0] for row in db.execute(query).all()]
    return {"prompt_ids": prompt_ids, "count": len(prompt_ids)}


@admin_router.get("/resources/ids", response_class=JSONResponse)
@require_permission("resources.read", allow_admin_bypass=False)
async def admin_get_all_resource_ids(
    q: str = Query("", max_length=500, description="Search query"),
    include_inactive: bool = False,
    gateway_id: QueryGatewayIdList = None,
    team_id: Optional[str] = Depends(_validated_team_id_param),
    include_public: bool = False,
    db: Session = Depends(get_db),
    user=Depends(get_current_user_with_permissions),
):
    """Return all resource IDs accessible to the current user (select-all helper).

    This endpoint is used by UI "Select All" helpers to fetch only the IDs
    of resources the requesting user can access (owner, team, or public).

    Args:
        q (str): Search query to filter resources by name, URI, or description.
        include_inactive (bool): Whether to include inactive resources in the results.
        gateway_id (Optional[str]): Filter by gateway ID(s), comma-separated. Accepts the literal value 'null' to indicate NULL gateway_id (local resources).
        team_id (Optional[str]): Filter by team ID.
        include_public (bool): Whether to include all platform-public resources when filtering by team.
        db (Session): Database session dependency.
        user: Authenticated user object from dependency injection.

    Returns:
        dict: A dictionary containing two keys:
            - "resource_ids": List[str] of accessible resource IDs.
            - "count": int number of IDs returned.
    """
    user_email = get_user_email(user)
    team_ids = await _get_user_team_ids(user, db)

    query = select(DbResource.id)

    if not include_inactive:
        query = query.where(DbResource.enabled.is_(True))

    # Apply search filter if provided
    if q:
        search_query = _normalize_search_query(q)
        if search_query:
            search_conditions = [
                _like_contains(func.lower(DbResource.id), search_query),
                _like_contains(func.lower(DbResource.name), search_query),
                _like_contains(func.lower(coalesce(DbResource.uri, "")), search_query),
                _like_contains(func.lower(coalesce(DbResource.description, "")), search_query),
            ]
            query = query.where(or_(*search_conditions))
            LOGGER.debug(f"Filtering resource IDs by search query: {search_query}")

    # Apply optional gateway/server scoping
    if gateway_id:
        gateway_ids = [gid.strip() for gid in gateway_id.split(",") if gid.strip()]
        if gateway_ids:
            null_requested = any(gid.lower() == "null" for gid in gateway_ids)
            non_null_ids = [gid for gid in gateway_ids if gid.lower() != "null"]
            if non_null_ids and null_requested:
                query = query.where(or_(DbResource.gateway_id.in_(non_null_ids), DbResource.gateway_id.is_(None)))
                LOGGER.debug(f"Filtering resources by gateway IDs (including NULL): {non_null_ids} + NULL")
            elif null_requested:
                query = query.where(DbResource.gateway_id.is_(None))
                LOGGER.debug("Filtering resources by NULL gateway_id (RestTool)")
            else:
                query = query.where(DbResource.gateway_id.in_(non_null_ids))
                LOGGER.debug(f"Filtering resources by gateway IDs: {non_null_ids}")

    # Build access conditions
    # When team_id is specified, show items from that team; optionally include
    # platform-public items when include_public is set (mirrors the partial endpoint).
    # Otherwise, show all accessible items (All Teams view).
    if team_id:
        if team_id in team_ids:
            team_access = [
                and_(DbResource.team_id == team_id, DbResource.visibility.in_(["team", "public"])),
                and_(DbResource.team_id == team_id, DbResource.owner_email == user_email),
            ]
            if include_public:
                team_access.append(DbResource.visibility == "public")
            query = query.where(or_(*team_access))
            LOGGER.debug(f"Filtering resource IDs by team_id: {team_id}{' (include_public)' if include_public else ''}")
        else:
            # User is not a member of this team, return no results using SQLAlchemy's false()
            LOGGER.warning(f"User {user_email} attempted to filter resource IDs by team {team_id} but is not a member")
            query = query.where(false())
    else:
        # All Teams view: apply standard access conditions (owner, team, public)
        access_conditions = []
        access_conditions.append(_owner_access_condition(DbResource.owner_email, DbResource.team_id, user_email=user_email, team_ids=team_ids, user=user))
        if team_ids:
            access_conditions.append(and_(DbResource.team_id.in_(team_ids), DbResource.visibility.in_(["team", "public"])))
        access_conditions.append(DbResource.visibility == "public")
        query = query.where(or_(*access_conditions))

    resource_ids = [row[0] for row in db.execute(query).all()]
    return {"resource_ids": resource_ids, "count": len(resource_ids)}


@admin_router.get("/resources/search", response_class=JSONResponse)
@require_permission("resources.read", allow_admin_bypass=False)
async def admin_search_resources(
    q: str = Query("", max_length=500, description="Search query"),
    tags: QueryTagsFilter = None,
    include_inactive: bool = False,
    limit: int = Query(settings.pagination_default_page_size, ge=1, le=settings.pagination_max_page_size),
    gateway_id: QueryGatewayIdList = None,
    team_id: Optional[str] = Depends(_validated_team_id_param),
    include_public: bool = False,
    db: Session = Depends(get_db),
    user=Depends(get_current_user_with_permissions),
):
    """Search resources by name or description for selector search.

    Performs a case-insensitive search over resource names and descriptions
    and returns a limited list of matching resources suitable for selector
    UIs (id, name, description).

    Args:
        q (str): Search query string.
        tags (Optional[str]): Tag filter expression (comma=OR, plus=AND).
        include_inactive (bool): When True include resources that are inactive.
        limit (int): Maximum number of results to return (bounded by the query parameter).
        gateway_id (Optional[str]): Filter by gateway ID(s), comma-separated.
        team_id (Optional[str]): Filter by team ID.
        include_public (bool): Whether to include all public resources in the results.
        db (Session): Database session (injected dependency).
        user: Authenticated user object from dependency injection.

    Returns:
        dict: A dictionary containing:
            - "resources": List[dict] where each dict has keys "id", "name", "description".
            - "count": int number of matched resources returned.
    """
    user_email = get_user_email(user)
    search_query = _normalize_search_query(q)
    normalized_tags = _normalize_tags_query(tags)
    tag_groups = _parse_tag_filter_groups(normalized_tags)
    if not search_query and not tag_groups:
        return _build_search_response(entity_key="resources", entity_type="resources", items=[], query=search_query, tags=normalized_tags, tag_groups=tag_groups)

    team_ids = await _get_user_team_ids(user, db)

    query = select(DbResource.id, DbResource.name, DbResource.description)

    # Apply gateway filter if provided
    if gateway_id:
        gateway_ids = [gid.strip() for gid in gateway_id.split(",") if gid.strip()]
        if gateway_ids:
            null_requested = any(gid.lower() == "null" for gid in gateway_ids)
            non_null_ids = [gid for gid in gateway_ids if gid.lower() != "null"]
            if non_null_ids and null_requested:
                query = query.where(or_(DbResource.gateway_id.in_(non_null_ids), DbResource.gateway_id.is_(None)))
                LOGGER.debug(f"Filtering resource search by gateway IDs (including NULL): {non_null_ids} + NULL")
            elif null_requested:
                query = query.where(DbResource.gateway_id.is_(None))
                LOGGER.debug("Filtering resource search by NULL gateway_id")
            else:
                query = query.where(DbResource.gateway_id.in_(non_null_ids))
                LOGGER.debug(f"Filtering resource search by gateway IDs: {non_null_ids}")

    if not include_inactive:
        query = query.where(DbResource.enabled.is_(True))

    # Build access conditions
    # When team_id is specified, show ONLY items from that team (team-scoped view)
    # When team_id + include_public, show team items PLUS public items from all teams
    # Otherwise, show all accessible items (All Teams view)
    if team_id:
        # Team-specific view: only show resources from the specified team
        if team_id in team_ids:
            # Apply visibility check: team/public resources + user's own resources (including private)
            team_access = [
                and_(DbResource.team_id == team_id, DbResource.visibility.in_(["team", "public"])),
                and_(DbResource.team_id == team_id, DbResource.owner_email == user_email),
            ]
            if include_public:
                team_access.append(DbResource.visibility == "public")
            query = query.where(or_(*team_access))
            LOGGER.debug(f"Filtering resource search by team_id: {team_id}{' (include_public)' if include_public else ''}")
        else:
            # User is not a member of this team, return no results using SQLAlchemy's false()
            LOGGER.warning(f"User {user_email} attempted to filter resource search by team {team_id} but is not a member")
            query = query.where(false())
    else:
        # All Teams view: apply standard access conditions (owner, team, public)
        access_conditions = []
        access_conditions.append(_owner_access_condition(DbResource.owner_email, DbResource.team_id, user_email=user_email, team_ids=team_ids, user=user))
        if team_ids:
            access_conditions.append(and_(DbResource.team_id.in_(team_ids), DbResource.visibility.in_(["team", "public"])))
        access_conditions.append(DbResource.visibility == "public")
        query = query.where(or_(*access_conditions))

    if search_query:
        search_conditions = [
            _like_contains(func.lower(DbResource.id), search_query),
            _like_contains(func.lower(DbResource.name), search_query),
            _like_contains(func.lower(coalesce(DbResource.uri, "")), search_query),
            _like_contains(func.lower(coalesce(DbResource.description, "")), search_query),
        ]
        query = query.where(or_(*search_conditions))

    query = _apply_tag_filter_groups(query, db, DbResource.tags, tag_groups)

    if search_query:
        query = query.order_by(
            case(
                (func.lower(DbResource.name).startswith(search_query), 1),
                else_=2,
            ),
            func.lower(DbResource.name),
        )
    else:
        query = query.order_by(func.lower(DbResource.name))
    query = query.limit(limit)

    results = db.execute(query).all()
    resources = []
    for row in results:
        resources.append({"id": row.id, "name": row.name, "description": row.description})

    return _build_search_response(entity_key="resources", entity_type="resources", items=resources, query=search_query, tags=normalized_tags, tag_groups=tag_groups)


@admin_router.get("/prompts/search", response_class=JSONResponse)
@require_permission("prompts.read", allow_admin_bypass=False)
async def admin_search_prompts(
    q: str = Query("", max_length=500, description="Search query"),
    tags: QueryTagsFilter = None,
    include_inactive: bool = False,
    limit: int = Query(settings.pagination_default_page_size, ge=1, le=settings.pagination_max_page_size),
    gateway_id: QueryGatewayIdList = None,
    team_id: Optional[str] = Depends(_validated_team_id_param),
    include_public: bool = False,
    db: Session = Depends(get_db),
    user=Depends(get_current_user_with_permissions),
):
    """Search prompts by name or description for selector search.

    Performs a case-insensitive search over prompt names and descriptions
    and returns a limited list of matching prompts suitable for selector
    UIs (id, name, description).

    Args:
        q (str): Search query string.
        tags (Optional[str]): Tag filter expression (comma=OR, plus=AND).
        include_inactive (bool): When True include prompts that are inactive.
        limit (int): Maximum number of results to return (bounded by the query parameter).
        gateway_id (Optional[str]): Filter by gateway ID(s), comma-separated.
        team_id (Optional[str]): Filter by team ID.
        include_public (bool): Whether to include all public prompts in the results.
        db (Session): Database session (injected dependency).
        user: Authenticated user object from dependency injection.

    Returns:
        dict: A dictionary containing:
            - "prompts": List[dict] where each dict has keys "id", "name", "description".
            - "count": int number of matched prompts returned.
    """
    user_email = get_user_email(user)
    search_query = _normalize_search_query(q)
    normalized_tags = _normalize_tags_query(tags)
    tag_groups = _parse_tag_filter_groups(normalized_tags)
    if not search_query and not tag_groups:
        return _build_search_response(entity_key="prompts", entity_type="prompts", items=[], query=search_query, tags=normalized_tags, tag_groups=tag_groups)

    team_ids = await _get_user_team_ids(user, db)

    query = select(DbPrompt.id, DbPrompt.original_name, DbPrompt.display_name, DbPrompt.description)

    # Apply gateway filter if provided
    if gateway_id:
        gateway_ids = [gid.strip() for gid in gateway_id.split(",") if gid.strip()]
        if gateway_ids:
            null_requested = any(gid.lower() == "null" for gid in gateway_ids)
            non_null_ids = [gid for gid in gateway_ids if gid.lower() != "null"]
            if non_null_ids and null_requested:
                query = query.where(or_(DbPrompt.gateway_id.in_(non_null_ids), DbPrompt.gateway_id.is_(None)))
                LOGGER.debug(f"Filtering prompt search by gateway IDs (including NULL): {non_null_ids} + NULL")
            elif null_requested:
                query = query.where(DbPrompt.gateway_id.is_(None))
                LOGGER.debug("Filtering prompt search by NULL gateway_id")
            else:
                query = query.where(DbPrompt.gateway_id.in_(non_null_ids))
                LOGGER.debug(f"Filtering prompt search by gateway IDs: {non_null_ids}")

    if not include_inactive:
        query = query.where(DbPrompt.enabled.is_(True))

    # Build access conditions
    # When team_id is specified, show ONLY items from that team (team-scoped view)
    # When team_id + include_public, show team items PLUS public items from all teams
    # Otherwise, show all accessible items (All Teams view)
    if team_id:
        # Team-specific view: only show prompts from the specified team
        if team_id in team_ids:
            # Apply visibility check: team/public resources + user's own resources (including private)
            team_access = [
                and_(DbPrompt.team_id == team_id, DbPrompt.visibility.in_(["team", "public"])),
                and_(DbPrompt.team_id == team_id, DbPrompt.owner_email == user_email),
            ]
            if include_public:
                team_access.append(DbPrompt.visibility == "public")
            query = query.where(or_(*team_access))
            LOGGER.debug(f"Filtering prompt search by team_id: {team_id}{' (include_public)' if include_public else ''}")
        else:
            # User is not a member of this team, return no results using SQLAlchemy's false()
            LOGGER.warning(f"User {user_email} attempted to filter prompt search by team {team_id} but is not a member")
            query = query.where(false())
    else:
        # All Teams view: apply standard access conditions (owner, team, public)
        access_conditions = []
        access_conditions.append(_owner_access_condition(DbPrompt.owner_email, DbPrompt.team_id, user_email=user_email, team_ids=team_ids, user=user))
        if team_ids:
            access_conditions.append(and_(DbPrompt.team_id.in_(team_ids), DbPrompt.visibility.in_(["team", "public"])))
        access_conditions.append(DbPrompt.visibility == "public")
        query = query.where(or_(*access_conditions))

    if search_query:
        search_conditions = [
            _like_contains(func.lower(DbPrompt.id), search_query),
            _like_contains(func.lower(DbPrompt.original_name), search_query),
            _like_contains(func.lower(coalesce(DbPrompt.display_name, "")), search_query),
            _like_contains(func.lower(coalesce(DbPrompt.description, "")), search_query),
        ]
        query = query.where(or_(*search_conditions))

    query = _apply_tag_filter_groups(query, db, DbPrompt.tags, tag_groups)

    if search_query:
        query = query.order_by(
            case(
                (func.lower(DbPrompt.original_name).startswith(search_query), 1),
                (func.lower(coalesce(DbPrompt.display_name, "")).startswith(search_query), 1),
                else_=2,
            ),
            func.lower(DbPrompt.original_name),
        )
    else:
        query = query.order_by(func.lower(DbPrompt.original_name))
    query = query.limit(limit)

    results = db.execute(query).all()
    prompts = []
    for row in results:
        prompts.append(
            {
                "id": row.id,
                "name": row.original_name,
                "original_name": row.original_name,
                "display_name": row.display_name,
                "description": row.description,
            }
        )

    return _build_search_response(entity_key="prompts", entity_type="prompts", items=prompts, query=search_query, tags=normalized_tags, tag_groups=tag_groups)


@admin_router.get("/tokens/partial", response_class=HTMLResponse)
@require_permission("tokens.read", allow_admin_bypass=False)
async def admin_tokens_partial_html(
    request: Request,
    page: int = Query(1, ge=1, description="Page number (1-indexed)"),
    per_page: int = Query(settings.pagination_default_page_size, ge=1, le=settings.pagination_max_page_size, description="Items per page"),
    include_inactive: bool = False,
    render: QueryRenderMode = None,
    q: Optional[str] = Query(None, max_length=500, description="Search query for token name"),
    team_id: Optional[str] = Depends(_validated_team_id_param),
    db: Session = Depends(get_db),
    user=Depends(get_current_user_with_permissions),
):
    """Return paginated tokens HTML partials for the admin UI.

    This HTMX endpoint returns only the partial HTML used by the admin UI for
    API tokens. It supports two render modes:

    - default: full token cards + pagination controls
    - ``render="controls"``: return only pagination controls

    Args:
        request: FastAPI request object used by the template engine.
        page: Page number (1-indexed).
        per_page: Number of items per page (bounded by settings).
        include_inactive: If True, include inactive/expired tokens in results.
        render: Render mode; one of None or "controls".
        q: Search query string to filter tokens by name.
        team_id: Filter by team ID.
        db: Database session (dependency-injected).
        user: Authenticated user object from dependency injection.

    Returns:
        HTMLResponse: A rendered template response containing either the token
        cards partial or pagination controls depending on ``render``.
    """
    user_email = get_user_email(user)
    LOGGER.debug(f"User {user_email} requested tokens HTML partial (page={page}, per_page={per_page}, include_inactive={include_inactive}, render={render}, q={q}, team_id={team_id})")

    # Normalize per_page within configured bounds
    per_page = max(settings.pagination_min_page_size, min(per_page, settings.pagination_max_page_size))

    # Build base query: tokens owned by this user OR in user's teams
    token_service = TokenCatalogService(db)
    user_team_ids = await token_service.get_user_team_ids(user_email)

    conditions = [EmailApiToken.user_email == user_email]
    if user_team_ids:
        conditions.append(EmailApiToken.team_id.in_(user_team_ids))

    query = select(EmailApiToken).where(or_(*conditions))

    if team_id:
        query = query.where(EmailApiToken.team_id == team_id)

    if not include_inactive:
        query = query.where(and_(EmailApiToken.is_active.is_(True), or_(EmailApiToken.expires_at.is_(None), EmailApiToken.expires_at > utc_now())))

    # Apply search filter on name (case-insensitive)
    if q and isinstance(q, str):
        query = query.where(EmailApiToken.name.ilike(f"%{_escape_like(q.strip().lower())}%", escape="\\"))

    query = query.order_by(desc(EmailApiToken.created_at))

    # Build query params for pagination links
    query_params: Dict[str, Any] = {}
    if include_inactive:
        query_params["include_inactive"] = "true"
    if team_id:
        query_params["team_id"] = team_id
    if q and isinstance(q, str):
        query_params["q"] = q

    # Use unified pagination function
    paginated_result = await paginate_query(
        db=db,
        query=query,
        page=page,
        per_page=per_page,
        cursor=None,
        base_url=f"{settings.app_root_path}/admin/tokens/partial",
        query_params=query_params,
        use_cursor_threshold=False,
    )

    tokens_db = paginated_result["data"]
    pagination = paginated_result["pagination"]
    links = paginated_result["links"]

    base_url = f"{settings.app_root_path}/admin/tokens/partial"

    if render == "controls":
        db.commit()
        return request.app.state.templates.TemplateResponse(
            request,
            "pagination_controls.html",
            {
                "request": request,
                "pagination": pagination.model_dump(),
                "base_url": base_url,
                "hx_target": "#tokens-table",
                "hx_indicator": "#tokens-loading",
                "query_params": query_params,
                "root_path": _resolve_root_path(request),
            },
        )

    # Build token data with revocation info and team names

    # Batch fetch team names
    team_ids_set = {t.team_id for t in tokens_db if t.team_id}
    team_map: Dict[str, str] = {}
    if team_ids_set:
        teams = db.execute(select(EmailTeam.id, EmailTeam.name).where(EmailTeam.id.in_(team_ids_set), EmailTeam.is_active.is_(True))).all()
        team_map = {team.id: team.name for team in teams}

    # Batch fetch revocation info (single query instead of N+1)
    revocation_map = await token_service.get_token_revocations_batch([t.jti for t in tokens_db])

    # Build token data list
    data = []
    for token in tokens_db:
        revocation_info = revocation_map.get(token.jti)
        data.append(
            {
                "id": token.id,
                "name": token.name,
                "description": token.description,
                "user_email": token.user_email,
                "team_id": token.team_id,
                "team_name": team_map.get(token.team_id) if token.team_id else None,
                "created_at": token.created_at,
                "expires_at": token.expires_at,
                "last_used": token.last_used,
                "is_active": token.is_active,
                "is_revoked": revocation_info is not None,
                "revoked_at": revocation_info.revoked_at if revocation_info else None,
                "revoked_by": revocation_info.revoked_by if revocation_info else None,
                "revocation_reason": revocation_info.reason if revocation_info else None,
                "tags": token.tags or [],
                "server_id": token.server_id,
                "resource_scopes": token.resource_scopes or [],
                "ip_restrictions": token.ip_restrictions or [],
                "time_restrictions": token.time_restrictions or {},
                "usage_limits": token.usage_limits or {},
            }
        )
    data = jsonable_encoder(data)
    for item in data:
        item["_json"] = orjson.dumps(item).decode()

    db.commit()

    return request.app.state.templates.TemplateResponse(
        request,
        "tokens_partial.html",
        {
            "request": request,
            "data": data,
            "pagination": pagination.model_dump(),
            "links": links.model_dump() if links else None,
            "root_path": _resolve_root_path(request),
            "include_inactive": include_inactive,
            "team_id": team_id,
        },
    )


@admin_router.get("/tokens/search", response_class=JSONResponse)
@require_permission("tokens.read", allow_admin_bypass=False)
async def admin_search_tokens(
    q: str = Query("", max_length=500, description="Search query"),
    include_inactive: bool = False,
    limit: int = Query(settings.pagination_default_page_size, ge=1, le=settings.pagination_max_page_size, description="Max results"),
    team_id: Optional[str] = Depends(_validated_team_id_param),
    db: Session = Depends(get_db),
    user=Depends(get_current_user_with_permissions),
):
    """Search API tokens by name.

    Args:
        q (str): Search query string to match against token names.
        include_inactive (bool): Whether to include inactive/revoked tokens.
        limit (int): Maximum number of results to return.
        team_id (Optional[str]): Filter by team ID.
        db (Session): Database session dependency.
        user: Current authenticated user.

    Returns:
        JSONResponse: List of matching tokens with basic info.
    """
    user_email = get_user_email(user)
    LOGGER.debug(f"User {user_email} searching tokens with query='{q}', include_inactive={include_inactive}, limit={limit}, team_id={team_id}")

    # Build base query: tokens owned by this user OR in user's teams
    token_service = TokenCatalogService(db)
    user_team_ids = await token_service.get_user_team_ids(user_email)

    conditions = [EmailApiToken.user_email == user_email]
    if user_team_ids:
        conditions.append(EmailApiToken.team_id.in_(user_team_ids))

    query = select(EmailApiToken).where(or_(*conditions))

    if team_id:
        query = query.where(EmailApiToken.team_id == team_id)

    if not include_inactive:
        query = query.where(and_(EmailApiToken.is_active.is_(True), or_(EmailApiToken.expires_at.is_(None), EmailApiToken.expires_at > utc_now())))

    # Apply search filter on name (case-insensitive)
    if q and isinstance(q, str):
        query = query.where(EmailApiToken.name.ilike(f"%{_escape_like(q.strip().lower())}%", escape="\\"))

    query = query.order_by(desc(EmailApiToken.created_at)).limit(limit)

    result = db.execute(query)
    tokens = result.scalars().all()

    # Batch fetch revocation info (single query instead of N+1)
    revocation_map = await token_service.get_token_revocations_batch([t.jti for t in tokens])

    token_data = []
    for token in tokens:
        revocation_info = revocation_map.get(token.jti)
        token_data.append(
            {
                "id": token.id,
                "name": token.name,
                "description": token.description,
                "user_email": token.user_email,
                "team_id": token.team_id,
                "created_at": token.created_at,
                "expires_at": token.expires_at,
                "last_used": token.last_used,
                "is_active": token.is_active,
                "is_revoked": revocation_info is not None,
                "tags": token.tags or [],
                "server_id": token.server_id,
            }
        )

    db.commit()
    return token_data


@admin_router.delete("/tokens/{token_id}", status_code=204)
@require_permission("tokens.revoke", allow_admin_bypass=False)
async def admin_revoke_token(
    token_id: str,
    current_user=Depends(get_current_user_with_permissions),
    db: Session = Depends(get_db),
) -> None:
    """Revoke a token from the admin UI.

    This endpoint uses the admin CSRF protection already enforced by the admin router.
    """
    token_service = TokenCatalogService(db)
    success = await token_service.revoke_token(
        token_id=token_id,
        user_email=current_user["email"],
        revoked_by=current_user["email"],
        reason="Revoked by user via admin interface",
    )
    if not success:
        raise HTTPException(status_code=404, detail="Token not found")

    db.commit()


@admin_router.get("/a2a/partial", response_class=HTMLResponse)
@require_permission("a2a.read", allow_admin_bypass=False)
async def admin_a2a_partial_html(
    request: Request,
    page: int = Query(1, ge=1, description="Page number (1-indexed)"),
    per_page: int = Query(settings.pagination_default_page_size, ge=1, le=settings.pagination_max_page_size, description="Items per page"),
    q: str = Query("", max_length=500, description="Search query"),
    tags: QueryTagsFilter = None,
    include_inactive: bool = False,
    render: QueryRenderMode = None,
    gateway_id: QueryGatewayIdList = None,
    team_id: Optional[str] = Depends(_validated_team_id_param),
    db: Session = Depends(get_db),
    user=Depends(get_current_user_with_permissions),
):
    """Return paginated a2a agents HTML partials for the admin UI.

    This HTMX endpoint returns only the partial HTML used by the admin UI for
    a2a agents. It supports three render modes:

    - default: full table partial (rows + controls)
    - ``render="controls"``: return only pagination controls
    - ``render="selector"``: return selector items for infinite scroll

    Args:
        request (Request): FastAPI request object used by the template engine.
        page (int): Page number (1-indexed).
        per_page (int): Number of items per page (bounded by settings).
        q (str): Free-text query string.
        tags (Optional[str]): Tag filter expression (comma=OR, plus=AND).
        include_inactive (bool): If True, include inactive a2a agents in results.
        render (Optional[str]): Render mode; one of None, "controls", "selector".
        gateway_id (Optional[str]): Filter by gateway ID(s), comma-separated.
        team_id (Optional[str]): Filter by team ID.
        db (Session): Database session (dependency-injected).
        user: Authenticated user object from dependency injection.

    Returns:
        Union[HTMLResponse, TemplateResponse]: A rendered template response
        containing either the table partial, pagination controls, or selector
        items depending on ``render``. The response contains JSON-serializable
        encoded a2a agent data when templates expect it.
    """
    LOGGER.debug(
        f"User {get_user_email(user)} requested a2a_agents HTML partial (page={page}, per_page={per_page}, include_inactive={include_inactive}, render={render}, gateway_id={gateway_id}, team_id={team_id})"
    )
    search_query = _normalize_search_query(q)
    normalized_tags = _normalize_tags_query(tags)
    tag_groups = _parse_tag_filter_groups(normalized_tags)
    # Normalize per_page within configured bounds
    per_page = max(settings.pagination_min_page_size, min(per_page, settings.pagination_max_page_size))

    user_email = get_user_email(user)

    # Team scoping
    team_ids = await _get_user_team_ids(user, db)

    # Build base query
    query = select(DbA2AAgent)

    # Note: A2A agents don't have gateway_id field, they connect directly via endpoint_url
    # The gateway_id parameter is ignored for A2A agents

    if not include_inactive:
        query = query.where(DbA2AAgent.enabled.is_(True))

    # Build access conditions
    # When team_id is specified, show ONLY items from that team (team-scoped view)
    # Otherwise, show all accessible items (All Teams view)
    if team_id:
        # Team-specific view: only show a2a agents from the specified team
        if team_id in team_ids:
            # Apply visibility check: team/public resources + user's own resources (including private)
            team_access = [
                and_(DbA2AAgent.team_id == team_id, DbA2AAgent.visibility.in_(["team", "public"])),
                and_(DbA2AAgent.team_id == team_id, DbA2AAgent.owner_email == user_email),
            ]
            query = query.where(or_(*team_access))
            LOGGER.debug(f"Filtering a2a agents by team_id: {team_id}")
        else:
            # User is not a member of this team, return no results using SQLAlchemy's false()
            LOGGER.warning(f"User {user_email} attempted to filter by team {team_id} but is not a member")
            query = query.where(false())
    else:
        # All Teams view: apply standard access conditions (owner, team, public)
        access_conditions = []
        access_conditions.append(_owner_access_condition(DbA2AAgent.owner_email, DbA2AAgent.team_id, user_email=user_email, team_ids=team_ids, user=user))
        if team_ids:
            access_conditions.append(and_(DbA2AAgent.team_id.in_(team_ids), DbA2AAgent.visibility.in_(["team", "public"])))
        access_conditions.append(DbA2AAgent.visibility == "public")
        query = query.where(or_(*access_conditions))

    if search_query:
        query = query.where(
            or_(
                _like_contains(func.lower(DbA2AAgent.id), search_query),
                _like_contains(func.lower(DbA2AAgent.name), search_query),
                _like_contains(func.lower(coalesce(DbA2AAgent.endpoint_url, "")), search_query),
                _like_contains(func.lower(coalesce(DbA2AAgent.description, "")), search_query),
            )
        )

    query = _apply_tag_filter_groups(query, db, DbA2AAgent.tags, tag_groups)

    # Apply pagination ordering for cursor support
    query = query.order_by(desc(DbA2AAgent.created_at), desc(DbA2AAgent.id))

    # Build query params for pagination links
    query_params = {}
    if include_inactive:
        query_params["include_inactive"] = "true"
    if gateway_id:
        query_params["gateway_id"] = gateway_id
    if team_id:
        query_params["team_id"] = team_id
    if search_query:
        query_params["q"] = search_query
    if normalized_tags:
        query_params["tags"] = normalized_tags

    # Use unified pagination function
    root_path = _resolve_root_path(request)
    base_url = f"{root_path}/admin/a2a/partial"
    paginated_result = await paginate_query(
        db=db,
        query=query,
        page=page,
        per_page=per_page,
        cursor=None,  # HTMX partials use page-based navigation
        base_url=base_url,
        query_params=query_params,
        use_cursor_threshold=False,  # Disable auto-cursor switching for UI
    )

    # Extract paginated a2a_agents (DbA2AAgent objects)
    a2a_agents_db = paginated_result["data"]
    pagination = paginated_result["pagination"]
    links = paginated_result["links"]

    # Batch fetch team names for the a2a_agents to avoid N+1 queries
    team_ids_set = {p.team_id for p in a2a_agents_db if p.team_id}
    team_map = {}
    if team_ids_set:
        teams = db.execute(select(EmailTeam.id, EmailTeam.name).where(EmailTeam.id.in_(team_ids_set), EmailTeam.is_active.is_(True))).all()
        team_map = {team.id: team.name for team in teams}

    # Apply team names to DB objects before conversion
    for p in a2a_agents_db:
        p.team = team_map.get(p.team_id) if p.team_id else None

    # Batch convert to Pydantic models using a2a service
    # This eliminates the N+1 query problem from calling get_a2a_details() in a loop
    a2a_agents_pydantic = []
    failed_count = 0
    for a in a2a_agents_db:
        try:
            a2a_agents_pydantic.append(a2a_service.convert_agent_to_read(a, include_metrics=False))
        except (ValidationError, ValueError, KeyError, TypeError, binascii.Error) as e:
            failed_count += 1
            LOGGER.exception(f"Failed to convert a2a agent {getattr(a, 'id', 'unknown')} ({getattr(a, 'name', 'unknown')}): {e}")
    _adjust_pagination_for_conversion_failures(pagination, failed_count, len(a2a_agents_pydantic))
    data = jsonable_encoder(a2a_agents_pydantic)

    # End the read-only transaction before template rendering to avoid idle-in-transaction timeouts.
    db.commit()

    if render == "controls":
        return request.app.state.templates.TemplateResponse(
            request,
            "pagination_controls.html",
            {
                "request": request,
                "pagination": pagination.model_dump(),
                "base_url": base_url,
                "hx_target": "#agents-table-body",
                "hx_indicator": "#agents-loading",
                "query_params": query_params,
                "root_path": _resolve_root_path(request),
            },
        )

    if render == "selector":
        return request.app.state.templates.TemplateResponse(
            request,
            "agents_selector_items.html",
            {
                "request": request,
                "data": data,
                "pagination": pagination.model_dump(),
                "root_path": _resolve_root_path(request),
                "gateway_id": gateway_id,
            },
        )

    _is_admin = bool(user.get("is_admin", False) if isinstance(user, dict) else getattr(user, "is_admin", False))
    _team_roles = _get_user_team_roles(db, user_email) if not _is_admin else {}
    return request.app.state.templates.TemplateResponse(
        request,
        "agents_partial.html",
        {
            "request": request,
            "data": data,
            "pagination": pagination.model_dump(),
            "links": links.model_dump() if links else None,
            "root_path": _resolve_root_path(request),
            "include_inactive": include_inactive,
            "query_params": query_params,
            "current_user_email": user_email,
            "is_admin": _is_admin,
            "user_team_roles": _team_roles,
        },
    )


@admin_router.get("/a2a/ids", response_class=JSONResponse)
@require_permission("a2a.read", allow_admin_bypass=False)
async def admin_get_all_agent_ids(
    include_inactive: bool = False,
    team_id: Optional[str] = Depends(_validated_team_id_param),
    db: Session = Depends(get_db),
    user=Depends(get_current_user_with_permissions),
):
    """Return all agent IDs accessible to the current user (select-all helper).

    This endpoint is used by UI "Select All" helpers to fetch only the IDs
    of a2a agents the requesting user can access (owner, team, or public).

    Args:
        include_inactive (bool): When True include a2a agents that are inactive.
        team_id (Optional[str]): Filter by team ID.
        db (Session): Database session (injected dependency).
        user: Authenticated user object from dependency injection.

    Returns:
        dict: A dictionary containing two keys:
            - "agent_ids": List[str] of accessible agent IDs.
            - "count": int number of IDs returned.
    """
    user_email = get_user_email(user)
    team_ids = await _get_user_team_ids(user, db)

    query = select(DbA2AAgent.id)

    if not include_inactive:
        query = query.where(DbA2AAgent.enabled.is_(True))

    # Build access conditions
    # When team_id is specified, show ONLY items from that team (team-scoped view)
    # Otherwise, show all accessible items (All Teams view)
    if team_id:
        if team_id in team_ids:
            # Apply visibility check: team/public resources + user's own resources (including private)
            team_access = [
                and_(DbA2AAgent.team_id == team_id, DbA2AAgent.visibility.in_(["team", "public"])),
                and_(DbA2AAgent.team_id == team_id, DbA2AAgent.owner_email == user_email),
            ]
            query = query.where(or_(*team_access))
            LOGGER.debug(f"Filtering A2A agent IDs by team_id: {team_id}")
        else:
            LOGGER.warning(f"User {user_email} attempted to filter A2A agent IDs by team {team_id} but is not a member")
            query = query.where(false())
    else:
        # All Teams view: apply standard access conditions (owner, team, public)
        access_conditions = []
        access_conditions.append(_owner_access_condition(DbA2AAgent.owner_email, DbA2AAgent.team_id, user_email=user_email, team_ids=team_ids, user=user))
        access_conditions.append(DbA2AAgent.visibility == "public")
        if team_ids:
            access_conditions.append(and_(DbA2AAgent.team_id.in_(team_ids), DbA2AAgent.visibility.in_(["team", "public"])))
        query = query.where(or_(*access_conditions))

    agent_ids = [row[0] for row in db.execute(query).all()]
    return {"agent_ids": agent_ids, "count": len(agent_ids)}


@admin_router.get("/a2a/search", response_class=JSONResponse)
@require_permission("a2a.read", allow_admin_bypass=False)
async def admin_search_a2a_agents(
    q: str = Query("", max_length=500, description="Search query"),
    tags: QueryTagsFilter = None,
    include_inactive: bool = False,
    limit: int = Query(settings.pagination_default_page_size, ge=1, le=settings.pagination_max_page_size),
    team_id: Optional[str] = Depends(_validated_team_id_param),
    db: Session = Depends(get_db),
    user=Depends(get_current_user_with_permissions),
):
    """Search a2a agents by name or description for selector search.

    Performs a case-insensitive search over prompt names and descriptions
    and returns a limited list of matching a2a agents suitable for selector
    UIs (id, name, description).

    Args:
        q (str): Search query string.
        tags (Optional[str]): Tag filter expression (comma=OR, plus=AND).
        include_inactive (bool): When True include a2a agents that are inactive.
        limit (int): Maximum number of results to return (bounded by the query parameter).
        team_id (Optional[str]): Filter by team ID.
        db (Session): Database session (injected dependency).
        user: Authenticated user object from dependency injection.

    Returns:
        dict: A dictionary containing:
            - "agents": List[dict] where each dict has keys "id", "name", "description".
            - "count": int number of matched a2a agents returned.
    """
    user_email = get_user_email(user)
    search_query = _normalize_search_query(q)
    normalized_tags = _normalize_tags_query(tags)
    tag_groups = _parse_tag_filter_groups(normalized_tags)
    if not search_query and not tag_groups:
        return _build_search_response(entity_key="agents", entity_type="agents", items=[], query=search_query, tags=normalized_tags, tag_groups=tag_groups)

    team_ids = await _get_user_team_ids(user, db)

    query = select(DbA2AAgent.id, DbA2AAgent.name, DbA2AAgent.endpoint_url, DbA2AAgent.description)

    if not include_inactive:
        query = query.where(DbA2AAgent.enabled.is_(True))

    # Build access conditions
    # When team_id is specified, show ONLY items from that team (team-scoped view)
    # Otherwise, show all accessible items (All Teams view)
    if team_id:
        if team_id in team_ids:
            # Apply visibility check: team/public resources + user's own resources (including private)
            team_access = [
                and_(DbA2AAgent.team_id == team_id, DbA2AAgent.visibility.in_(["team", "public"])),
                and_(DbA2AAgent.team_id == team_id, DbA2AAgent.owner_email == user_email),
            ]
            query = query.where(or_(*team_access))
            LOGGER.debug(f"Filtering A2A agent search by team_id: {team_id}")
        else:
            LOGGER.warning(f"User {user_email} attempted to filter A2A agent search by team {team_id} but is not a member")
            query = query.where(false())
    else:
        # All Teams view: apply standard access conditions (owner, team, public)
        access_conditions = []
        access_conditions.append(_owner_access_condition(DbA2AAgent.owner_email, DbA2AAgent.team_id, user_email=user_email, team_ids=team_ids, user=user))
        access_conditions.append(DbA2AAgent.visibility == "public")
        if team_ids:
            access_conditions.append(and_(DbA2AAgent.team_id.in_(team_ids), DbA2AAgent.visibility.in_(["team", "public"])))
        query = query.where(or_(*access_conditions))

    if search_query:
        search_conditions = [
            _like_contains(func.lower(DbA2AAgent.id), search_query),
            _like_contains(func.lower(DbA2AAgent.name), search_query),
            _like_contains(func.lower(coalesce(DbA2AAgent.endpoint_url, "")), search_query),
            _like_contains(func.lower(coalesce(DbA2AAgent.description, "")), search_query),
        ]
        query = query.where(or_(*search_conditions))

    query = _apply_tag_filter_groups(query, db, DbA2AAgent.tags, tag_groups)

    if search_query:
        query = query.order_by(
            case(
                (func.lower(DbA2AAgent.name).startswith(search_query), 1),
                (func.lower(coalesce(DbA2AAgent.endpoint_url, "")).startswith(search_query), 1),
                else_=2,
            ),
            func.lower(DbA2AAgent.name),
        )
    else:
        query = query.order_by(func.lower(DbA2AAgent.name))
    query = query.limit(limit)

    results = db.execute(query).all()
    agents = []
    for row in results:
        agents.append(
            {
                "id": row.id,
                "name": row.name,
                "endpoint_url": row.endpoint_url,
                "description": row.description,
            }
        )

    return _build_search_response(entity_key="agents", entity_type="agents", items=agents, query=search_query, tags=normalized_tags, tag_groups=tag_groups)


@admin_router.get("/search", response_class=JSONResponse)
@require_permission("admin.dashboard", allow_admin_bypass=False)
async def admin_unified_search(
    q: str = Query("", max_length=500, description="Search query"),
    tags: QueryTagsFilter = None,
    entity_types: QueryEntityTypes = None,
    include_inactive: bool = False,
    limit: int = Query(8, ge=1, le=settings.pagination_max_page_size, description="Per-entity result limit"),
    limit_per_type: Optional[int] = Query(
        None,
        ge=1,
        le=settings.pagination_max_page_size,
        description="Optional alias for per-entity result limit",
    ),
    gateway_id: QueryGatewayIdList = None,
    team_id: Optional[str] = Depends(_validated_team_id_param),
    db: Session = Depends(get_db),
    user=Depends(get_current_user_with_permissions),
):
    """Unified search across primary admin entities.

    Searches servers, gateways, tools, resources, prompts, agents, teams, roots,
    and optionally users (when the caller has ``admin.user_management`` permission).

    Args:
        q (str): Free-text search query.
        tags (Optional[str]): Tag filter expression (comma=OR, plus=AND).
        entity_types (Optional[str]): Optional comma-separated entity type list.
            Supported values: servers, gateways, tools, resources, prompts,
            agents, teams, users, roots.
        include_inactive (bool): Whether to include inactive entities.
        limit (int): Default per-entity limit for returned items.
        limit_per_type (Optional[int]): Optional alias overriding ``limit``.
        gateway_id (Optional[str]): Gateway filter for tools/resources/prompts.
        team_id (Optional[str]): Team scope filter.
        db (Session): Database session.
        user: Authenticated user context.

    Returns:
        dict[str, Any]: Grouped and flattened search results with metadata.

    Raises:
        HTTPException: If ``entity_types`` is provided but contains no supported values.
    """
    search_query = _normalize_search_query(q)
    normalized_tags = _normalize_tags_query(tags)
    normalized_entity_types = _normalize_tags_query(entity_types)
    tag_groups = _parse_tag_filter_groups(normalized_tags)

    supported_entity_types = ["servers", "gateways", "tools", "resources", "prompts", "agents", "teams", "users", "roots"]
    default_entity_types = ["servers", "gateways", "tools", "resources", "prompts", "agents", "teams", "roots"]
    selected_entity_types: list[str] = []
    if normalized_entity_types:
        for raw_entity_type in normalized_entity_types.split(","):
            candidate = raw_entity_type.strip().lower()
            if not candidate:
                continue
            if candidate == "a2a":
                candidate = "agents"
            if candidate in supported_entity_types and candidate not in selected_entity_types:
                selected_entity_types.append(candidate)
    else:
        selected_entity_types = default_entity_types.copy()

    users_explicitly_requested = bool(normalized_entity_types and "users" in selected_entity_types)
    if "users" in selected_entity_types:
        can_search_users = await _has_permission(db=db, user=user, permission="admin.user_management")
        if not can_search_users:
            selected_entity_types = [entity_type for entity_type in selected_entity_types if entity_type != "users"]
            if users_explicitly_requested and not selected_entity_types:
                raise HTTPException(status_code=403, detail=_ACCESS_DENIED_MSG)

    if not selected_entity_types:
        raise HTTPException(status_code=400, detail="No valid entity_types requested")

    resolved_limit = _normalize_int_query(limit, 8)
    effective_limit = _normalize_int_query(limit_per_type, resolved_limit)
    effective_limit = max(1, min(effective_limit, settings.pagination_max_page_size))

    if not search_query and not tag_groups:
        return {
            "query": search_query,
            "tags": normalized_tags,
            "entity_types": selected_entity_types,
            "limit_per_type": effective_limit,
            "filters_applied": {"q": search_query, "tags": normalized_tags, "tag_groups": tag_groups},
            "results": {key: [] for key in selected_entity_types},
            "groups": [],
            "items": [],
            "count": 0,
        }

    async def _safe_entity_search(search_callable, empty_key: str, **kwargs: Any) -> dict[str, Any]:
        """Execute entity search and return empty results on auth denials.

        Intentional silent 401/403 suppression: unified search spans entity types
        with heterogeneous permission gates (e.g. roots require admin.system_config
        with no admin bypass), and a single denial must not fail the whole search
        or leak existence of restricted entities to unprivileged callers.

        Args:
            search_callable: Async entity search function to execute.
            empty_key: Entity collection key used for fallback empty payloads.
            **kwargs: Parameters forwarded to ``search_callable``.

        Returns:
            Search result payload or an empty payload for auth-denied entities.

        Raises:
            HTTPException: Re-raised when the failure is not an auth-denied status.
        """
        try:
            return await search_callable(**kwargs)
        except HTTPException as exc:
            if exc.status_code in {401, 403}:
                return {empty_key: [], "items": [], "count": 0}
            raise

    # Pre-fetch team IDs once and inject into the user context so that
    # individual search functions reuse them via _get_user_team_ids().
    _team_ids = await _get_user_team_ids(user, db)
    user = dict(user)  # shallow copy to avoid mutating the caller's dict
    user["_cached_team_ids"] = _team_ids

    grouped_results: dict[str, list[dict[str, Any]]] = {entity_type: [] for entity_type in selected_entity_types}

    if "servers" in selected_entity_types:
        servers_result = await _safe_entity_search(
            admin_search_servers,
            "servers",
            q=search_query,
            tags=normalized_tags,
            include_inactive=include_inactive,
            limit=effective_limit,
            team_id=team_id,
            db=db,
            user=user,
        )
        grouped_results["servers"] = typing_cast(list[dict[str, Any]], servers_result.get("servers", servers_result.get("items", [])))

    if "gateways" in selected_entity_types:
        gateways_result = await _safe_entity_search(
            admin_search_gateways,
            "gateways",
            q=search_query,
            tags=normalized_tags,
            include_inactive=include_inactive,
            limit=effective_limit,
            team_id=team_id,
            db=db,
            user=user,
        )
        grouped_results["gateways"] = typing_cast(list[dict[str, Any]], gateways_result.get("gateways", gateways_result.get("items", [])))

    if "tools" in selected_entity_types:
        tools_result = await _safe_entity_search(
            admin_search_tools,
            "tools",
            q=search_query,
            tags=normalized_tags,
            include_inactive=include_inactive,
            limit=effective_limit,
            gateway_id=gateway_id,
            team_id=team_id,
            db=db,
            user=user,
        )
        grouped_results["tools"] = typing_cast(list[dict[str, Any]], tools_result.get("tools", tools_result.get("items", [])))

    if "resources" in selected_entity_types:
        resources_result = await _safe_entity_search(
            admin_search_resources,
            "resources",
            q=search_query,
            tags=normalized_tags,
            include_inactive=include_inactive,
            limit=effective_limit,
            gateway_id=gateway_id,
            team_id=team_id,
            db=db,
            user=user,
        )
        grouped_results["resources"] = typing_cast(list[dict[str, Any]], resources_result.get("resources", resources_result.get("items", [])))

    if "prompts" in selected_entity_types:
        prompts_result = await _safe_entity_search(
            admin_search_prompts,
            "prompts",
            q=search_query,
            tags=normalized_tags,
            include_inactive=include_inactive,
            limit=effective_limit,
            gateway_id=gateway_id,
            team_id=team_id,
            db=db,
            user=user,
        )
        grouped_results["prompts"] = typing_cast(list[dict[str, Any]], prompts_result.get("prompts", prompts_result.get("items", [])))

    if "agents" in selected_entity_types:
        agents_result = await _safe_entity_search(
            admin_search_a2a_agents,
            "agents",
            q=search_query,
            tags=normalized_tags,
            include_inactive=include_inactive,
            limit=effective_limit,
            team_id=team_id,
            db=db,
            user=user,
        )
        grouped_results["agents"] = typing_cast(list[dict[str, Any]], agents_result.get("agents", agents_result.get("items", [])))

    # Teams and users do not support tag filtering; only include when a text query exists.
    if "teams" in selected_entity_types and search_query:
        teams_result = await _safe_entity_search(
            admin_search_teams,
            "teams",
            q=search_query,
            include_inactive=include_inactive,
            limit=effective_limit,
            visibility=None,
            db=db,
            user=user,
        )
        if isinstance(teams_result, list):
            grouped_results["teams"] = typing_cast(list[dict[str, Any]], teams_result)
        else:
            grouped_results["teams"] = typing_cast(list[dict[str, Any]], teams_result.get("teams", teams_result.get("items", [])))

    if "users" in selected_entity_types and search_query:
        users_result = await _safe_entity_search(
            admin_search_users,
            "users",
            q=search_query,
            limit=effective_limit,
            db=db,
            user=user,
        )
        grouped_results["users"] = typing_cast(list[dict[str, Any]], users_result.get("users", users_result.get("items", [])))

    # Roots do not support tag filtering; only include when a text query exists.
    if "roots" in selected_entity_types and search_query:
        roots_result = await _safe_entity_search(
            admin_search_roots,
            "roots",
            q=search_query,
            limit=effective_limit,
            user=user,
        )
        grouped_results["roots"] = typing_cast(list[dict[str, Any]], roots_result.get("roots", roots_result.get("items", [])))

    groups = []
    flat_items: list[dict[str, Any]] = []
    for entity_type in selected_entity_types:
        items = grouped_results.get(entity_type, [])
        groups.append({"entity_type": entity_type, "count": len(items), "items": items})
        for item in items:
            enriched_item = dict(item)
            enriched_item["entity_type"] = entity_type
            flat_items.append(enriched_item)

    return {
        "query": search_query,
        "tags": normalized_tags,
        "entity_types": selected_entity_types,
        "limit_per_type": effective_limit,
        "filters_applied": {"q": search_query, "tags": normalized_tags, "tag_groups": tag_groups},
        "results": grouped_results,
        "groups": groups,
        "items": flat_items,
        "count": len(flat_items),
    }


@admin_router.get("/tools/{tool_id}", response_model=ToolRead)
@require_permission("tools.read", allow_admin_bypass=False)
async def admin_get_tool(tool_id: str, request: Request, db: Session = Depends(get_db), user=Depends(get_current_user_with_permissions)) -> Dict[str, Any]:
    """
    Retrieve specific tool details for the admin UI.

    This endpoint fetches the details of a specific tool from the database
    by its ID. It provides access to all information about the tool for
    viewing and management purposes.

    Args:
        tool_id (str): The ID of the tool to retrieve.
        request (Request): Incoming FastAPI request (for visibility scope resolution).
        db (Session): Database session dependency.
        user (str): Authenticated user dependency.

    Returns:
        ToolRead: The tool details formatted with by_alias=True.

    Raises:
        HTTPException: If the tool is not found.
        Exception: For any other unexpected errors.

    Examples:
        >>> callable(admin_get_tool)
        True
        >>> admin_get_tool.__name__
        'admin_get_tool'
    """
    LOGGER.debug(f"User {get_user_email(user)} requested details for tool ID {tool_id}")
    auth_user_email, auth_token_teams = get_scoped_resource_access_context(request, user)
    _user_email = get_user_email(user)
    _is_admin = bool(user.get("is_admin", False) if isinstance(user, dict) else getattr(user, "is_admin", False))
    _team_roles = _get_user_team_roles(db, _user_email) if not _is_admin else {}
    try:
        tool = await tool_service.get_tool(
            db,
            tool_id,
            requesting_user_email=auth_user_email,
            requesting_user_is_admin=_is_admin,
            requesting_user_team_roles=_team_roles,
            token_teams=auth_token_teams,
        )
        return tool.model_dump(by_alias=True)
    except ToolNotFoundError as e:
        raise HTTPException(status_code=404, detail=str(e))
    except Exception as e:
        # Catch any other unexpected errors and re-raise or log as needed
        LOGGER.error(f"Error getting tool {tool_id}: {e}")
        raise e  # Re-raise for now, or return a 500 JSONResponse if preferred for API consistency


def _build_auth_obj_from_form(form: Any) -> Optional[dict[str, Any]]:
    """Parse auth fields from a form and return a serialized auth object, or None.

    Args:
        form: Multipart form data containing auth_type and credential fields.

    Returns:
        A dict with auth_type and encrypted auth_value, or None if no valid auth provided.
    """
    auth_headers_json = form.get("auth_headers") or ""
    auth_headers: list[dict[str, Any]] = []
    if auth_headers_json:
        try:
            auth_headers = orjson.loads(auth_headers_json)
        except (orjson.JSONDecodeError, ValueError):
            auth_headers = []

    auth_type = form.get("auth_type", "")
    auth_obj: Optional[dict[str, Any]] = None
    if auth_type:
        if auth_type == "basic":
            username = form.get("auth_username", "")
            password = form.get("auth_password", "")
            if username and password:
                creds = base64.b64encode(f"{username}:{password}".encode("utf-8")).decode()
                auth_value = encode_auth({"Authorization": f"Basic {creds}"})
                auth_obj = {"auth_type": auth_type, "auth_value": auth_value}
        elif auth_type == "bearer":
            token = form.get("auth_token", "")
            if token:
                auth_value = encode_auth({"Authorization": f"Bearer {token}"})
                auth_obj = {"auth_type": auth_type, "auth_value": auth_value}
        elif auth_type == "authheaders":
            if auth_headers:
                header_dict = {h.get("key"): h.get("value", "") for h in auth_headers if h.get("key")}
                if header_dict:
                    auth_value = encode_auth(header_dict)
                    auth_obj = {"auth_type": auth_type, "auth_value": auth_value}
            else:
                header_key = form.get("auth_header_key", "")
                header_value = form.get("auth_header_value", "")
                if header_key and header_value:
                    auth_value = encode_auth({header_key: header_value})
                    auth_obj = {"auth_type": auth_type, "auth_value": auth_value}
    return auth_obj


@admin_router.post("/tools/")
@admin_router.post("/tools")
@require_permission("tools.create", allow_admin_bypass=False)
async def admin_add_tool(
    request: Request,
    db: Session = Depends(get_db),
    user=Depends(get_current_user_with_permissions),
) -> JSONResponse:
    """
    Add a tool via the admin UI with error handling.

    Expects form fields:
      - name
      - url
      - description (optional)
      - requestType (mapped to request_type; defaults to "SSE")
      - integrationType (mapped to integration_type; defaults to "MCP")
      - headers (JSON string)
      - input_schema (JSON string)
      - output_schema (JSON string, optional)
      - jsonpath_filter (optional)
      - auth_type (optional)
      - auth_username (optional)
      - auth_password (optional)
      - auth_token (optional)
      - auth_header_key (optional)
      - auth_header_value (optional)

    Logs the raw form data and assembled tool_data for debugging.

    Args:
        request (Request): the FastAPI request object containing the form data.
        db (Session): the SQLAlchemy database session.
        user (str): identifier of the authenticated user.

    Returns:
        JSONResponse: a JSON response with `{"message": ..., "success": ...}` and an appropriate HTTP status code.

    Raises:
        HTTPException: 422 when public visibility is disabled and request is team-scoped.

    Examples:
        >>> callable(admin_add_tool)
        True
        >>> admin_add_tool.__name__
        'admin_add_tool'
    """
    LOGGER.debug(f"User {get_user_email(user)} is adding a new tool")
    form = await request.form()
    LOGGER.debug(f"Received form data: {dict(form)}")
    team_id = _form_team_id(form)
    integration_type = form.get("integrationType", "REST")
    request_type = form.get("requestType")
    visibility = str(form.get("visibility", "private"))
    _check_public_visibility_allowed(visibility, team_id=team_id)

    if request_type is None:
        if integration_type == "REST":
            request_type = "GET"  # or any valid REST method default
        elif integration_type == "MCP":
            request_type = "SSE"
        else:
            request_type = "GET"

    user_email = get_user_email(user)
    team_service = TeamManagementService(db)
    team_id = await team_service.verify_team_for_user(user_email, team_id)
    # Parse tags from comma-separated string
    tags_str = str(form.get("tags", ""))
    tags: list[str] = [tag.strip() for tag in tags_str.split(",") if tag.strip()] if tags_str else []

    # Build auth object from form fields
    auth_obj = _build_auth_obj_from_form(form)

    # Safely parse potential JSON strings from form
    headers_raw = form.get("headers")
    input_schema_raw = form.get("input_schema")
    output_schema_raw = form.get("output_schema")
    annotations_raw = form.get("annotations")

    # Parse JSON fields with validation
    try:
        headers = orjson.loads(headers_raw if isinstance(headers_raw, str) and headers_raw else "{}")
        input_schema = orjson.loads(input_schema_raw if isinstance(input_schema_raw, str) and input_schema_raw else "{}")
        output_schema = orjson.loads(output_schema_raw) if isinstance(output_schema_raw, str) and output_schema_raw else None
        annotations = orjson.loads(annotations_raw if isinstance(annotations_raw, str) and annotations_raw else "{}")
        query_mapping = orjson.loads(form.get("query_mapping") or "{}")
        header_mapping = orjson.loads(form.get("header_mapping") or "{}")
        allowlist = orjson.loads(form.get("allowlist") or "[]")
        plugin_chain_pre = orjson.loads(form.get("plugin_chain_pre") or "[]")
        plugin_chain_post = orjson.loads(form.get("plugin_chain_post") or "[]")
    except orjson.JSONDecodeError as ex:
        LOGGER.error(f"Invalid JSON in form field: {str(ex)}")
        return ORJSONResponse(
            content={"message": f"Invalid JSON in form field: {str(ex)}", "success": False},
            status_code=422,
        )

    tool_data: dict[str, Any] = {
        "name": form.get("name"),
        "displayName": form.get("displayName"),
        "url": form.get("url"),
        "description": form.get("description"),
        "request_type": request_type,
        "integration_type": integration_type,
        "headers": headers,
        "input_schema": input_schema,
        "output_schema": output_schema,
        "annotations": annotations,
        "jsonpath_filter": form.get("jsonpath_filter", ""),
        "auth": auth_obj,
        "tags": tags,
        "visibility": visibility,
        "team_id": team_id,
        "owner_email": user_email,
        "query_mapping": query_mapping,
        "header_mapping": header_mapping,
        "timeout_ms": int(form.get("timeout_ms")) if form.get("timeout_ms") and form.get("timeout_ms").strip() else None,
        "expose_passthrough": form.get("expose_passthrough", "true"),
        "allowlist": allowlist,
        "plugin_chain_pre": plugin_chain_pre,
        "plugin_chain_post": plugin_chain_post,
    }
    LOGGER.debug(f"Tool data built: {tool_data}")
    try:
        tool = ToolCreate(**tool_data)
        LOGGER.debug(f"Validated tool data: {tool.model_dump(by_alias=True)}")

        # Extract creation metadata
        metadata = MetadataCapture.extract_creation_metadata(request, user)

        await tool_service.register_tool(
            db,
            tool,
            created_by=metadata["created_by"],
            created_from_ip=metadata["created_from_ip"],
            created_via=metadata["created_via"],
            created_user_agent=metadata["created_user_agent"],
            import_batch_id=metadata["import_batch_id"],
            federation_source=metadata["federation_source"],
        )
        return ORJSONResponse(
            content={"message": "Tool registered successfully!", "success": True},
            status_code=200,
        )
    except IntegrityError as ex:
        error_message = ErrorFormatter.format_database_error(ex)
        LOGGER.error(f"IntegrityError in admin_add_tool: {error_message}")
        return ORJSONResponse(status_code=409, content=error_message)
    except ToolNameConflictError as ex:
        LOGGER.error(f"ToolNameConflictError in admin_add_tool: {str(ex)}")
        return ORJSONResponse(content={"message": str(ex), "success": False}, status_code=409)
    except ToolError as ex:
        return ORJSONResponse(content={"message": str(ex), "success": False}, status_code=500)
    except ValidationError as ex:  # This block should catch ValidationError
        LOGGER.error(f"ValidationError in admin_add_tool: {str(ex)}")
        return ORJSONResponse(content=ErrorFormatter.format_validation_error(ex), status_code=422)
    except Exception as ex:
        LOGGER.error(f"Unexpected error in admin_add_tool: {str(ex)}")
        return ORJSONResponse(content={"message": str(ex), "success": False}, status_code=500)


@admin_router.post("/tools/{tool_id}/edit/", response_model=None)
@admin_router.post("/tools/{tool_id}/edit", response_model=None)
@require_permission("tools.update", allow_admin_bypass=False)
async def admin_edit_tool(
    tool_id: str,
    request: Request,
    db: Session = Depends(get_db),
    user=Depends(get_current_user_with_permissions),
) -> Response:
    """
    Edit a tool via the admin UI.

    Expects form fields:
      - name
      - displayName (optional)
      - url
      - description (optional)
      - requestType (to be mapped to request_type)
      - integrationType (to be mapped to integration_type)
      - headers (as a JSON string)
      - input_schema (as a JSON string)
      - output_schema (as a JSON string, optional)
      - jsonpathFilter (optional)
      - auth_type (optional, string: "basic", "bearer", or empty)
      - auth_username (optional, for basic auth)
      - auth_password (optional, for basic auth)
      - auth_token (optional, for bearer auth)
      - auth_header_key (optional, for headers auth)
      - auth_header_value (optional, for headers auth)

    Assembles the tool_data dictionary by remapping form keys into the
    snake-case keys expected by the schemas.

    Args:
        tool_id (str): The ID of the tool to edit.
        request (Request): FastAPI request containing form data.
        db (Session): Database session dependency.
        user (str): Authenticated user dependency.

    Returns:
        Response: A redirect response to the tools section of the admin
            dashboard with a status code of 303 (See Other), or a JSON response with
            an error message if the update fails.

    Raises:
        HTTPException: 422 when public visibility is disabled and request is team-scoped.

    Examples:
        >>> callable(admin_edit_tool)
        True
        >>> admin_edit_tool.__name__
        'admin_edit_tool'
    """
    LOGGER.debug(f"User {get_user_email(user)} is editing tool ID {tool_id}")
    form = await request.form()
    team_id = _form_team_id(form)
    # Parse tags from comma-separated string
    tags_str = str(form.get("tags", ""))
    tags: list[str] = [tag.strip() for tag in tags_str.split(",") if tag.strip()] if tags_str else []

    # Build auth object from form fields
    auth_obj = _build_auth_obj_from_form(form)

    visibility = str(form.get("visibility", "private"))
    _check_public_visibility_allowed(visibility, team_id=team_id)

    user_email = get_user_email(user)
    LOGGER.info(f"before Verifying team for user {user_email} with team_id {team_id}")
    team_service = TeamManagementService(db)
    team_id = await team_service.verify_team_for_user(user_email, team_id)

    headers_raw2 = form.get("headers")
    input_schema_raw2 = form.get("input_schema")
    output_schema_raw2 = form.get("output_schema")
    annotations_raw2 = form.get("annotations")

    # Parse JSON fields with validation
    try:
        headers = orjson.loads(headers_raw2 if isinstance(headers_raw2, str) and headers_raw2 else "{}")
        input_schema = orjson.loads(input_schema_raw2 if isinstance(input_schema_raw2, str) and input_schema_raw2 else "{}")
        output_schema = orjson.loads(output_schema_raw2) if isinstance(output_schema_raw2, str) and output_schema_raw2 else None
        annotations = orjson.loads(annotations_raw2 if isinstance(annotations_raw2, str) and annotations_raw2 else "{}")
    except orjson.JSONDecodeError as ex:
        LOGGER.error(f"Invalid JSON in form field: {str(ex)}")
        return ORJSONResponse(
            content={"message": f"Invalid JSON in form field: {str(ex)}", "success": False},
            status_code=422,
        )

    tool_data: dict[str, Any] = {
        "name": form.get("name"),
        "displayName": form.get("displayName"),
        "custom_name": form.get("customName"),
        "url": form.get("url"),
        "description": form.get("description"),
        "headers": headers,
        "input_schema": input_schema,
        "output_schema": output_schema,
        "annotations": annotations,
        "jsonpath_filter": form.get("jsonpathFilter", ""),
        "auth": auth_obj,
        "tags": tags,
        "visibility": visibility,
        "owner_email": user_email,
        "team_id": team_id,
    }
    # Only include integration_type if it's provided (not disabled in form)
    if "integrationType" in form:
        tool_data["integration_type"] = form.get("integrationType")
    # Only include request_type if it's provided (not disabled in form)
    if "requestType" in form:
        tool_data["request_type"] = form.get("requestType")
    LOGGER.debug(f"Tool update data built: {tool_data}")
    try:
        tool = ToolUpdate(**tool_data)  # Pydantic validation happens here

        # Get current tool to extract current version
        current_tool = db.get(DbTool, tool_id)
        current_version = getattr(current_tool, "version", 0) if current_tool else 0

        # Extract modification metadata
        mod_metadata = MetadataCapture.extract_modification_metadata(request, user, current_version)

        await tool_service.update_tool(
            db,
            tool_id,
            tool,
            modified_by=mod_metadata["modified_by"],
            modified_from_ip=mod_metadata["modified_from_ip"],
            modified_via=mod_metadata["modified_via"],
            modified_user_agent=mod_metadata["modified_user_agent"],
            user_email=user_email,
        )
        return ORJSONResponse(content={"message": "Edit tool successfully", "success": True}, status_code=200)
    except PermissionError as e:
        LOGGER.info(f"Permission denied for user {get_user_email(user)}: {e}")
        return ORJSONResponse(
            content={"message": str(e), "success": False},
            status_code=403,
        )
    except IntegrityError as ex:
        error_message = ErrorFormatter.format_database_error(ex)
        LOGGER.error(f"IntegrityError in admin_tool_resource: {error_message}")
        return ORJSONResponse(status_code=409, content=error_message)
    except ToolNameConflictError as ex:
        LOGGER.error(f"ToolNameConflictError in admin_edit_tool: {str(ex)}")
        return ORJSONResponse(content={"message": str(ex), "success": False}, status_code=409)
    except ToolError as ex:
        LOGGER.error(f"ToolError in admin_edit_tool: {str(ex)}")
        return ORJSONResponse(content={"message": str(ex), "success": False}, status_code=500)
    except ValidationError as ex:  # Catch Pydantic validation errors
        LOGGER.error(f"ValidationError in admin_edit_tool: {str(ex)}")
        return ORJSONResponse(content=ErrorFormatter.format_validation_error(ex), status_code=422)
    except Exception as ex:  # Generic catch-all for unexpected errors
        LOGGER.exception(f"Unexpected error in admin_edit_tool: {ex}")
        return ORJSONResponse(content={"message": "An unexpected error occurred. Please try again or contact support.", "success": False}, status_code=500)


@admin_router.post("/tools/generate-schemas-from-openapi")
# tools.create — this endpoint makes outbound HTTP requests to user-supplied
# URLs to fetch OpenAPI specs.  tools.read would let viewers probe internal
# services; tools.create scopes it to users who can already register tools.
@require_permission("tools.create", allow_admin_bypass=False)
async def generate_schemas_from_openapi(
    request: Request,
    _user=Depends(get_current_user_with_permissions),
) -> JSONResponse:
    """
    Generate input_schema and output_schema from OpenAPI specification URL.

    Expects JSON body with:
      - url: The tool URL (e.g., http://localhost:8100/calculate)
      - request_type: HTTP method (GET, POST, etc.)
      - openapi_url: (optional) Direct OpenAPI spec URL

    Args:
        request: FastAPI Request object containing JSON body

    Returns:
        JSONResponse with generated schemas or error message.
    """
    try:
        body = await _read_request_json(request)
    except Exception:
        return ORJSONResponse(
            content={"message": "Invalid JSON in request body", "success": False},
            status_code=400,
        )

    if not isinstance(body, dict):
        return ORJSONResponse(
            content={"message": "Request body must be a JSON object", "success": False},
            status_code=400,
        )

    tool_url = body.get("url", "")
    request_type = body.get("request_type", "GET")
    openapi_url = body.get("openapi_url", "")

    if not isinstance(tool_url, str) or not isinstance(request_type, str) or not isinstance(openapi_url, str):
        return ORJSONResponse(
            content={"message": "'url', 'request_type', and 'openapi_url' must be strings", "success": False},
            status_code=400,
        )

    tool_url = tool_url.strip()
    request_type = request_type.strip()
    openapi_url = openapi_url.strip()

    if not tool_url:
        return ORJSONResponse(
            content={"message": "'url' is required to identify the API path and base URL", "success": False},
            status_code=400,
        )

    try:
        SecurityValidator.validate_url(tool_url, "Tool URL")
    except ValueError as e:
        return ORJSONResponse(
            content={"message": str(e), "success": False},
            status_code=400,
        )

    parsed = urllib.parse.urlparse(tool_url)
    base_url = f"{parsed.scheme}://{parsed.netloc}"
    tool_path = parsed.path

    try:
        input_schema, output_schema, spec_url = await fetch_and_extract_schemas(
            base_url=base_url,
            path=tool_path,
            method=request_type,
            openapi_url=openapi_url,
            timeout=10.0,
        )
    except ValueError as e:
        return ORJSONResponse(
            content={"message": f"Security validation failed: {str(e)}", "success": False},
            status_code=400,
        )
    except KeyError as e:
        return ORJSONResponse(
            content={"message": str(e), "success": False},
            status_code=404,
        )
    except httpx.HTTPStatusError as e:
        LOGGER.warning("OpenAPI spec server returned HTTP %s", e.response.status_code, exc_info=True)
        return ORJSONResponse(
            content={"message": f"OpenAPI spec server returned HTTP {e.response.status_code}", "success": False},
            status_code=502,
        )
    except httpx.HTTPError:
        LOGGER.warning("Failed to fetch OpenAPI spec", exc_info=True)
        return ORJSONResponse(
            content={"message": "Failed to fetch OpenAPI spec from the provided URL", "success": False},
            status_code=502,
        )
    except Exception:
        LOGGER.error("Error fetching OpenAPI spec", exc_info=True)
        return ORJSONResponse(
            content={"message": "An unexpected error occurred while processing the OpenAPI spec", "success": False},
            status_code=500,
        )

    return ORJSONResponse(
        content={
            "message": "Schemas generated successfully from OpenAPI spec",
            "success": True,
            "input_schema": input_schema,
            "output_schema": output_schema,
            "spec_url": spec_url,
        },
        status_code=200,
    )


@admin_router.post("/tools/{tool_id}/delete")
@require_permission("tools.delete", allow_admin_bypass=False)
async def admin_delete_tool(tool_id: str, request: Request, db: Session = Depends(get_db), user=Depends(get_current_user_with_permissions)) -> RedirectResponse:
    """
    Delete a tool via the admin UI.

    This endpoint permanently removes a tool from the database using its ID.
    It is irreversible and should be used with caution. The operation is logged,
    and the user must be authenticated to access this route.

    Args:
        tool_id (str): The ID of the tool to delete.
        request (Request): FastAPI request object (not used directly, but required by route signature).
        db (Session): Database session dependency.
        user (str): Authenticated user dependency.

    Returns:
        RedirectResponse: A redirect response to the tools section of the admin
        dashboard with a status code of 303 (See Other).

    Examples:
        >>> callable(admin_delete_tool)
        True
        >>> admin_delete_tool.__name__
        'admin_delete_tool'
    """
    form = await request.form()
    is_inactive_checked = str(form.get("is_inactive_checked", "false"))
    purge_metrics = str(form.get("purge_metrics", "false")).lower() == "true"
    user_email = get_user_email(user)
    LOGGER.debug(f"User {user_email} is deleting tool ID {tool_id}")
    error_message = None
    try:
        await tool_service.delete_tool(db, tool_id, user_email=user_email, purge_metrics=purge_metrics)
    except PermissionError as e:
        LOGGER.warning(f"Permission denied for user {user_email} deleting tool {tool_id}: {e}")
        error_message = str(e)
    except Exception as e:
        LOGGER.error(f"Error deleting tool: {e}")
        error_message = "Failed to delete tool. Please try again."

    root_path = _resolve_root_path(request)
    team_id = str(form.get("team_id", "") or "")
    redirect_url = _build_admin_redirect(root_path, "tools", error=error_message, include_inactive=is_inactive_checked.lower() == "true", team_id=team_id)
    return RedirectResponse(redirect_url, status_code=303)


@admin_router.post("/tools/{tool_id}/state")
@require_permission("tools.update", allow_admin_bypass=False)
async def admin_set_tool_state(
    tool_id: str,
    request: Request,
    db: Session = Depends(get_db),
    user=Depends(get_current_user_with_permissions),
) -> RedirectResponse:
    """
    Toggle a tool's active status via the admin UI.

    This endpoint processes a form request to activate or deactivate a tool.
    It expects a form field 'activate' with value "true" to activate the tool
    or "false" to deactivate it. The endpoint handles exceptions gracefully and
    logs any errors that might occur during the status toggle operation.

    Args:
        tool_id (str): The ID of the tool whose status to toggle.
        request (Request): FastAPI request containing form data with the 'activate' field.
        db (Session): Database session dependency.
        user (str): Authenticated user dependency.

    Returns:
        RedirectResponse: A redirect to the admin dashboard tools section with a
        status code of 303 (See Other).

    Examples:
        >>> callable(admin_set_tool_state)
        True
        >>> admin_set_tool_state.__name__
        'admin_set_tool_state'
    """
    error_message = None
    user_email = get_user_email(user)
    LOGGER.debug(f"User {user_email} is toggling tool ID {tool_id}")
    form = await request.form()
    activate = str(form.get("activate", "true")).lower() == "true"
    is_inactive_checked = str(form.get("is_inactive_checked", "false"))
    try:
        await tool_service.set_tool_state(db, tool_id, activate, reachable=activate, user_email=user_email)
    except PermissionError as e:
        LOGGER.warning(f"Permission denied for user {user_email} setting tool state {tool_id}: {e}")
        error_message = str(e)
    except ToolLockConflictError as e:
        LOGGER.warning(f"Lock conflict for user {user_email} setting tool {tool_id} state: {e}")
        error_message = "Tool is being modified by another request. Please try again."
    except Exception as e:
        LOGGER.error(f"Error setting tool state: {e}")
        error_message = "Failed to set tool state. Please try again."

    root_path = _resolve_root_path(request)
    team_id = str(form.get("team_id", "") or "")
    redirect_url = _build_admin_redirect(root_path, "tools", error=error_message, include_inactive=is_inactive_checked.lower() == "true", team_id=team_id)
    return RedirectResponse(redirect_url, status_code=303)


@admin_router.get("/gateways/{gateway_id}", response_model=GatewayRead)
@require_permission("gateways.read", allow_admin_bypass=False)
async def admin_get_gateway(gateway_id: str, request: Request, db: Session = Depends(get_db), user=Depends(get_current_user_with_permissions)) -> Dict[str, Any]:
    """Get gateway details for the admin UI.

    Args:
        gateway_id: Gateway ID.
        request: Incoming FastAPI request (for visibility scope resolution).
        db: Database session.
        user: Authenticated user.

    Returns:
        Gateway details.

    Raises:
        HTTPException: If the gateway is not found.
        Exception: For any other unexpected errors.

    Examples:
        >>> callable(admin_get_gateway)
        True
        >>> admin_get_gateway.__name__
        'admin_get_gateway'
    """
    LOGGER.debug(f"User {get_user_email(user)} requested details for gateway ID {gateway_id}")
    try:
        auth_user_email, auth_token_teams = get_scoped_resource_access_context(request, user)
        gateway = await gateway_service.get_gateway(db, gateway_id, user_email=auth_user_email, token_teams=auth_token_teams)
        return gateway.model_dump(by_alias=True)
    except GatewayNotFoundError as e:
        raise HTTPException(status_code=404, detail=str(e))
    except Exception as e:
        LOGGER.error(f"Error getting gateway {gateway_id}: {e}")
        raise e


@admin_router.post("/gateways/discover-oauth")
@require_permission("gateways.create", allow_admin_bypass=False)
async def admin_discover_oauth(
    request: Request,
    user: dict[str, Any] = Depends(get_current_user_with_permissions),  # pylint: disable=unused-argument
) -> JSONResponse:
    """Discover OAuth/OIDC endpoints from an issuer URL (RFC 8414 / OIDC discovery).

    Args:
        request: FastAPI request containing JSON body with 'issuer' field.
        user: Authenticated user.

    Returns:
        JSONResponse with discovered endpoints or error message.

    Examples:
        >>> callable(admin_discover_oauth)
        True
    """
    # First-Party
    from mcpgateway.services.dcr_service import DcrService  # pylint: disable=import-outside-toplevel
    from mcpgateway.utils.url_auth import sanitize_exception_message  # pylint: disable=import-outside-toplevel

    try:
        body = await request.json()
    except Exception:
        LOGGER.warning("OAuth discovery failed: invalid JSON body")
        return JSONResponse(
            {"success": False, "error": "Invalid JSON body"},
            status_code=400,
        )

    if not isinstance(body, dict):
        return JSONResponse(
            {"success": False, "error": "Request body must be a JSON object"},
            status_code=400,
        )

    issuer = body.get("issuer", "").strip()
    if not issuer:
        return JSONResponse(
            {"success": False, "error": "issuer is required"},
            status_code=400,
        )

    try:
        SecurityValidator.validate_url(issuer, "OAuth issuer URL")
    except ValueError as _e:
        return JSONResponse(
            {"success": False, "error": f"Invalid issuer URL: {_e}"},
            status_code=400,
        )

    try:
        dcr = DcrService()
        metadata = await dcr.discover_as_metadata(issuer)

        def _safe_endpoint(raw: str | None, name: str) -> str | None:
            """Validate and return an OAuth endpoint URL, or None if invalid.

            Args:
                raw: The raw endpoint URL string or None.
                name: The name of the endpoint for validation error messages.

            Returns:
                The validated URL string if valid, None otherwise.
            """
            if not raw:
                return None
            try:
                SecurityValidator.validate_url(raw, name)
                return raw
            except ValueError:
                return None

        return JSONResponse(
            {
                "success": True,
                "token_endpoint": _safe_endpoint(metadata.get("token_endpoint"), "token_endpoint"),
                "authorization_endpoint": _safe_endpoint(metadata.get("authorization_endpoint"), "authorization_endpoint"),
                "jwks_uri": _safe_endpoint(metadata.get("jwks_uri"), "jwks_uri"),
                "registration_endpoint": _safe_endpoint(metadata.get("registration_endpoint"), "registration_endpoint"),
                "dcr_available": bool(metadata.get("registration_endpoint")),
                "scopes_supported": metadata.get("scopes_supported", []),
                "grant_types_supported": metadata.get("grant_types_supported", []),
            }
        )
    except Exception as e:
        LOGGER.warning("OAuth discovery failed: %s", e)
        sanitized = sanitize_exception_message(str(e))
        return JSONResponse(
            {
                "success": False,
                "error": sanitized,
                "message": "Discovery failed. Please configure token and authorization endpoints manually.",
            },
            status_code=502,
        )


@admin_router.post("/gateways")
@require_permission("gateways.create", allow_admin_bypass=False)
async def admin_add_gateway(request: Request, db: Session = Depends(get_db), user: dict[str, Any] = Depends(get_current_user_with_permissions)) -> JSONResponse:
    """Add a gateway via the admin UI.

    Expects form fields:
      - name
      - url
      - description (optional)
      - tags (optional, comma-separated)

    Args:
        request: FastAPI request containing form data.
        db: Database session.
        user: Authenticated user.

    Returns:
        A redirect response to the admin dashboard.

    Raises:
        HTTPException: 422 when public visibility is disabled and request is team-scoped.

    Examples:
        >>> callable(admin_add_gateway)
        True
        >>> admin_add_gateway.__name__
        'admin_add_gateway'
    """
    LOGGER.debug(f"User {get_user_email(user)} is adding a new gateway")
    form = await request.form()
    team_id = _form_team_id(form)
    visibility = str(form.get("visibility", "private"))
    _check_public_visibility_allowed(visibility, team_id=team_id)
    try:
        # Parse tags from comma-separated string
        tags_str = str(form.get("tags", ""))
        tags: list[str] = [tag.strip() for tag in tags_str.split(",") if tag.strip()] if tags_str else []

        # Parse auth_headers JSON if present
        auth_headers_json = form.get("auth_headers") or ""
        auth_headers: list[dict[str, Any]] = []
        if auth_headers_json:
            try:
                auth_headers = orjson.loads(auth_headers_json)
            except (orjson.JSONDecodeError, ValueError):
                auth_headers = []

        # Parse OAuth configuration - support both JSON string and individual form fields
        oauth_config_json = str(form.get("oauth_config"))
        oauth_config: Optional[dict[str, Any]] = None

        LOGGER.info(f"DEBUG: oauth_config_json from form = '{oauth_config_json}'")
        LOGGER.info(f"DEBUG: Individual OAuth fields - grant_type='{form.get('oauth_grant_type')}', issuer='{form.get('oauth_issuer')}'")

        # Option 1: Pre-assembled oauth_config JSON (from API calls)
        if oauth_config_json and oauth_config_json != "None":
            try:
                oauth_config = orjson.loads(oauth_config_json)
                # Encrypt the client secret if present
                if oauth_config and "client_secret" in oauth_config:
                    encryption = get_encryption_service(settings.auth_encryption_secret)
                    oauth_config["client_secret"] = await encryption.encrypt_secret_async(oauth_config["client_secret"])
            except (orjson.JSONDecodeError, ValueError) as e:
                LOGGER.error(f"Failed to parse OAuth config: {e}")
                oauth_config = None

        # Option 2: Assemble from individual UI form fields
        if not oauth_config:
            oauth_grant_type = str(form.get("oauth_grant_type", ""))
            oauth_issuer = str(form.get("oauth_issuer", ""))
            oauth_token_url = str(form.get("oauth_token_url", ""))
            oauth_authorization_url = str(form.get("oauth_authorization_url", ""))
            oauth_redirect_uri = str(form.get("oauth_redirect_uri", ""))
            oauth_client_id = str(form.get("oauth_client_id", ""))
            oauth_client_secret = str(form.get("oauth_client_secret", ""))
            oauth_username = str(form.get("oauth_username", ""))
            oauth_password = str(form.get("oauth_password", ""))
            oauth_scopes_str = str(form.get("oauth_scopes", ""))

            # If any OAuth field is provided, assemble oauth_config
            if any([oauth_grant_type, oauth_issuer, oauth_token_url, oauth_authorization_url, oauth_client_id]):
                oauth_config = {}

                if oauth_grant_type:
                    oauth_config["grant_type"] = oauth_grant_type
                if oauth_issuer:
                    oauth_config["issuer"] = oauth_issuer
                if oauth_token_url:
                    oauth_config["token_url"] = oauth_token_url  # OAuthManager expects 'token_url', not 'token_endpoint'
                if oauth_authorization_url:
                    oauth_config["authorization_url"] = oauth_authorization_url  # OAuthManager expects 'authorization_url', not 'authorization_endpoint'
                if oauth_redirect_uri:
                    oauth_config["redirect_uri"] = oauth_redirect_uri
                if oauth_client_id:
                    oauth_config["client_id"] = oauth_client_id
                if oauth_client_secret:
                    # Encrypt the client secret
                    encryption = get_encryption_service(settings.auth_encryption_secret)
                    oauth_config["client_secret"] = await encryption.encrypt_secret_async(oauth_client_secret)

                # Add username and password for password grant type
                if oauth_username:
                    oauth_config["username"] = oauth_username
                if oauth_password:
                    oauth_config["password"] = oauth_password

                # Parse scopes (comma or space separated)
                if oauth_scopes_str:
                    scopes = [s.strip() for s in oauth_scopes_str.replace(",", " ").split() if s.strip()]
                    if scopes:
                        oauth_config["scopes"] = scopes

                LOGGER.info(f"✅ Assembled OAuth config from UI form fields: grant_type={oauth_grant_type}, issuer={oauth_issuer}")
                LOGGER.info(f"DEBUG: Complete oauth_config = {oauth_config}")

        # Handle passthrough_headers
        passthrough_headers = str(form.get("passthrough_headers"))
        if passthrough_headers and passthrough_headers.strip():
            try:
                passthrough_headers = orjson.loads(passthrough_headers)
            except (orjson.JSONDecodeError, ValueError):
                # Fallback to comma-separated parsing
                passthrough_headers = [h.strip() for h in passthrough_headers.split(",") if h.strip()]
        else:
            passthrough_headers = None

        # Auto-detect OAuth: if oauth_config is present and auth_type not explicitly set, use "oauth"
        auth_type_from_form = str(form.get("auth_type", ""))
        LOGGER.info(f"DEBUG: auth_type from form: '{auth_type_from_form}', oauth_config present: {oauth_config is not None}")
        if oauth_config and not auth_type_from_form:
            auth_type_from_form = "oauth"
            LOGGER.info("✅ Auto-detected OAuth configuration, setting auth_type='oauth'")
        elif oauth_config and auth_type_from_form:
            LOGGER.info(f"✅ OAuth config present with explicit auth_type='{auth_type_from_form}'")

        ca_certificate: Optional[str] = None
        sig: Optional[str] = None

        # CA certificate(s) handled by JavaScript validation (supports single or multiple files)
        # JavaScript validates, orders (root→intermediate→leaf), and concatenates into hidden field
        if "ca_certificate" in form:
            ca_cert_value = form["ca_certificate"]
            if isinstance(ca_cert_value, str) and ca_cert_value.strip():
                ca_certificate = ca_cert_value.strip()
                LOGGER.info("✅ CA certificate(s) received and validated by frontend")

                if settings.enable_ed25519_signing:
                    try:
                        private_key_pem = settings.ed25519_private_key.get_secret_value()
                        sig = sign_data(ca_certificate.encode(), private_key_pem)
                    except Exception as e:
                        LOGGER.error(f"Error signing CA certificate: {e}")
                        sig = None
                        raise RuntimeError("Failed to sign CA certificate") from e
                else:
                    LOGGER.warning("⚠️  Ed25519 signing is disabled; CA certificate will be stored without signature")
                    sig = None

        gateway = GatewayCreate(
            name=str(form["name"]),
            url=str(form["url"]),
            description=str(form.get("description")),
            tags=tags,
            transport=str(form.get("transport", "SSE")),
            auth_type=auth_type_from_form,
            auth_username=str(form.get("auth_username", "")),
            auth_password=str(form.get("auth_password", "")),
            auth_token=str(form.get("auth_token", "")),
            auth_header_key=str(form.get("auth_header_key", "")),
            auth_header_value=str(form.get("auth_header_value", "")),
            auth_headers=auth_headers if auth_headers else None,
            auth_query_param_key=str(form.get("auth_query_param_key", "")) or None,
            auth_query_param_value=str(form.get("auth_query_param_value", "")) or None,
            oauth_config=oauth_config,
            one_time_auth=form.get("one_time_auth", False),
            passthrough_headers=passthrough_headers,
            visibility=visibility,
            ca_certificate=ca_certificate,
            ca_certificate_sig=sig if sig else None,
            signing_algorithm="ed25519" if sig else None,
        )
    except KeyError as e:
        # Convert KeyError to ValidationError-like response
        return ORJSONResponse(content={"message": f"Missing required field: {e}", "success": False}, status_code=422)

    except ValidationError as ex:
        # --- Getting only the custom message from the ValueError ---
        error_ctx = [str(err["ctx"]["error"]) for err in ex.errors()]
        return ORJSONResponse(content={"success": False, "message": "; ".join(error_ctx)}, status_code=422)

    except RuntimeError as err:
        # --- Getting only the custom message from the RuntimeError ---
        error_ctx = [str(err)]
        return ORJSONResponse(content={"success": False, "message": "; ".join(error_ctx)}, status_code=422)

    user_email = get_user_email(user)

    team_service = TeamManagementService(db)
    team_id = await team_service.verify_team_for_user(user_email, team_id)

    try:
        # Extract creation metadata
        metadata = MetadataCapture.extract_creation_metadata(request, user)

        team_id_cast = typing_cast(Optional[str], team_id)
        result = await gateway_service.register_gateway(
            db,
            gateway,
            created_by=metadata["created_by"],
            created_from_ip=metadata["created_from_ip"],
            created_via=metadata["created_via"],
            created_user_agent=metadata["created_user_agent"],
            visibility=visibility,
            team_id=team_id_cast,
            owner_email=user_email,
            initialize_timeout=settings.httpx_admin_read_timeout,
        )

        # Provide specific guidance for OAuth Authorization Code flow
        message = "Gateway registered successfully!"
        if oauth_config and isinstance(oauth_config, dict) and oauth_config.get("grant_type") == "authorization_code":
            message = (
                "Gateway registered successfully! 🎉\n\n"
                "⚠️  IMPORTANT: This gateway uses OAuth Authorization Code flow.\n"
                "You must complete the OAuth authorization before tools will work:\n\n"
                "1. Go to the Gateways list\n"
                "2. Click the '🔐 Authorize' button for this gateway\n"
                "3. Complete the OAuth consent flow\n"
                "4. Return to the admin panel\n\n"
                "Tools will not work until OAuth authorization is completed."
            )
        skipped_tools = result.skipped_tools if isinstance(getattr(result, "skipped_tools", None), list) else []
        return ORJSONResponse(
            content={"message": message, "success": True, "skipped_tools": skipped_tools},
            status_code=200,
        )

    except GatewayConnectionError as ex:
        return ORJSONResponse(content={"message": str(ex), "success": False}, status_code=502)
    except GatewayDuplicateConflictError as ex:
        return ORJSONResponse(content={"message": str(ex), "success": False}, status_code=409)
    except GatewayNameConflictError as ex:
        return ORJSONResponse(content={"message": str(ex), "success": False}, status_code=409)
    except RuntimeError as ex:
        return ORJSONResponse(content={"message": str(ex), "success": False}, status_code=500)
    except ValidationError as ex:
        return ORJSONResponse(content=ErrorFormatter.format_validation_error(ex), status_code=422)
    # NOTE: Pydantic's ValidationError subclasses ValueError, so ValidationError must be handled first.
    except ValueError as ex:
        return ORJSONResponse(content={"message": str(ex), "success": False}, status_code=400)
    except IntegrityError as ex:
        return ORJSONResponse(content=ErrorFormatter.format_database_error(ex), status_code=409)
    except DataError as ex:
        return ORJSONResponse(content=ErrorFormatter.format_database_error(ex), status_code=400)
    except Exception as ex:
        LOGGER.exception(f"Unexpected error in admin_add_gateway: {ex}")
        return ORJSONResponse(content={"message": "An unexpected error occurred. Please try again or contact support.", "success": False}, status_code=500)


# OAuth callback is now handled by the dedicated OAuth router at /oauth/callback
# This route has been removed to avoid conflicts with the complete implementation
@admin_router.post("/gateways/{gateway_id}/edit")
@require_permission("gateways.update", allow_admin_bypass=False)
async def admin_edit_gateway(
    gateway_id: str,
    request: Request,
    db: Session = Depends(get_db),
    user=Depends(get_current_user_with_permissions),
) -> JSONResponse:
    """Edit a gateway via the admin UI.

    Expects form fields:
      - name
      - url
      - description (optional)
      - tags (optional, comma-separated)

    Args:
        gateway_id: Gateway ID.
        request: FastAPI request containing form data.
        db: Database session.
        user: Authenticated user.

    Returns:
        A redirect response to the admin dashboard.

    Raises:
        HTTPException: 422 when public visibility is disabled and request is team-scoped.

    Examples:
        >>> callable(admin_edit_gateway)
        True
        >>> admin_edit_gateway.__name__
        'admin_edit_gateway'
    """
    LOGGER.debug(f"User {get_user_email(user)} is editing gateway ID {gateway_id}")
    form = await request.form()
    team_id = _form_team_id(form)
    try:
        # Parse tags from comma-separated string
        tags_str = str(form.get("tags", ""))
        tags: List[str] = [tag.strip() for tag in tags_str.split(",") if tag.strip()] if tags_str else []

        visibility = str(form.get("visibility", "private"))
        _check_public_visibility_allowed(visibility, team_id=team_id)

        # Parse auth_headers JSON if present
        auth_headers_json = form.get("auth_headers") or ""
        auth_headers = []
        if auth_headers_json:
            try:
                auth_headers = orjson.loads(auth_headers_json)
            except (orjson.JSONDecodeError, ValueError):
                auth_headers = []

        # Handle passthrough_headers
        passthrough_headers = str(form.get("passthrough_headers"))
        if passthrough_headers and passthrough_headers.strip():
            try:
                passthrough_headers = orjson.loads(passthrough_headers)
            except (orjson.JSONDecodeError, ValueError):
                # Fallback to comma-separated parsing
                passthrough_headers = [h.strip() for h in passthrough_headers.split(",") if h.strip()]
        else:
            passthrough_headers = None

        # Parse OAuth configuration - support both JSON string and individual form fields
        oauth_config_json = str(form.get("oauth_config"))
        oauth_config: Optional[dict[str, Any]] = None

        # Option 1: Pre-assembled oauth_config JSON (from API calls)
        if oauth_config_json and oauth_config_json != "None":
            try:
                oauth_config = orjson.loads(oauth_config_json)
                # Encrypt the client secret if present and not empty
                if oauth_config and "client_secret" in oauth_config and oauth_config["client_secret"]:
                    encryption = get_encryption_service(settings.auth_encryption_secret)
                    oauth_config["client_secret"] = await encryption.encrypt_secret_async(oauth_config["client_secret"])
            except (orjson.JSONDecodeError, ValueError) as e:
                LOGGER.error(f"Failed to parse OAuth config: {e}")
                oauth_config = None

        # Option 2: Assemble from individual UI form fields
        if not oauth_config:
            oauth_grant_type = str(form.get("oauth_grant_type", ""))
            oauth_issuer = str(form.get("oauth_issuer", ""))
            oauth_token_url = str(form.get("oauth_token_url", ""))
            oauth_authorization_url = str(form.get("oauth_authorization_url", ""))
            oauth_redirect_uri = str(form.get("oauth_redirect_uri", ""))
            oauth_client_id = str(form.get("oauth_client_id", ""))
            oauth_client_secret = str(form.get("oauth_client_secret", ""))
            oauth_username = str(form.get("oauth_username", ""))
            oauth_password = str(form.get("oauth_password", ""))
            oauth_scopes_str = str(form.get("oauth_scopes", ""))

            # If any OAuth field is provided, assemble oauth_config
            if any([oauth_grant_type, oauth_issuer, oauth_token_url, oauth_authorization_url, oauth_client_id]):
                oauth_config = {}

                if oauth_grant_type:
                    oauth_config["grant_type"] = oauth_grant_type
                if oauth_issuer:
                    oauth_config["issuer"] = oauth_issuer
                if oauth_token_url:
                    oauth_config["token_url"] = oauth_token_url  # OAuthManager expects 'token_url', not 'token_endpoint'
                if oauth_authorization_url:
                    oauth_config["authorization_url"] = oauth_authorization_url  # OAuthManager expects 'authorization_url', not 'authorization_endpoint'
                if oauth_redirect_uri:
                    oauth_config["redirect_uri"] = oauth_redirect_uri
                if oauth_client_id:
                    oauth_config["client_id"] = oauth_client_id
                if oauth_client_secret:
                    # Encrypt the client secret
                    encryption = get_encryption_service(settings.auth_encryption_secret)
                    oauth_config["client_secret"] = await encryption.encrypt_secret_async(oauth_client_secret)

                # Add username and password for password grant type
                if oauth_username:
                    oauth_config["username"] = oauth_username
                if oauth_password:
                    oauth_config["password"] = oauth_password

                # Parse scopes (comma or space separated)
                if oauth_scopes_str:
                    scopes = [s.strip() for s in oauth_scopes_str.replace(",", " ").split() if s.strip()]
                    if scopes:
                        oauth_config["scopes"] = scopes

                LOGGER.info(f"✅ Assembled OAuth config from UI form fields (edit): grant_type={oauth_grant_type}, issuer={oauth_issuer}")

        user_email = get_user_email(user)
        # Preserve existing gateway's team_id when no explicit team_id is provided.
        # Without this guard, verify_team_for_user() falls back to the user's
        # personal team, silently reassigning the gateway on every edit.
        if not team_id:
            existing_gateway = db.get(DbGateway, gateway_id)
            existing_team = getattr(existing_gateway, "team_id", None) if existing_gateway else None
            if isinstance(existing_team, str) and existing_team:
                team_id = existing_team

        team_service = TeamManagementService(db)
        team_id = await team_service.verify_team_for_user(user_email, team_id)

        # Auto-detect OAuth: if oauth_config is present and auth_type not explicitly set, use "oauth"
        auth_type_from_form = str(form.get("auth_type", ""))
        if oauth_config and not auth_type_from_form:
            auth_type_from_form = "oauth"
            LOGGER.info("Auto-detected OAuth configuration in edit, setting auth_type='oauth'")

        gateway = GatewayUpdate(  # Pydantic validation happens here
            name=str(form.get("name")),
            url=str(form["url"]),
            description=str(form.get("description")),
            transport=str(form.get("transport", "SSE")),
            tags=tags,
            auth_type=auth_type_from_form,
            auth_username=str(form.get("auth_username", "")),
            auth_password=str(form.get("auth_password", "")),
            auth_token=str(form.get("auth_token", "")),
            auth_header_key=str(form.get("auth_header_key", "")),
            auth_header_value=str(form.get("auth_header_value", "")),
            auth_value=str(form.get("auth_value", "")),
            auth_headers=auth_headers if auth_headers else None,
            auth_query_param_key=str(form.get("auth_query_param_key", "")) or None,
            auth_query_param_value=str(form.get("auth_query_param_value", "")) or None,
            one_time_auth=form.get("one_time_auth", False),
            passthrough_headers=passthrough_headers,
            oauth_config=oauth_config,
            visibility=visibility,
            owner_email=user_email,
            team_id=team_id,
        )

        mod_metadata = MetadataCapture.extract_modification_metadata(request, user, 0)
        await gateway_service.update_gateway(
            db,
            gateway_id,
            gateway,
            modified_by=mod_metadata["modified_by"],
            modified_from_ip=mod_metadata["modified_from_ip"],
            modified_via=mod_metadata["modified_via"],
            modified_user_agent=mod_metadata["modified_user_agent"],
            user_email=user_email,
        )
        return ORJSONResponse(
            content={"message": "Gateway updated successfully!", "success": True},
            status_code=200,
        )
    except PermissionError as e:
        LOGGER.info(f"Permission denied for user {get_user_email(user)}: {e}")
        return ORJSONResponse(
            content={"message": str(e), "success": False},
            status_code=403,
        )
    except HTTPException:
        raise
    except Exception as ex:
        if isinstance(ex, GatewayConnectionError):
            return ORJSONResponse(content={"message": str(ex), "success": False}, status_code=502)
        if isinstance(ex, RuntimeError):
            return ORJSONResponse(content={"message": str(ex), "success": False}, status_code=500)
        if isinstance(ex, ValidationError):
            return ORJSONResponse(content=ErrorFormatter.format_validation_error(ex), status_code=422)
        if isinstance(ex, IntegrityError):
            return ORJSONResponse(status_code=409, content=ErrorFormatter.format_database_error(ex))
        # NOTE: Pydantic's ValidationError subclasses ValueError, so ValidationError must be handled first.
        if isinstance(ex, ValueError):
            return ORJSONResponse(content={"message": str(ex), "success": False}, status_code=400)
        LOGGER.exception(f"Unexpected error in admin_edit_gateway: {ex}")
        return ORJSONResponse(content={"message": "An unexpected error occurred. Please try again or contact support.", "success": False}, status_code=500)


@admin_router.post("/gateways/{gateway_id}/delete")
@require_permission("gateways.delete", allow_admin_bypass=False)
async def admin_delete_gateway(gateway_id: str, request: Request, db: Session = Depends(get_db), user=Depends(get_current_user_with_permissions)) -> RedirectResponse:
    """
    Delete a gateway via the admin UI.

    This endpoint removes a gateway from the database by its ID. The deletion is
    permanent and cannot be undone. It requires authentication and logs the
    operation for auditing purposes.

    Args:
        gateway_id (str): The ID of the gateway to delete.
        request (Request): FastAPI request object (not used directly but required by the route signature).
        db (Session): Database session dependency.
        user (str): Authenticated user dependency.

    Returns:
        RedirectResponse: A redirect response to the gateways section of the admin
        dashboard with a status code of 303 (See Other).

    Examples:
        >>> callable(admin_delete_gateway)
        True
        >>> admin_delete_gateway.__name__
        'admin_delete_gateway'
    """
    user_email = get_user_email(user)
    LOGGER.debug(f"User {user_email} is deleting gateway ID {gateway_id}")
    error_message = None
    try:
        await gateway_service.delete_gateway(db, gateway_id, user_email=user_email)
    except PermissionError as e:
        LOGGER.warning(f"Permission denied for user {user_email} deleting gateway {gateway_id}: {e}")
        error_message = str(e)
    except Exception as e:
        LOGGER.error(f"Error deleting gateway: {e}")
        error_message = "Failed to delete gateway. Please try again."

    form = await request.form()
    is_inactive_checked = str(form.get("is_inactive_checked", "false"))
    root_path = _resolve_root_path(request)
    team_id = str(form.get("team_id", "") or "")
    redirect_url = _build_admin_redirect(root_path, "gateways", error=error_message, include_inactive=is_inactive_checked.lower() == "true", team_id=team_id)
    return RedirectResponse(redirect_url, status_code=303)


@admin_router.get("/resources/test/{resource_uri:path}")
@require_permission("resources.read", allow_admin_bypass=False)
async def admin_test_resource(resource_uri: str, db: Session = Depends(get_db), user=Depends(get_current_user_with_permissions)) -> Dict[str, Any]:
    """
    Test reading a resource by its URI for the admin UI.

    Args:
        resource_uri: The full resource URI (may include encoded characters).
        db: Database session dependency.
        user: Authenticated user with proper permissions.

    Returns:
        A dictionary containing the resolved resource content.

    Raises:
        HTTPException: If the resource is not found.
        Exception: For unexpected errors.

    Examples:
        >>> callable(admin_test_resource)
        True
        >>> admin_test_resource.__name__
        'admin_test_resource'
    """
    user_email = get_user_email(user)
    LOGGER.debug(f"User {user_email} requested details for resource ID {resource_uri}")

    # For admin UI, pass user email and token_teams=None
    # Since admin UI requires admin permissions, the user should have full access
    # via the admin bypass (is_admin + token_teams=None)
    is_admin = user.get("is_admin", False) if isinstance(user, dict) else False

    try:
        # Admin users get unrestricted access (user_email=None, token_teams=None)
        # Non-admin users get team-based access (user_email=email, token_teams=None for lookup)
        resource_content = await resource_service.read_resource(
            db,
            resource_uri=resource_uri,
            user=None if is_admin else user_email,
            token_teams=None,
        )
        return {"content": resource_content}
    except ResourceNotFoundError as e:
        raise HTTPException(status_code=404, detail=str(e))
    except Exception as e:
        LOGGER.error(f"Error getting resource for {resource_uri}: {e}")
        raise e


@admin_router.get("/resources/{resource_id}")
@require_permission("resources.read", allow_admin_bypass=False)
async def admin_get_resource(resource_id: str, request: Request, db: Session = Depends(get_db), user=Depends(get_current_user_with_permissions)) -> Dict[str, Any]:
    """Get resource details for the admin UI.

    Args:
        resource_id: Resource ID.
        request: Incoming FastAPI request (for visibility scope resolution).
        db: Database session.
        user: Authenticated user.

    Returns:
        A dictionary containing resource details.

    Raises:
        HTTPException: If the resource is not found.
        Exception: For any other unexpected errors.

    Examples:
        >>> callable(admin_get_resource)
        True
        >>> admin_get_resource.__name__
        'admin_get_resource'
    """
    LOGGER.debug(f"User {get_user_email(user)} requested details for resource ID {resource_id}")
    try:
        auth_user_email, auth_token_teams = get_scoped_resource_access_context(request, user)
        resource = await resource_service.get_resource_by_id(
            db,
            resource_id,
            include_inactive=True,
            user_email=auth_user_email,
            token_teams=auth_token_teams,
        )
        return {"resource": resource.model_dump(by_alias=True)}
    except ResourceNotFoundError as e:
        raise HTTPException(status_code=404, detail=str(e))
    except Exception as e:
        LOGGER.error(f"Error getting resource {resource_id}: {e}")
        raise e


@admin_router.post("/resources")
@require_permission("resources.create", allow_admin_bypass=False)
async def admin_add_resource(request: Request, db: Session = Depends(get_db), user=Depends(get_current_user_with_permissions)) -> Response:
    """
    Add a resource via the admin UI.

    Expects form fields:
      - uri
      - name
      - description (optional)
      - mime_type (optional)
      - content

    Args:
        request: FastAPI request containing form data.
        db: Database session.
        user: Authenticated user.

    Returns:
        A redirect response to the admin dashboard.

    Raises:
        HTTPException: 422 when public visibility is disabled and request is team-scoped.

    Examples:
        >>> callable(admin_add_resource)
        True
        >>> admin_add_resource.__name__
        'admin_add_resource'
    """
    LOGGER.debug(f"User {get_user_email(user)} is adding a new resource")
    form = await request.form()
    team_id = _form_team_id(form)

    # Parse tags from comma-separated string
    tags_str = str(form.get("tags", ""))
    tags: List[str] = [tag.strip() for tag in tags_str.split(",") if tag.strip()] if tags_str else []
    visibility = str(form.get("visibility", "public"))
    _check_public_visibility_allowed(visibility, team_id=team_id)
    user_email = get_user_email(user)
    team_service = TeamManagementService(db)
    team_id = await team_service.verify_team_for_user(user_email, team_id)

    try:
        # Handle template field: convert empty string to None for optional field
        template = None
        template_value = form.get("uri_template")
        template = template_value if template_value else None
        template_value = form.get("uri_template")
        uri_value = form.get("uri")

        # Ensure uri_value is a string
        if isinstance(uri_value, str) and "{" in uri_value and "}" in uri_value:
            template = uri_value

        resource = ResourceCreate(
            uri=str(form["uri"]),
            name=str(form["name"]),
            description=str(form.get("description", "")),
            mime_type=str(form.get("mimeType", "")),
            uri_template=template,
            content=str(form["content"]),
            tags=tags,
            visibility=visibility,
            team_id=team_id,
            owner_email=user_email,
        )

        metadata = MetadataCapture.extract_creation_metadata(request, user)

        await resource_service.register_resource(
            db,
            resource,
            created_by=metadata["created_by"],
            created_from_ip=metadata["created_from_ip"],
            created_via=metadata["created_via"],
            created_user_agent=metadata["created_user_agent"],
            import_batch_id=metadata["import_batch_id"],
            federation_source=metadata["federation_source"],
            team_id=team_id,
            owner_email=user_email,
            visibility=visibility,
        )
        return ORJSONResponse(
            content={"message": "Add resource registered successfully!", "success": True},
            status_code=200,
        )
    except Exception as ex:
        # Roll back only when a transaction is active to avoid sqlite3 "no transaction" errors.
        try:
            active_transaction = db.get_transaction() if hasattr(db, "get_transaction") else None
            if db.is_active and active_transaction is not None:
                db.rollback()
        except (InvalidRequestError, OperationalError) as rollback_error:
            LOGGER.warning(
                "Rollback failed (ignoring for SQLite compatibility): %s",
                rollback_error,
            )

        if isinstance(ex, ValidationError):
            LOGGER.error(f"ValidationError in admin_add_resource: {ErrorFormatter.format_validation_error(ex)}")
            return ORJSONResponse(content=ErrorFormatter.format_validation_error(ex), status_code=422)
        if isinstance(ex, IntegrityError):
            error_message = ErrorFormatter.format_database_error(ex)
            LOGGER.error(f"IntegrityError in admin_add_resource: {error_message}")
            return ORJSONResponse(status_code=409, content=error_message)
        if isinstance(ex, ResourceURIConflictError):
            LOGGER.error(f"ResourceURIConflictError in admin_add_resource: {ex}")
            return ORJSONResponse(content={"message": str(ex), "success": False}, status_code=409)
        if isinstance(ex, ContentSizeError):
            LOGGER.error(f"ContentSizeError in admin_add_resource: {ex}")
            return ORJSONResponse(content={"message": str(ex), "success": False}, status_code=413)
        if isinstance(ex, ContentTypeError):
            LOGGER.error(f"ContentTypeError in admin_add_resource: {ex}")
            return ORJSONResponse(
                content={
                    "message": str(ex),
                    "success": False,
                    "mime_type": ex.mime_type,
                    "allowed_types": ex.allowed_types,
                },
                status_code=415,
            )
        LOGGER.exception(f"Unexpected error in admin_add_resource: {ex}")
        return ORJSONResponse(content={"message": "An unexpected error occurred. Please try again or contact support.", "success": False}, status_code=500)


@admin_router.post("/resources/{resource_id}/edit")
@require_permission("resources.update", allow_admin_bypass=False)
async def admin_edit_resource(
    resource_id: str,
    request: Request,
    db: Session = Depends(get_db),
    user=Depends(get_current_user_with_permissions),
) -> JSONResponse:
    """
    Edit a resource via the admin UI.

    Expects form fields:
      - name
      - description (optional)
      - mime_type (optional)
      - content

    Args:
        resource_id: Resource ID.
        request: FastAPI request containing form data.
        db: Database session.
        user: Authenticated user.

    Returns:
        JSONResponse: A JSON response indicating success or failure of the resource update operation.

    Raises:
        HTTPException: 422 when public visibility is disabled and request is team-scoped.

    Examples:
        >>> callable(admin_edit_resource)
        True
        >>> admin_edit_resource.__name__
        'admin_edit_resource'
    """
    LOGGER.debug(f"User {get_user_email(user)} is editing resource ID {resource_id}")
    form = await request.form()
    LOGGER.info(f"Form data received for resource edit: {form}")
    team_id = _form_team_id(form)
    visibility = str(form.get("visibility", "private"))
    _check_public_visibility_allowed(visibility, team_id=team_id)

    user_email = get_user_email(user)

    # Preserve existing resource's team_id when no explicit team_id is provided.
    # Without this guard, verify_team_for_user() falls back to the user's
    # personal team, silently reassigning the resource on every edit.
    if not team_id:
        existing_resource = db.get(DbResource, resource_id)
        existing_team = getattr(existing_resource, "team_id", None) if existing_resource else None
        if isinstance(existing_team, str) and existing_team:
            team_id = existing_team

    team_service = TeamManagementService(db)
    team_id = await team_service.verify_team_for_user(user_email, team_id)

    # Parse tags from comma-separated string
    tags_str = str(form.get("tags", ""))
    tags: List[str] = [tag.strip() for tag in tags_str.split(",") if tag.strip()] if tags_str else []

    try:
        mod_metadata = MetadataCapture.extract_modification_metadata(request, user, 0)
        resource = ResourceUpdate(
            uri=str(form.get("uri", "")),
            name=str(form.get("name", "")),
            description=str(form.get("description")),
            mime_type=str(form.get("mimeType")),
            content=str(form.get("content", "")),
            template=str(form.get("template")),
            tags=tags,
            visibility=visibility,
            team_id=team_id,
            owner_email=user_email,
        )
        LOGGER.info(f"ResourceUpdate object created: {resource}")
        await resource_service.update_resource(
            db,
            resource_id,
            resource,
            modified_by=mod_metadata["modified_by"],
            modified_from_ip=mod_metadata["modified_from_ip"],
            modified_via=mod_metadata["modified_via"],
            modified_user_agent=mod_metadata["modified_user_agent"],
            user_email=get_user_email(user),
        )
        return ORJSONResponse(
            content={"message": "Resource updated successfully!", "success": True},
            status_code=200,
        )
    except PermissionError as e:
        LOGGER.info(f"Permission denied for user {get_user_email(user)}: {e}")
        return ORJSONResponse(content={"message": str(e), "success": False}, status_code=403)
    except Exception as ex:
        # Roll back to discard any dirty tracked state from the failed update.
        try:
            active_transaction = db.get_transaction() if hasattr(db, "get_transaction") else None
            if db.is_active and active_transaction is not None:
                db.rollback()
        except (InvalidRequestError, OperationalError) as rollback_error:
            LOGGER.warning("Rollback failed (ignoring for SQLite compatibility): %s", rollback_error)

        if isinstance(ex, ValidationError):
            LOGGER.error(f"ValidationError in admin_edit_resource: {ErrorFormatter.format_validation_error(ex)}")
            return ORJSONResponse(content=ErrorFormatter.format_validation_error(ex), status_code=422)
        if isinstance(ex, IntegrityError):
            error_message = ErrorFormatter.format_database_error(ex)
            LOGGER.error(f"IntegrityError in admin_edit_resource: {error_message}")
            return ORJSONResponse(status_code=409, content=error_message)
        if isinstance(ex, ResourceURIConflictError):
            LOGGER.error(f"ResourceURIConflictError in admin_edit_resource: {ex}")
            return ORJSONResponse(status_code=409, content={"message": str(ex), "success": False})
        if isinstance(ex, ContentSizeError):
            LOGGER.error(f"ContentSizeError in admin_edit_resource: {ex}")
            return ORJSONResponse(status_code=413, content={"message": str(ex), "success": False})
        if isinstance(ex, ContentTypeError):
            LOGGER.error(f"ContentTypeError in admin_edit_resource: {ex}")
            return ORJSONResponse(
                status_code=415,
                content={
                    "message": str(ex),
                    "success": False,
                    "mime_type": ex.mime_type,
                    "allowed_types": ex.allowed_types,
                },
            )
        LOGGER.exception(f"Unexpected error in admin_edit_resource: {ex}")
        return ORJSONResponse(content={"message": "An unexpected error occurred. Please try again or contact support.", "success": False}, status_code=500)


@admin_router.post("/resources/{resource_id}/delete")
@require_permission("resources.delete", allow_admin_bypass=False)
async def admin_delete_resource(resource_id: str, request: Request, db: Session = Depends(get_db), user=Depends(get_current_user_with_permissions)) -> RedirectResponse:
    """
    Delete a resource via the admin UI.

    This endpoint permanently removes a resource from the database using its resource ID.
    The operation is irreversible and should be used with caution. It requires
    user authentication and logs the deletion attempt.

    Args:
        resource_id (str): The ID of the resource to delete.
        request (Request): FastAPI request object (not used directly but required by the route signature).
        db (Session): Database session dependency.
        user (str): Authenticated user dependency.

    Returns:
        RedirectResponse: A redirect response to the resources section of the admin
        dashboard with a status code of 303 (See Other).

    Examples:
        >>> callable(admin_delete_resource)
        True
        >>> admin_delete_resource.__name__
        'admin_delete_resource'
    """

    form = await request.form()
    is_inactive_checked: str = str(form.get("is_inactive_checked", "false"))
    purge_metrics = str(form.get("purge_metrics", "false")).lower() == "true"
    user_email = get_user_email(user)
    LOGGER.debug(f"User {get_user_email(user)} is deleting resource ID {resource_id}")
    error_message = None
    try:
        await resource_service.delete_resource(
            db,  # Use endpoint's db session (user["db"] is now closed early)
            resource_id,
            user_email=user_email,
            purge_metrics=purge_metrics,
        )
    except PermissionError as e:
        LOGGER.warning(f"Permission denied for user {user_email} deleting resource {resource_id}: {e}")
        error_message = str(e)
    except Exception as e:
        LOGGER.error(f"Error deleting resource: {e}")
        error_message = "Failed to delete resource. Please try again."
    root_path = _resolve_root_path(request)
    team_id = str(form.get("team_id", "") or "")
    redirect_url = _build_admin_redirect(root_path, "resources", error=error_message, include_inactive=is_inactive_checked.lower() == "true", team_id=team_id)
    return RedirectResponse(redirect_url, status_code=303)


@admin_router.post("/resources/{resource_id}/state")
@require_permission("resources.update", allow_admin_bypass=False)
async def admin_set_resource_state(
    resource_id: str,
    request: Request,
    db: Session = Depends(get_db),
    user=Depends(get_current_user_with_permissions),
) -> RedirectResponse:
    """
    Toggle a resource's active status via the admin UI.

    This endpoint processes a form request to activate or deactivate a resource.
    It expects a form field 'activate' with value "true" to activate the resource
    or "false" to deactivate it. The endpoint handles exceptions gracefully and
    logs any errors that might occur during the status toggle operation.

    Args:
        resource_id (str): The ID of the resource whose status to toggle.
        request (Request): FastAPI request containing form data with the 'activate' field.
        db (Session): Database session dependency.
        user (str): Authenticated user dependency.

    Returns:
        RedirectResponse: A redirect to the admin dashboard resources section with a
        status code of 303 (See Other).

    Examples:
        >>> callable(admin_set_resource_state)
        True
        >>> admin_set_resource_state.__name__
        'admin_set_resource_state'
    """
    user_email = get_user_email(user)
    LOGGER.debug(f"User {user_email} is toggling resource ID {resource_id}")
    form = await request.form()
    error_message = None
    activate = str(form.get("activate", "true")).lower() == "true"
    is_inactive_checked = str(form.get("is_inactive_checked", "false"))
    try:
        await resource_service.set_resource_state(db, resource_id, activate, user_email=user_email)
    except PermissionError as e:
        LOGGER.warning(f"Permission denied for user {user_email} setting resource state {resource_id}: {e}")
        error_message = str(e)
    except Exception as e:
        LOGGER.error(f"Error setting resource state: {e}")
        error_message = "Failed to set resource state. Please try again."

    root_path = _resolve_root_path(request)
    team_id = str(form.get("team_id", "") or "")
    redirect_url = _build_admin_redirect(root_path, "resources", error=error_message, include_inactive=is_inactive_checked.lower() == "true", team_id=team_id)
    return RedirectResponse(redirect_url, status_code=303)


@admin_router.get("/prompts/{prompt_id}")
@require_permission("prompts.read", allow_admin_bypass=False)
async def admin_get_prompt(prompt_id: str, request: Request, db: Session = Depends(get_db), user=Depends(get_current_user_with_permissions)) -> Dict[str, Any]:
    """Get prompt details for the admin UI.

    Args:
        prompt_id: Prompt ID.
        request: Incoming FastAPI request (for visibility scope resolution).
        db: Database session.
        user: Authenticated user.

    Returns:
        A dictionary with prompt details.

    Raises:
        HTTPException: If the prompt is not found.
        Exception: For any other unexpected errors.

    Examples:
        >>> callable(admin_get_prompt)
        True
        >>> admin_get_prompt.__name__
        'admin_get_prompt'
    """
    LOGGER.info(f"User {get_user_email(user)} requested details for prompt ID {prompt_id}")
    try:
        auth_user_email, auth_token_teams = get_scoped_resource_access_context(request, user)
        prompt_details = await prompt_service.get_prompt_details(
            db,
            prompt_id,
            user_email=auth_user_email,
            token_teams=auth_token_teams,
        )
        prompt = PromptRead.model_validate(prompt_details)
        return prompt.model_dump(by_alias=True)
    except PromptNotFoundError as e:
        raise HTTPException(status_code=404, detail=str(e))
    except Exception as e:
        LOGGER.error(f"Error getting prompt {prompt_id}: {e}")
        raise


@admin_router.post("/prompts")
@require_permission("prompts.create", allow_admin_bypass=False)
async def admin_add_prompt(request: Request, db: Session = Depends(get_db), user=Depends(get_current_user_with_permissions)) -> JSONResponse:
    """Add a prompt via the admin UI.

    Expects form fields:
      - name
      - description (optional)
      - template
      - arguments (as a JSON string representing a list)

    Args:
        request: FastAPI request containing form data.
        db: Database session.
        user: Authenticated user.

    Returns:
        A redirect response to the admin dashboard.

    Raises:
        HTTPException: 422 when public visibility is disabled and request is team-scoped.

    Examples:
        >>> callable(admin_add_prompt)
        True
        >>> admin_add_prompt.__name__
        'admin_add_prompt'
    """
    LOGGER.debug(f"User {get_user_email(user)} is adding a new prompt")
    form = await request.form()
    team_id = _form_team_id(form)
    visibility = str(form.get("visibility", "private"))
    _check_public_visibility_allowed(visibility, team_id=team_id)
    user_email = get_user_email(user)
    team_service = TeamManagementService(db)
    team_id = await team_service.verify_team_for_user(user_email, team_id)

    # Parse tags from comma-separated string
    tags_str = str(form.get("tags", ""))
    tags: List[str] = [tag.strip() for tag in tags_str.split(",") if tag.strip()] if tags_str else []

    try:
        # Validate arguments JSON using prompt service
        arguments: List[Dict[str, Any]] = prompt_service.validate_arguments_json(args_value=form.get("arguments"), context="new prompt")
        prompt = PromptCreate(
            name=str(form["name"]),
            display_name=str(form.get("display_name") or form["name"]),
            description=str(form.get("description")),
            template=str(form["template"]),
            arguments=arguments,
            tags=tags,
            visibility=visibility,
            team_id=team_id,
            owner_email=user_email,
        )
        # Extract creation metadata
        metadata = MetadataCapture.extract_creation_metadata(request, user)

        await prompt_service.register_prompt(
            db,
            prompt,
            created_by=metadata["created_by"],
            created_from_ip=metadata["created_from_ip"],
            created_via=metadata["created_via"],
            created_user_agent=metadata["created_user_agent"],
            import_batch_id=metadata["import_batch_id"],
            federation_source=metadata["federation_source"],
            team_id=team_id,
            owner_email=user_email,
            visibility=visibility,
        )
        return ORJSONResponse(
            content={"message": "Prompt registered successfully!", "success": True},
            status_code=200,
        )
    except Exception as ex:
        if isinstance(ex, ValidationError):
            LOGGER.error(f"ValidationError in admin_add_prompt: {ErrorFormatter.format_validation_error(ex)}")
            return ORJSONResponse(content=ErrorFormatter.format_validation_error(ex), status_code=422)
        if isinstance(ex, IntegrityError):
            error_message = ErrorFormatter.format_database_error(ex)
            LOGGER.error(f"IntegrityError in admin_add_prompt: {error_message}")
            return ORJSONResponse(status_code=409, content=error_message)
        if isinstance(ex, PromptNameConflictError):
            LOGGER.error(f"PromptNameConflictError in admin_add_prompt: {ex}")
            return ORJSONResponse(status_code=409, content={"message": str(ex), "success": False})
        if isinstance(ex, PromptArgumentsJSONError):
            LOGGER.error(f"PromptArgumentsJSONError in admin_add_prompt: {ex}")
            return ORJSONResponse(
                status_code=422,
                content={
                    "message": f"Invalid JSON in {ex.field_name}: {ex.json_error}",
                    "field": ex.field_name,
                    "success": False,
                },
            )
        if isinstance(ex, ContentSizeError):
            LOGGER.error(f"ContentSizeError in admin_add_prompt: {ex}")
            return ORJSONResponse(status_code=413, content={"message": str(ex), "success": False})
        if isinstance(ex, TemplateValidationError):
            LOGGER.error(f"TemplateValidationError in admin_add_prompt: {ex}")
            return ORJSONResponse(
                status_code=400,
                content={
                    "message": f"Template validation failed: {ex.reason}",
                    "template_name": ex.template_name,
                    "reason": ex.reason,
                    "pattern": ex.pattern,
                    "success": False,
                },
            )

        LOGGER.exception(f"Unexpected error in admin_add_prompt: {ex}")
        return ORJSONResponse(content={"message": "An unexpected error occurred. Please try again or contact support.", "success": False}, status_code=500)


@admin_router.post("/prompts/{prompt_id}/edit")
@require_permission("prompts.update", allow_admin_bypass=False)
async def admin_edit_prompt(
    prompt_id: str,
    request: Request,
    db: Session = Depends(get_db),
    user=Depends(get_current_user_with_permissions),
) -> JSONResponse:
    """Edit a prompt via the admin UI.

    Expects form fields:
        - name
        - description (optional)
        - template
        - arguments (as a JSON string representing a list)

    Args:
        prompt_id: Prompt ID.
        request: FastAPI request containing form data.
        db: Database session.
        user: Authenticated user.

    Returns:
        JSONResponse: A JSON response indicating success or failure of the server update operation.

    Raises:
        HTTPException: 422 when public visibility is disabled and request is team-scoped.

    Examples:
        >>> callable(admin_edit_prompt)
        True
        >>> admin_edit_prompt.__name__
        'admin_edit_prompt'
    """
    LOGGER.debug(f"User {get_user_email(user)} is editing prompt {prompt_id}")
    form = await request.form()
    team_id = _form_team_id(form)

    visibility = str(form.get("visibility", "private"))
    _check_public_visibility_allowed(visibility, team_id=team_id)
    user_email = get_user_email(user)

    # Preserve existing prompt's team_id when no explicit team_id is provided.
    # Without this guard, verify_team_for_user() falls back to the user's
    # personal team, silently reassigning the prompt on every edit.
    if not team_id:
        existing_prompt = db.get(DbPrompt, prompt_id)
        existing_team = getattr(existing_prompt, "team_id", None) if existing_prompt else None
        if isinstance(existing_team, str) and existing_team:
            team_id = existing_team

    team_service = TeamManagementService(db)
    team_id = await team_service.verify_team_for_user(user_email, team_id)

    # Parse tags from comma-separated string
    tags_str = str(form.get("tags", ""))
    tags: List[str] = [tag.strip() for tag in tags_str.split(",") if tag.strip()] if tags_str else []
    try:
        # Validate arguments JSON using prompt service; preserve existing when field absent
        args_value = form.get("arguments")
        arguments = prompt_service.validate_arguments_json(args_value, context="prompt update") if args_value is not None else None
        mod_metadata = MetadataCapture.extract_modification_metadata(request, user, 0)
        prompt = PromptUpdate(
            custom_name=str(form.get("customName") or form.get("name")),
            display_name=str(form.get("displayName") or form.get("display_name") or form.get("name")),
            description=str(form.get("description")),
            template=str(form["template"]),
            arguments=arguments,
            tags=tags,
            visibility=visibility,
            team_id=team_id,
            owner_email=user_email,
        )
        await prompt_service.update_prompt(
            db,
            prompt_id,
            prompt,
            modified_by=mod_metadata["modified_by"],
            modified_from_ip=mod_metadata["modified_from_ip"],
            modified_via=mod_metadata["modified_via"],
            modified_user_agent=mod_metadata["modified_user_agent"],
            user_email=user_email,
        )
        return ORJSONResponse(
            content={"message": "Prompt updated successfully!", "success": True},
            status_code=200,
        )
    except PermissionError as e:
        LOGGER.info(f"Permission denied for user {get_user_email(user)}: {e}")
        return ORJSONResponse(content={"message": str(e), "success": False}, status_code=403)
    except Exception as ex:
        if isinstance(ex, ValidationError):
            LOGGER.error(f"ValidationError in admin_edit_prompt: {ErrorFormatter.format_validation_error(ex)}")
            return ORJSONResponse(content=ErrorFormatter.format_validation_error(ex), status_code=422)
        if isinstance(ex, IntegrityError):
            error_message = ErrorFormatter.format_database_error(ex)
            LOGGER.error(f"IntegrityError in admin_edit_prompt: {error_message}")
            return ORJSONResponse(status_code=409, content=error_message)
        if isinstance(ex, PromptNameConflictError):
            LOGGER.error(f"PromptNameConflictError in admin_edit_prompt: {ex}")
            return ORJSONResponse(status_code=409, content={"message": str(ex), "success": False})
        if isinstance(ex, PromptArgumentsJSONError):
            LOGGER.error(f"PromptArgumentsJSONError in admin_edit_prompt: {ex}")
            return ORJSONResponse(
                status_code=422,
                content={
                    "message": f"Invalid JSON in {ex.field_name}: {ex.json_error}",
                    "field": ex.field_name,
                    "success": False,
                },
            )
        if isinstance(ex, ContentSizeError):
            LOGGER.error(f"ContentSizeError in admin_edit_prompt: {ex}")
            return ORJSONResponse(status_code=413, content={"message": str(ex), "success": False})
        if isinstance(ex, TemplateValidationError):
            LOGGER.error(f"TemplateValidationError in admin_edit_prompt: {ex}")
            return ORJSONResponse(
                status_code=400,
                content={
                    "message": f"Template validation failed: {ex.reason}",
                    "template_name": ex.template_name,
                    "reason": ex.reason,
                    "pattern": ex.pattern,
                    "success": False,
                },
            )
        LOGGER.exception(f"Unexpected error in admin_edit_prompt: {ex}")
        return ORJSONResponse(content={"message": "An unexpected error occurred. Please try again or contact support.", "success": False}, status_code=500)


@admin_router.post("/prompts/{prompt_id}/delete")
@require_permission("prompts.delete", allow_admin_bypass=False)
async def admin_delete_prompt(prompt_id: str, request: Request, db: Session = Depends(get_db), user=Depends(get_current_user_with_permissions)) -> RedirectResponse:
    """
    Delete a prompt via the admin UI.

    This endpoint permanently deletes a prompt from the database using its ID.
    Deletion is irreversible and requires authentication. All actions are logged
    for administrative auditing.

    Args:
        prompt_id (str): The ID of the prompt to delete.
        request (Request): FastAPI request object (not used directly but required by the route signature).
        db (Session): Database session dependency.
        user (str): Authenticated user dependency.

    Returns:
        RedirectResponse: A redirect response to the prompts section of the admin
        dashboard with a status code of 303 (See Other).

    Examples:
        >>> callable(admin_delete_prompt)
        True
        >>> admin_delete_prompt.__name__
        'admin_delete_prompt'
    """
    form = await request.form()
    is_inactive_checked: str = str(form.get("is_inactive_checked", "false"))
    purge_metrics = str(form.get("purge_metrics", "false")).lower() == "true"
    user_email = get_user_email(user)
    LOGGER.info(f"User {get_user_email(user)} is deleting prompt id {prompt_id}")
    error_message = None
    try:
        await prompt_service.delete_prompt(db, prompt_id, user_email=user_email, purge_metrics=purge_metrics)
    except PermissionError as e:
        LOGGER.warning(f"Permission denied for user {user_email} deleting prompt {prompt_id}: {e}")
        error_message = str(e)
    except Exception as e:
        LOGGER.error(f"Error deleting prompt: {e}")
        error_message = "Failed to delete prompt. Please try again."
    root_path = _resolve_root_path(request)
    team_id = str(form.get("team_id", "") or "")
    redirect_url = _build_admin_redirect(root_path, "prompts", error=error_message, include_inactive=is_inactive_checked.lower() == "true", team_id=team_id)
    return RedirectResponse(redirect_url, status_code=303)


@admin_router.post("/prompts/{prompt_id}/state")
@require_permission("prompts.update", allow_admin_bypass=False)
async def admin_set_prompt_state(
    prompt_id: str,
    request: Request,
    db: Session = Depends(get_db),
    user=Depends(get_current_user_with_permissions),
) -> RedirectResponse:
    """
    Toggle a prompt's active status via the admin UI.

    This endpoint processes a form request to activate or deactivate a prompt.
    It expects a form field 'activate' with value "true" to activate the prompt
    or "false" to deactivate it. The endpoint handles exceptions gracefully and
    logs any errors that might occur during the status toggle operation.

    Args:
        prompt_id (str): The ID of the prompt whose status to toggle.
        request (Request): FastAPI request containing form data with the 'activate' field.
        db (Session): Database session dependency.
        user (str): Authenticated user dependency.

    Returns:
        RedirectResponse: A redirect to the admin dashboard prompts section with a
        status code of 303 (See Other).

    Examples:
        >>> callable(admin_set_prompt_state)
        True
        >>> admin_set_prompt_state.__name__
        'admin_set_prompt_state'
    """
    user_email = get_user_email(user)
    LOGGER.debug(f"User {user_email} is toggling prompt ID {prompt_id}")
    error_message = None
    form = await request.form()
    activate: bool = str(form.get("activate", "true")).lower() == "true"
    is_inactive_checked: str = str(form.get("is_inactive_checked", "false"))
    try:
        await prompt_service.set_prompt_state(db, prompt_id, activate, user_email=user_email)
    except PermissionError as e:
        LOGGER.warning(f"Permission denied for user {user_email} setting prompt state {prompt_id}: {e}")
        error_message = str(e)
    except Exception as e:
        LOGGER.error(f"Error setting prompt state: {e}")
        error_message = "Failed to set prompt state. Please try again."

    root_path = _resolve_root_path(request)
    team_id = str(form.get("team_id", "") or "")
    redirect_url = _build_admin_redirect(root_path, "prompts", error=error_message, include_inactive=is_inactive_checked.lower() == "true", team_id=team_id)
    return RedirectResponse(redirect_url, status_code=303)


@admin_router.get("/roots/search", response_class=JSONResponse)
@require_permission("admin.system_config", allow_admin_bypass=False)
async def admin_search_roots(
    q: str = Query("", max_length=500, description="Search query"),
    limit: int = Query(settings.pagination_default_page_size, ge=1, le=settings.pagination_max_page_size, description="Maximum number of results to return"),
    user=Depends(get_current_user_with_permissions),
) -> dict:
    """Search roots by name or URI.

    Roots are held in-memory by :class:`~mcpgateway.services.root_service.RootService`,
    so this function fetches the full list before filtering. Registered roots are
    typically a small set, making the in-memory scan negligible. If root counts grow
    substantially, consider adding filtering support directly to
    :meth:`~mcpgateway.services.root_service.RootService.list_roots`.

    Args:
        q (str): Free-text search query matched against root name and URI.
        limit (int): Maximum number of results to return.
        user: Authenticated user context.

    Returns:
        dict: Unified search payload containing matching roots.

    Examples:
        >>> callable(admin_search_roots)
        True
        >>> admin_search_roots.__name__
        'admin_search_roots'
    """
    search_query = _normalize_search_query(q)
    # Defense-in-depth clamp: FastAPI validates ge/le at the HTTP layer, but direct
    # Python calls (e.g. from admin_unified_search) bypass that validation.
    limit = max(1, min(limit, settings.pagination_max_page_size))
    all_roots = await root_service.list_roots()

    results: list[dict[str, Any]] = []
    for r in all_roots:
        if len(results) >= limit:
            break
        uri_str = str(r.uri)
        name_str = r.name or uri_str
        if not search_query or search_query in uri_str.lower() or search_query in name_str.lower():
            results.append({"id": uri_str, "name": name_str, "uri": uri_str})

    LOGGER.debug(f"User {get_user_email(user)} searched roots with query '{search_query}': {len(results)} results")
    return _build_search_response(entity_key="roots", entity_type="roots", items=results, query=search_query, tags="", tag_groups=[])


@admin_router.get("/roots/export")
@require_permission("admin.system_config", allow_admin_bypass=False)
async def admin_export_root(
    uri: str,
    user=Depends(get_current_user_with_permissions),
):
    """
    Export a single root configuration as JSON.

    Args:
        uri: Root URI to export (query parameter)
        user: Authenticated user

    Returns:
        JSON file download with root configuration

    Raises:
        HTTPException: If root not found or export fails
    """
    try:
        LOGGER.info(f"Admin user {get_user_email(user)} requested root export for URI: {uri}")

        # Get the root by URI
        root = await root_service.get_root_by_uri(uri)

        # Extract username from user
        username = get_user_email(user)

        # Create export data
        export_data = {
            "exported_at": datetime.now().isoformat(),
            "exported_by": username,
            "export_type": "root",
            "version": "1.0",
            "root": {
                "uri": str(root.uri),
                "name": root.name,
            },
        }

        # Generate filename - sanitize URI for filename
        # Remove protocol and special characters
        safe_uri = uri.replace("://", "_").replace("/", "_").replace("\\", "_")
        timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
        filename = f"root-export-{safe_uri}-{timestamp}.json"

        # Return as downloadable file
        content = orjson.dumps(export_data, option=orjson.OPT_INDENT_2).decode()
        return Response(
            content=content,
            media_type="application/json",
            headers={
                "Content-Disposition": f'attachment; filename="{filename}"',
            },
        )

    except RootServiceNotFoundError as e:
        LOGGER.error(f"Root not found for export by user {get_user_email(user)}: {str(e)}")
        raise HTTPException(status_code=404, detail=str(e))
    except Exception as e:
        LOGGER.error(f"Unexpected root export error for user {get_user_email(user)}: {str(e)}")
        raise HTTPException(status_code=500, detail="Root export failed")


@admin_router.get("/roots/{uri:path}")
@require_permission("admin.system_config", allow_admin_bypass=False)
async def admin_get_root(uri: str, user=Depends(get_current_user_with_permissions)) -> dict:
    """Get a specific root by URI via the admin UI.

    This endpoint retrieves details for a specific root URI from the system.
    It requires authentication and logs the operation for audit purposes.

    Args:
        uri (str): The URI of the root to retrieve.
        user: Authenticated user dependency.

    Returns:
        dict: A dictionary containing the root information.

    Raises:
        HTTPException: If the root is not found.
        Exception: For any other unexpected errors.

    Examples:
        >>> callable(admin_get_root)
        True
        >>> admin_get_root.__name__
        'admin_get_root'
    """
    LOGGER.debug(f"User {get_user_email(user)} is retrieving root URI {uri}")
    try:
        root = await root_service.get_root_by_uri(uri)
        return root.model_dump(by_alias=True)
    except RootServiceNotFoundError as e:
        raise HTTPException(status_code=404, detail=str(e))
    except Exception as e:
        LOGGER.error(f"Error getting root {uri}: {e}")
        raise e


@admin_router.post("/roots")
@require_permission("admin.system_config", allow_admin_bypass=False)
async def admin_add_root(request: Request, user=Depends(get_current_user_with_permissions), _db: Session = Depends(get_db)) -> RedirectResponse:
    """Add a new root via the admin UI.

    Expects form fields:
      - uri
      - name (optional)

    Args:
        request: FastAPI request containing form data.
        user: Authenticated user.
        _db: Database session for permission checks.

    Returns:
        RedirectResponse: A redirect response to the admin dashboard.

    Examples:
        >>> callable(admin_add_root)
        True
        >>> admin_add_root.__name__
        'admin_add_root'
    """
    error_message = None
    user_email = get_user_email(user)
    LOGGER.debug(f"User {user_email} is adding a new root")

    form = await request.form()
    uri = str(form.get("uri", ""))
    name_value = form.get("name")
    name: str | None = None
    if isinstance(name_value, str) and name_value.strip():
        name = name_value.strip()

    try:
        if not uri:
            raise ValueError("URI is required")
        await root_service.add_root(str(uri), name)

    except RootServiceError as e:
        LOGGER.warning(f"Failed to add root for user {user_email}: {e}")
        error_message = "Failed to add root. Please check the URI format."
    except ValueError as e:
        LOGGER.warning(f"Invalid input from user {user_email}: {e}")
        error_message = "Invalid input. Please try again."
    except Exception as e:
        LOGGER.error(f"Error adding root: {e}")
        error_message = "Failed to add root. Please try again."

    root_path = _resolve_root_path(request)
    team_id = str(form.get("team_id", "") or "")
    redirect_url = _build_admin_redirect(root_path, "roots", error=error_message, team_id=team_id)
    return RedirectResponse(redirect_url, status_code=303)


@admin_router.post("/roots/{uri:path}/update")
@require_permission("admin.system_config", allow_admin_bypass=False)
async def admin_update_root(uri: str, request: Request, user=Depends(get_current_user_with_permissions)) -> RedirectResponse:
    """Update a root via the admin UI.

    This endpoint updates an existing root URI in the system. It expects form
    fields for the new values and requires authentication.

    Expects form fields:
    - name (optional): New name for the root
    - is_inactive_checked: Whether the root should be marked as inactive

    Args:
        uri (str): The URI of the root to update.
        request (Request): FastAPI request object containing form data.
        user: Authenticated user dependency.

    Returns:
        RedirectResponse: A redirect response to the roots section of the admin
        dashboard with a status code of 303 (See Other).

    Raises:
        HTTPException: If the root is not found (404) or other errors occur.
        Exception: For any other unexpected errors.

    Examples:
        >>> callable(admin_update_root)
        True
        >>> admin_update_root.__name__
        'admin_update_root'
    """
    LOGGER.debug(f"User {get_user_email(user)} is updating root URI {uri}")

    try:
        form = await request.form()
        name_value = form.get("name")
        name: str | None = None

        if isinstance(name_value, str):
            name = name_value

        await root_service.update_root(uri, name)

        root_path = _resolve_root_path(request)
        is_inactive_checked: str = str(form.get("is_inactive_checked", "false"))
        team_id = str(form.get("team_id", "") or "")
        redirect_url = _build_admin_redirect(root_path, "roots", include_inactive=is_inactive_checked.lower() == "true", team_id=team_id)
        return RedirectResponse(redirect_url, status_code=303)

    except RootServiceNotFoundError as e:
        raise HTTPException(status_code=404, detail=str(e))
    except Exception as e:
        LOGGER.error(f"Error updating root {uri}: {e}")
        raise e


@admin_router.post("/roots/{uri:path}/delete")
@require_permission("admin.system_config", allow_admin_bypass=False)
async def admin_delete_root(uri: str, request: Request, user=Depends(get_current_user_with_permissions), _db: Session = Depends(get_db)) -> RedirectResponse:
    """
    Delete a root via the admin UI.

    This endpoint removes a registered root URI from the system. The deletion is
    permanent and cannot be undone. It requires authentication and logs the
    operation for audit purposes.

    Args:
        uri (str): The URI of the root to delete.
        request (Request): FastAPI request object (not used directly but required by the route signature).
        user (str): Authenticated user dependency.
        _db: Database session for permission checks.

    Returns:
        RedirectResponse: A redirect response to the roots section of the admin
        dashboard with a status code of 303 (See Other).

    Examples:
        >>> callable(admin_delete_root)
        True
        >>> admin_delete_root.__name__
        'admin_delete_root'
    """
    LOGGER.debug(f"User {get_user_email(user)} is deleting root URI {uri}")
    await root_service.remove_root(uri)
    form = await request.form()
    root_path = _resolve_root_path(request)
    is_inactive_checked: str = str(form.get("is_inactive_checked", "false"))
    team_id = str(form.get("team_id", "") or "")
    redirect_url = _build_admin_redirect(root_path, "roots", include_inactive=is_inactive_checked.lower() == "true", team_id=team_id)
    return RedirectResponse(redirect_url, status_code=303)


# Metrics
MetricsDict = Dict[str, Union[ToolMetrics, ResourceMetrics, ServerMetrics, PromptMetrics]]


# @admin_router.get("/metrics", response_model=MetricsDict)
# async def admin_get_metrics(
#     db: Session = Depends(get_db),
#     user=Depends(get_current_user_with_permissions),
# ) -> MetricsDict:
#     """
#     Retrieve aggregate metrics for all entity types via the admin UI.

#     This endpoint collects and returns usage metrics for tools, resources, servers,
#     and prompts. The metrics are retrieved by calling the aggregate_metrics method
#     on each respective service, which compiles statistics about usage patterns,
#     success rates, and other relevant metrics for administrative monitoring
#     and analysis purposes.

#     Args:
#         db (Session): Database session dependency.
#         user (str): Authenticated user dependency.

#     Returns:
#         MetricsDict: A dictionary containing the aggregated metrics for tools,
#         resources, servers, and prompts. Each value is a Pydantic model instance
#         specific to the entity type.
#     """
#     LOGGER.debug(f"User {get_user_email(user)} requested aggregate metrics")
#     tool_metrics = await tool_service.aggregate_metrics(db)
#     resource_metrics = await resource_service.aggregate_metrics(db)
#     server_metrics = await server_service.aggregate_metrics(db)
#     prompt_metrics = await prompt_service.aggregate_metrics(db)

#     # Return actual Pydantic model instances
#     return {
#         "tools": tool_metrics,
#         "resources": resource_metrics,
#         "servers": server_metrics,
#         "prompts": prompt_metrics,
#     }


@admin_router.get("/metrics")
@require_permission("admin.system_config", allow_admin_bypass=False)
async def get_aggregated_metrics(
    db: Session = Depends(get_db),
    _user=Depends(get_current_user_with_permissions),
) -> Dict[str, Any]:
    """Retrieve aggregated metrics and top performers for all entity types.

    This endpoint collects usage metrics and top-performing entities for tools,
    resources, prompts, and servers by calling the respective service methods.
    The results are compiled into a dictionary for administrative monitoring.

    Args:
        db (Session): Database session dependency for querying metrics.

    Returns:
        Dict[str, Any]: A dictionary containing aggregated metrics and top performers
            for tools, resources, prompts, and servers. The structure includes:
            - 'tools': Metrics for tools.
            - 'resources': Metrics for resources.
            - 'prompts': Metrics for prompts.
            - 'servers': Metrics for servers.
            - 'topPerformers': A nested dictionary with all tools, resources, prompts,
              and servers with their metrics.
    """
    metrics = {
        "tools": await tool_service.aggregate_metrics(db),
        "resources": await resource_service.aggregate_metrics(db),
        "prompts": await prompt_service.aggregate_metrics(db),
        "servers": await server_service.aggregate_metrics(db),
        "topPerformers": {
            "tools": await tool_service.get_top_tools(db, limit=10),
            "resources": await resource_service.get_top_resources(db, limit=10),
            "prompts": await prompt_service.get_top_prompts(db, limit=10),
            "servers": await server_service.get_top_servers(db, limit=10),
        },
    }
    return metrics


@admin_router.get("/metrics/partial", response_class=HTMLResponse)
@require_permission("admin.system_config", allow_admin_bypass=False)
async def admin_metrics_partial_html(
    request: Request,
    entity_type: QueryEntityType = "tools",
    page: int = Query(1, ge=1, description="Page number (1-indexed)"),
    per_page: int = Query(10, ge=1, le=settings.pagination_max_page_size, description="Items per page"),
    db: Session = Depends(get_db),
    user=Depends(get_current_user_with_permissions),
):
    """
    Return HTML partial for paginated top performers (HTMX endpoint).

    Matches the /admin/tools/partial pattern for consistent pagination UX.

    Args:
        request: FastAPI request object
        entity_type: Entity type (tools, resources, prompts, servers)
        page: Page number (1-indexed)
        per_page: Items per page
        db: Database session
        user: Authenticated user

    Returns:
        HTMLResponse with paginated table and OOB pagination controls

    Raises:
        HTTPException: If entity_type is not one of the valid types
    """
    LOGGER.debug(f"User {get_user_email(user)} requested metrics partial (entity_type={entity_type}, page={page}, per_page={per_page})")

    # Validate entity type
    valid_types = ["tools", "resources", "prompts", "servers"]
    if entity_type not in valid_types:
        raise HTTPException(status_code=400, detail=f"Invalid entity_type. Must be one of: {', '.join(valid_types)}")

    # Constrain parameters
    page = max(1, page)
    per_page = max(1, min(per_page, 1000))

    # Get all items for this entity type
    if entity_type == "tools":
        all_items = await tool_service.get_top_tools(db, limit=None)
    elif entity_type == "resources":
        all_items = await resource_service.get_top_resources(db, limit=None)
    elif entity_type == "prompts":
        all_items = await prompt_service.get_top_prompts(db, limit=None)
    else:  # servers
        all_items = await server_service.get_top_servers(db, limit=None)

    # Calculate pagination
    total_items = len(all_items)
    total_pages = math.ceil(total_items / per_page) if per_page > 0 else 0
    offset = (page - 1) * per_page
    paginated_items = all_items[offset : offset + per_page]

    # Convert to JSON-serializable format
    data = jsonable_encoder(paginated_items)

    # Build pagination metadata
    pagination = PaginationMeta(
        page=page,
        per_page=per_page,
        total_items=total_items,
        total_pages=total_pages,
        has_next=page < total_pages,
        has_prev=page > 1,
    )

    # Render template
    return request.app.state.templates.TemplateResponse(
        request,
        "metrics_top_performers_partial.html",
        {
            "request": request,
            "entity_type": entity_type,
            "data": data,
            "pagination": pagination.model_dump(),
            "root_path": _resolve_root_path(request),
        },
    )


@admin_router.post("/metrics/reset", response_model=Dict[str, object])
@require_permission("admin.system_config", allow_admin_bypass=False)
async def admin_reset_metrics(db: Session = Depends(get_db), user=Depends(get_current_user_with_permissions)) -> Dict[str, object]:
    """
    Reset all metrics for tools, resources, servers, and prompts.
    Each service must implement its own reset_metrics method.

    Args:
        db (Session): Database session dependency.
        user (str): Authenticated user dependency.

    Returns:
        Dict[str, object]: A dictionary containing a success message and status.

    Examples:
        >>> callable(admin_reset_metrics)
        True
        >>> admin_reset_metrics.__name__
        'admin_reset_metrics'
    """
    LOGGER.debug(f"User {get_user_email(user)} requested to reset all metrics")
    await tool_service.reset_metrics(db)
    await resource_service.reset_metrics(db)
    await server_service.reset_metrics(db)
    await prompt_service.reset_metrics(db)
    return {"message": "All metrics reset successfully", "success": True}


@admin_router.post("/gateways/test", response_model=GatewayTestResponse)
@require_permission("gateways.read", allow_admin_bypass=False)
async def admin_test_gateway(
    request: GatewayTestRequest, team_id: Optional[str] = Depends(_validated_team_id_param), user=Depends(get_current_user_with_permissions), db: Session = Depends(get_db)
) -> GatewayTestResponse:
    """
    Test a gateway by sending a request to its URL.
    This endpoint allows administrators to test the connectivity and response

    Args:
        request (GatewayTestRequest): The request object containing the gateway URL and request details.
        team_id (Optional[str]): Optional team ID for team-specific gateways.
        user (str): Authenticated user dependency.
        db (Session): Database session dependency.

    Returns:
        GatewayTestResponse: The response from the gateway, including status code, latency, and body

    Examples:
        >>> callable(admin_test_gateway)
        True
        >>> admin_test_gateway.__name__
        'admin_test_gateway'
    """
    start_time: float = time.monotonic()

    # Build allowlist for gateway test endpoint
    allowed_hosts_set: set[str] = set()

    if settings.gateway_test_allow_registered_only:
        # Mode 1: Only allow testing registered gateway URLs
        # Query all enabled gateways to build allowlist from their base URLs
        try:
            query = select(DbGateway.url).where(DbGateway.enabled)
            if team_id:
                query = query.where(DbGateway.team_id == team_id)
            registered_urls = db.execute(query).scalars().all()

            # Extract hostnames from registered gateway URLs
            for url in registered_urls:
                try:
                    parsed = urllib.parse.urlparse(url)
                    if parsed.hostname:
                        # Normalize: lowercase and strip trailing dots
                        hostname = parsed.hostname.lower().rstrip(".")
                        allowed_hosts_set.add(hostname)
                except (ValueError, AttributeError) as e:
                    # Log parse failures to help debug "URL not in allowlist" mysteries
                    LOGGER.debug("Failed to parse registered gateway URL '%s': %s", url, e)
                    continue
        except SQLAlchemyError as e:
            LOGGER.warning("Failed to build allowlist from registered gateways: %s", e)
    else:
        # Mode 2: Use configured host patterns from settings
        allowed_hosts_set = set(settings.gateway_test_allowed_hosts)

    allowed_hosts = list(allowed_hosts_set)

    # Validate URL with allowlist enforcement
    try:
        validated_base_url = SecurityValidator.validate_gateway_test_url(str(request.base_url), allowed_hosts, "Gateway test URL")
    except ValueError as e:
        # Log the actual error for security monitoring, but return generic message
        safe_url = sanitize_url_for_logging(str(request.base_url))
        LOGGER.warning(
            "Gateway test URL validation failed for %s by user %s: %s",
            safe_url,
            get_user_email(user),
            str(e),
        )
        latency_ms = int((time.monotonic() - start_time) * 1000)
        # Generic error message - don't expose allowlist or validation details
        return GatewayTestResponse(status_code=400, latency_ms=latency_ms, body={"error": "Invalid gateway URL"})

    full_url = validated_base_url.rstrip("/") + "/" + request.path.lstrip("/")
    full_url = full_url.rstrip("/")
    safe_validated_url = sanitize_url_for_logging(validated_base_url)
    LOGGER.debug(f"User {get_user_email(user)} testing server at {safe_validated_url}.")

    # TODO(ICACF-15): DNS rebinding risk — allowlist and SSRF checks resolve DNS, but the
    # actual ResilientHttpClient request resolves DNS a third time. An attacker-controlled DNS
    # server could return a public IP during validation and a private IP during the actual
    # request. Consider pinning the resolved IP for outbound requests (custom transport) or
    # caching DNS resolution across validation and request phases.
    headers = request.headers or {}

    # Attempt to find a registered gateway matching this URL and team.
    # Query the raw DB object directly so we get the unmasked auth_value
    # (get_first_gateway_by_url returns a masked GatewayRead where
    # auth_value="*****", which cannot be decoded).
    try:
        query = select(DbGateway).where(DbGateway.url == validated_base_url, DbGateway.enabled)
        if team_id:
            query = query.where(DbGateway.team_id == team_id)
        gateway = db.execute(query).scalars().first()
    except Exception:
        gateway = None

    try:
        user_email = get_user_email(user)
        if gateway and gateway.auth_type == "oauth" and gateway.oauth_config:
            grant_type = gateway.oauth_config.get("grant_type", "client_credentials")

            if grant_type == "authorization_code":
                # For Authorization Code flow, try to get stored tokens
                try:
                    # First-Party
                    from mcpgateway.services.token_storage_service import TokenStorageService  # pylint: disable=import-outside-toplevel

                    token_storage = TokenStorageService(db)

                    # Get user-specific OAuth token
                    if not user_email:
                        latency_ms = int((time.monotonic() - start_time) * 1000)
                        return GatewayTestResponse(
                            status_code=401, latency_ms=latency_ms, body={"error": f"User authentication required for OAuth-protected gateway '{gateway.name}'. Please ensure you are authenticated."}
                        )

                    access_token: str = await token_storage.get_user_token(gateway.id, user_email)

                    if access_token:
                        headers["Authorization"] = f"Bearer {access_token}"
                    else:
                        latency_ms = int((time.monotonic() - start_time) * 1000)
                        return GatewayTestResponse(
                            status_code=401, latency_ms=latency_ms, body={"error": f"Please authorize {gateway.name} first. Visit /oauth/authorize/{gateway.id} to complete OAuth flow."}
                        )
                except Exception as e:
                    LOGGER.error(f"Failed to obtain stored OAuth token for gateway {gateway.name}: {e}")
                    latency_ms = int((time.monotonic() - start_time) * 1000)
                    return GatewayTestResponse(status_code=500, latency_ms=latency_ms, body={"error": f"OAuth token retrieval failed for gateway: {str(e)}"})
            else:
                # For Client Credentials flow, get token directly
                try:
                    oauth_manager = OAuthManager(request_timeout=int(os.getenv("OAUTH_REQUEST_TIMEOUT", "30")), max_retries=int(os.getenv("OAUTH_MAX_RETRIES", "3")))
                    access_token: str = await oauth_manager.get_access_token(
                        gateway.oauth_config, ca_certificate=gateway.ca_certificate, client_cert=gateway.client_cert, client_key=gateway.client_key
                    )
                    headers["Authorization"] = f"Bearer {access_token}"
                except Exception as e:
                    LOGGER.error(f"Failed to obtain OAuth access token for gateway {gateway.name}: {e}")
                    response_body = {"error": f"OAuth token retrieval failed for gateway: {str(e)}"}
        elif gateway and gateway.auth_type in ("basic", "bearer", "authheaders") and gateway.auth_value:
            if isinstance(gateway.auth_value, dict):
                headers.update(gateway.auth_value)
            elif isinstance(gateway.auth_value, str):
                headers.update(decode_auth(gateway.auth_value))

        # Prepare request based on content type
        content_type = getattr(request, "content_type", "application/json")
        request_kwargs = {"method": request.method.upper(), "url": full_url, "headers": headers}

        if request.body is not None:
            if content_type == "application/x-www-form-urlencoded":
                # Set proper content type header and use data parameter for form encoding
                headers["Content-Type"] = "application/x-www-form-urlencoded"
                request_kwargs["data"] = request.body
            else:
                # Default to JSON
                headers["Content-Type"] = "application/json"
                request_kwargs["json"] = request.body

        async with ResilientHttpClient(client_args={"timeout": settings.federation_timeout, "verify": not settings.skip_ssl_verify}) as client:
            response: httpx.Response = await client.request(**request_kwargs)
        latency_ms = int((time.monotonic() - start_time) * 1000)
        try:
            response_body: Union[Dict[str, Any], str] = response.json()
        except ValueError:
            response_body = {"details": response.text}

        # Structured logging: Log successful gateway test
        structured_logger = get_structured_logger("gateway_service")
        structured_logger.log(
            level="INFO",
            message=f"Gateway test completed: {safe_validated_url}",
            event_type="gateway_tested",
            component="gateway_service",
            user_email=get_user_email(user),
            team_id=team_id,
            resource_type="gateway",
            resource_id=gateway.id if gateway else None,
            custom_fields={
                "gateway_name": gateway.name if gateway else None,
                "gateway_url": safe_validated_url,
                "test_method": request.method,
                "test_path": request.path,
                "status_code": response.status_code,
                "latency_ms": latency_ms,
            },
        )

        return GatewayTestResponse(status_code=response.status_code, latency_ms=latency_ms, body=response_body)

    except httpx.RequestError as e:
        safe_url = sanitize_url_for_logging(str(request.base_url))
        LOGGER.warning("Gateway test failed for %s: %s", safe_url, e)
        latency_ms = int((time.monotonic() - start_time) * 1000)

        # Structured logging: Log failed gateway test
        structured_logger = get_structured_logger("gateway_service")
        structured_logger.log(
            level="ERROR",
            message=f"Gateway test failed: {safe_url}",
            event_type="gateway_test_failed",
            component="gateway_service",
            user_email=get_user_email(user),
            team_id=team_id,
            resource_type="gateway",
            resource_id=gateway.id if gateway else None,
            error=e,
            custom_fields={
                "gateway_name": gateway.name if gateway else None,
                "gateway_url": safe_url,
                "test_method": request.method,
                "test_path": request.path,
                "latency_ms": latency_ms,
            },
        )

        return GatewayTestResponse(status_code=502, latency_ms=latency_ms, body={"error": "Request failed", "details": str(e)})


# Event Streaming via SSE to the Admin UI
@admin_router.get("/events")
@require_permission("admin.events", allow_admin_bypass=False)
async def admin_events(request: Request, _user=Depends(get_current_user_with_permissions), _db: Session = Depends(get_db)):
    """
    Stream admin events from all services via SSE (Server-Sent Events).

    This endpoint establishes a persistent connection to stream real-time updates
    from the gateway service and tool service to the frontend. It aggregates
    multiple event streams into a single asyncio queue for unified delivery.

    Args:
        request (Request): The FastAPI request object, used to detect client disconnection.
        _user (Any): Authenticated user dependency (ensures admin permissions).
        _db: Database session for permission checks.

    Returns:
        StreamingResponse: An async generator yielding SSE-formatted strings
        (media_type="text/event-stream").

    Examples:
        >>> # Test function exists and has correct name
        >>> from mcpgateway.admin import admin_events
        >>> admin_events.__name__
        'admin_events'
        >>> # Test it's a coroutine function
        >>> import inspect
        >>> inspect.iscoroutinefunction(admin_events)
        True
    """
    # Create a shared queue to aggregate events from all services
    event_queue = asyncio.Queue()
    heartbeat_interval = 15.0

    # Define a generic producer that feeds a specific stream into the queue
    async def stream_to_queue(generator, source_name: str):
        """Consume events from an async generator and forward them to a queue.

        This coroutine iterates over an asynchronous generator and enqueues each
        yielded event into a global or external `event_queue`. It gracefully
        handles task cancellation and logs unexpected exceptions.

        Args:
            generator (AsyncGenerator): An asynchronous generator that yields events.
            source_name (str): A human-readable label for the event source, used
                for logging error messages.

        Raises:
            asyncio.CancelledError: If the task is cancelled externally.
            Exception: Any unexpected exception raised while iterating over the
                generator will be caught, logged, and suppressed.

        Doctest:
            >>> import asyncio
            >>> class FakeQueue:
            ...     def __init__(self):
            ...         self.items = []
            ...     async def put(self, item):
            ...         self.items.append(item)
            ...
            >>> async def fake_gen():
            ...     yield 1
            ...     yield 2
            ...     yield 3
            ...
            >>> event_queue = FakeQueue()  # monkey-patch the global name
            >>> async def run_test():
            ...     await stream_to_queue(fake_gen(), "test_source")
            ...     return event_queue.items
            ...
            >>> asyncio.run(run_test())
            [1, 2, 3]

        """
        try:
            async for event in generator:
                await event_queue.put(event)
        except Exception as e:
            LOGGER.error(f"Error in {source_name} event subscription: {e}")

    async def event_generator():
        """
        Asynchronous Server-Sent Events (SSE) generator.

        This coroutine listens to multiple background event streams (e.g., from
        gateway and tool services), funnels their events into a shared queue, and
        yields them to the client in proper SSE format.

        The function:
        - Spawns background tasks to consume events from subscribed services.
        - Monitors the client connection for disconnection.
        - Yields SSE-formatted messages as they arrive.
        - Cleans up subscription tasks on exit.

        The SSE format emitted:
            event: <event_type>
            data: <json-encoded data>

        Yields:
            AsyncGenerator[str, None]: A generator yielding SSE-formatted strings.

        Raises:
            asyncio.CancelledError: If the SSE stream or background tasks are cancelled.
            Exception: Any unexpected exception in the main loop is logged but not re-raised.

        Notes:
            This function expects the following names to exist in the outer scope:
            - `request`: A FastAPI/Starlette Request object.
            - `event_queue`: An asyncio.Queue instance where events are dispatched.
            - `gateway_service` and `tool_service`: Services exposing async subscribe_events().
            - `stream_to_queue`: Coroutine to pipe service streams into the queue.
            - `LOGGER`: Logger instance.

        Example:
            Basic doctest demonstrating SSE formatting from mock data:

            >>> import orjson, asyncio
            >>> class DummyRequest:
            ...     async def is_disconnected(self):
            ...         return False
            >>> async def dummy_gen():
            ...     # Simulate an event queue and minimal environment
            ...     global request, event_queue
            ...     request = DummyRequest()
            ...     event_queue = asyncio.Queue()
            ...     # Minimal stubs to satisfy references
            ...     class DummyService:
            ...         async def subscribe_events(self):
            ...             async def gen():
            ...                 yield {"type": "test", "data": {"a": 1}}
            ...             return gen()
            ...     global gateway_service, tool_service, stream_to_queue, LOGGER
            ...     gateway_service = tool_service = DummyService()
            ...     async def stream_to_queue(gen, tag):
            ...         async for e in gen:
            ...             await event_queue.put(e)
            ...     class DummyLogger:
            ...         def debug(self, *args, **kwargs): pass
            ...         def error(self, *args, **kwargs): pass
            ...     LOGGER = DummyLogger()
            ...
            ...     agen = event_generator()
            ...     # Startup requires allowing tasks to enqueue
            ...     async def get_one():
            ...         async for msg in agen:
            ...             return msg
            ...     return (await get_one()).startswith("event: test")
            >>> asyncio.run(dummy_gen())
            True
        """
        # Create background tasks for each service subscription
        # This allows them to run concurrently
        tasks = [asyncio.create_task(stream_to_queue(gateway_service.subscribe_events(), "gateway")), asyncio.create_task(stream_to_queue(tool_service.subscribe_events(), "tool"))]

        try:
            while True:
                # Check for client disconnection
                if await request.is_disconnected():
                    LOGGER.debug("SSE Client disconnected")
                    break

                # Wait for the next event from EITHER service
                # We use asyncio.wait_for to allow checking request.is_disconnected periodically
                # or simply rely on queue.get() which is efficient.
                try:
                    # Wait for an event or send a keepalive to avoid idle timeouts
                    event = await asyncio.wait_for(event_queue.get(), timeout=heartbeat_interval)

                    # SSE format
                    event_type = event.get("type", "message")
                    event_data = orjson.dumps(event.get("data", {})).decode()

                    yield f"event: {event_type}\ndata: {event_data}\n\n"

                    # Mark task as done in queue (good practice)
                    event_queue.task_done()
                except asyncio.TimeoutError:
                    yield ": keepalive\n\n"

                except asyncio.CancelledError:
                    LOGGER.debug("SSE Event generator task cancelled")
                    raise

        except asyncio.CancelledError:
            LOGGER.debug("SSE Stream cancelled")
            raise
        except Exception as e:
            LOGGER.error(f"SSE Stream error: {e}")
        finally:
            # Cleanup: Cancel all background subscription tasks
            # This is crucial to close Redis connections/listeners in the EventService
            for task in tasks:
                task.cancel()

            # Wait for tasks to clean up
            await asyncio.gather(*tasks, return_exceptions=True)
            LOGGER.debug("Background event subscription tasks cleaned up")

    return StreamingResponse(
        event_generator(),
        media_type="text/event-stream",
        headers={
            "Cache-Control": "no-cache",
            "Connection": "keep-alive",
            "X-Accel-Buffering": "no",
        },
    )


####################
# Admin Tag Routes #
####################


@admin_router.get("/tags", response_model=PaginatedResponse)
@require_permission("tags.read", allow_admin_bypass=False)
async def admin_list_tags(
    entity_types: Optional[str] = None,
    include_entities: bool = False,
    db: Session = Depends(get_db),
    user=Depends(get_current_user_with_permissions),
) -> List[Dict[str, Any]]:
    """
    List all unique tags with statistics for the admin UI.

    Args:
        entity_types: Comma-separated list of entity types to filter by
                     (e.g., "tools,resources,prompts,servers,gateways").
                     If not provided, returns tags from all entity types.
        include_entities: Whether to include the list of entities that have each tag
        db: Database session
        user: Authenticated user

    Returns:
        List of tag information with statistics

    Raises:
        HTTPException: If tag retrieval fails

    Examples:
        >>> # Test function exists and has correct name
        >>> from mcpgateway.admin import admin_list_tags
        >>> admin_list_tags.__name__
        'admin_list_tags'
        >>> # Test it's a coroutine function
        >>> import inspect
        >>> inspect.iscoroutinefunction(admin_list_tags)
        True
    """
    tag_service = TagService()

    # Parse entity types parameter if provided
    entity_types_list = None
    if entity_types:
        entity_types_list = [et.strip().lower() for et in entity_types.split(",") if et.strip()]

    LOGGER.debug(f"Admin user {user} is retrieving tags for entity types: {entity_types_list}, include_entities: {include_entities}")

    try:
        user_email = user.get("email") if isinstance(user, dict) else None
        token_teams = user.get("token_teams") if isinstance(user, dict) else None
        is_admin = bool(user.get("is_admin")) if isinstance(user, dict) else False

        # Preserve admin bypass only when token/user context is unrestricted.
        if is_admin and token_teams is None:
            user_email = None

        tags = await tag_service.get_all_tags(
            db,
            entity_types=entity_types_list,
            include_entities=include_entities,
            user_email=user_email,
            token_teams=token_teams,
        )

        # Convert to list of dicts for admin UI
        result: List[Dict[str, Any]] = []
        for tag in tags:
            tag_dict: Dict[str, Any] = {
                "name": tag.name,
                "tools": tag.stats.tools,
                "resources": tag.stats.resources,
                "prompts": tag.stats.prompts,
                "servers": tag.stats.servers,
                "gateways": tag.stats.gateways,
                "total": tag.stats.total,
            }

            # Include entities if requested
            if include_entities and tag.entities:
                tag_dict["entities"] = [
                    {
                        "id": entity.id,
                        "name": entity.name,
                        "type": entity.type,
                        "description": entity.description,
                    }
                    for entity in tag.entities
                ]

            result.append(tag_dict)

        return result
    except Exception as e:
        LOGGER.error(f"Failed to retrieve tags for admin: {str(e)}")
        raise HTTPException(status_code=500, detail="Failed to retrieve tags")


async def _read_request_json(request: Request) -> Any:
    """Read JSON payload using orjson, falling back to request.json for mocks.

    Args:
        request: Incoming FastAPI request to read JSON from.

    Returns:
        Parsed JSON payload (dict/list/etc.).
    """
    body = await request.body()
    if isinstance(body, (bytes, bytearray, memoryview)):
        if body:
            return orjson.loads(body)
    elif isinstance(body, str) and body:
        return orjson.loads(body)
    return await request.json()


@admin_router.post("/tools/import/")
@admin_router.post("/tools/import")
@require_permission("tools.create", allow_admin_bypass=False)
@rate_limit(requests_per_minute=settings.mcpgateway_bulk_import_rate_limit)
async def admin_import_tools(
    request: Request,
    db: Session = Depends(get_db),
    user=Depends(get_current_user_with_permissions),
) -> JSONResponse:
    """Bulk import multiple tools in a single request.

    Accepts a JSON array of tool definitions and registers them individually.
    Provides per-item validation and error reporting without failing the entire batch.

    Args:
        request: FastAPI Request containing the tools data
        db: Database session
        user: Authenticated username

    Returns:
        JSONResponse with success status, counts, and details of created/failed tools

    Raises:
        HTTPException: For authentication or rate limiting failures
    """
    # Check if bulk import is enabled
    if not settings.mcpgateway_bulk_import_enabled:
        LOGGER.warning("Bulk import attempted but feature is disabled")
        raise HTTPException(status_code=403, detail="Bulk import feature is disabled. Enable MCPGATEWAY_BULK_IMPORT_ENABLED to use this endpoint.")

    LOGGER.debug("bulk tool import: user=%s", user)
    try:
        # ---------- robust payload parsing ----------
        ctype = (request.headers.get("content-type") or "").lower()
        if "application/json" in ctype:
            try:
                payload = await _read_request_json(request)
            except Exception as ex:
                LOGGER.exception("Invalid JSON body")
                return ORJSONResponse({"success": False, "message": f"Invalid JSON: {ex}"}, status_code=422)
        else:
            try:
                form = await request.form()
            except Exception as ex:
                LOGGER.exception("Invalid form body")
                return ORJSONResponse({"success": False, "message": f"Invalid form data: {ex}"}, status_code=422)
            # Check for file upload first
            if "tools_file" in form:
                file = form["tools_file"]
                if isinstance(file, StarletteUploadFile):
                    content = await file.read()
                    try:
                        payload = orjson.loads(content.decode("utf-8"))
                    except (orjson.JSONDecodeError, UnicodeDecodeError) as ex:
                        LOGGER.exception("Invalid JSON file")
                        return ORJSONResponse({"success": False, "message": f"Invalid JSON file: {ex}"}, status_code=422)
                else:
                    return ORJSONResponse({"success": False, "message": "Invalid file upload"}, status_code=422)
            else:
                # Check for JSON in form fields
                raw_val = form.get("tools") or form.get("tools_json") or form.get("json") or form.get("payload")
                raw = raw_val if isinstance(raw_val, str) else None
                if not raw:
                    return ORJSONResponse({"success": False, "message": "Missing tools/tools_json/json/payload form field."}, status_code=422)
                try:
                    payload = orjson.loads(raw)
                except Exception as ex:
                    LOGGER.exception("Invalid JSON in form field")
                    return ORJSONResponse({"success": False, "message": f"Invalid JSON: {ex}"}, status_code=422)

        if not isinstance(payload, list):
            return ORJSONResponse({"success": False, "message": "Payload must be a JSON array of tools."}, status_code=422)

        max_batch = settings.mcpgateway_bulk_import_max_tools
        if len(payload) > max_batch:
            return ORJSONResponse({"success": False, "message": f"Too many tools ({len(payload)}). Max {max_batch}."}, status_code=413)

        created, errors = [], []

        # ---------- import loop ----------
        # Generate import batch ID for this bulk operation
        import_batch_id = str(uuid.uuid4())

        # Extract base metadata for bulk import
        base_metadata = MetadataCapture.extract_creation_metadata(request, user, import_batch_id=import_batch_id)
        for i, item in enumerate(payload):
            name = (item or {}).get("name")
            try:
                tool = ToolCreate(**item)  # pydantic validation
                await tool_service.register_tool(
                    db,
                    tool,
                    created_by=base_metadata["created_by"],
                    created_from_ip=base_metadata["created_from_ip"],
                    created_via="import",  # Override to show this is bulk import
                    created_user_agent=base_metadata["created_user_agent"],
                    import_batch_id=import_batch_id,
                    federation_source=base_metadata["federation_source"],
                )
                created.append({"index": i, "name": name})
            except IntegrityError as ex:
                # The formatter can itself throw; guard it.
                try:
                    formatted = ErrorFormatter.format_database_error(ex)
                except Exception:
                    formatted = {"message": str(ex)}
                errors.append({"index": i, "name": name, "error": formatted})
            except (ValidationError, CoreValidationError) as ex:
                # Ditto: guard the formatter
                try:
                    formatted = ErrorFormatter.format_validation_error(ex)
                except Exception:
                    formatted = {"message": str(ex)}
                errors.append({"index": i, "name": name, "error": formatted})
            except ToolError as ex:
                errors.append({"index": i, "name": name, "error": {"message": str(ex)}})
            except Exception as ex:
                LOGGER.exception("Unexpected error importing tool %r at index %d", name, i)
                errors.append({"index": i, "name": name, "error": {"message": str(ex)}})

        # Format response to match both frontend and test expectations
        response_data = {
            "success": len(errors) == 0,
            # New format for frontend
            "imported": len(created),
            "failed": len(errors),
            "total": len(payload),
            # Original format for tests
            "created_count": len(created),
            "failed_count": len(errors),
            "created": created,
            "errors": errors,
            # Detailed format for frontend
            "details": {
                "success": [item["name"] for item in created if item.get("name")],
                "failed": [{"name": item["name"], "error": item["error"].get("message", str(item["error"]))} for item in errors],
            },
        }

        rd = typing_cast(Dict[str, Any], response_data)
        if len(errors) == 0:
            rd["message"] = f"Successfully imported all {len(created)} tools"
        else:
            rd["message"] = f"Imported {len(created)} of {len(payload)} tools. {len(errors)} failed."

        return ORJSONResponse(
            response_data,
            status_code=200,  # Always return 200, success field indicates if all succeeded
        )

    except HTTPException:
        # let FastAPI semantics (e.g., auth) pass through
        raise
    except Exception as ex:
        # absolute catch-all: report instead of crashing
        LOGGER.exception("Fatal error in admin_import_tools")
        return ORJSONResponse({"success": False, "message": str(ex)}, status_code=500)


####################
# Log Endpoints
####################


@admin_router.get("/logs")
@require_permission("admin.system_config", allow_admin_bypass=False)
async def admin_get_logs(
    entity_type: Optional[str] = None,
    entity_id: Optional[str] = None,
    level: Optional[str] = None,
    start_time: Optional[str] = None,
    end_time: Optional[str] = None,
    request_id: Optional[str] = None,
    search: Optional[str] = None,
    limit: int = 100,
    offset: int = 0,
    order: str = "desc",
    user=Depends(get_current_user_with_permissions),  # pylint: disable=unused-argument
    _db: Session = Depends(get_db),
) -> Dict[str, Any]:
    """Get filtered log entries from the in-memory buffer.

    Args:
        entity_type: Filter by entity type (tool, resource, server, gateway)
        entity_id: Filter by entity ID
        level: Minimum log level (debug, info, warning, error, critical)
        start_time: ISO format start time
        end_time: ISO format end time
        request_id: Filter by request ID
        search: Search in message text
        limit: Maximum number of results (default 100, max 1000)
        offset: Number of results to skip
        order: Sort order (asc or desc)
        user: Authenticated user
        _db: Database session for permission checks.

    Returns:
        Dictionary with logs and metadata

    Raises:
        HTTPException: If validation fails or service unavailable
    """
    # Get log storage from logging service
    storage = typing_cast(Any, logging_service).get_storage()
    if not storage:
        return {"logs": [], "total": 0, "stats": {}}

    # Parse timestamps if provided
    start_dt = None
    end_dt = None
    if start_time:
        try:
            start_dt = datetime.fromisoformat(start_time.replace("Z", "+00:00"))
        except ValueError:
            raise HTTPException(400, f"Invalid start_time format: {start_time}")

    if end_time:
        try:
            end_dt = datetime.fromisoformat(end_time.replace("Z", "+00:00"))
        except ValueError:
            raise HTTPException(400, f"Invalid end_time format: {end_time}")

    # Parse log level
    log_level = None
    if level:
        try:
            log_level = LogLevel(level.lower())
        except ValueError:
            raise HTTPException(400, f"Invalid log level: {level}")

    # Limit max results
    limit = min(limit, 1000)

    # Get filtered logs
    logs = await storage.get_logs(
        entity_type=entity_type,
        entity_id=entity_id,
        level=log_level,
        start_time=start_dt,
        end_time=end_dt,
        request_id=request_id,
        search=search,
        limit=limit,
        offset=offset,
        order=order,
    )

    # Get statistics
    stats = storage.get_stats()

    return {
        "logs": logs,
        "total": stats.get("total_logs", 0),
        "stats": stats,
    }


@admin_router.get("/logs/stream")
@require_permission("admin.system_config", allow_admin_bypass=False)
async def admin_stream_logs(
    request: Request,
    entity_type: Optional[str] = None,
    entity_id: Optional[str] = None,
    level: Optional[str] = None,
    user=Depends(get_current_user_with_permissions),  # pylint: disable=unused-argument
    _db: Session = Depends(get_db),
):
    """Stream real-time log updates via Server-Sent Events.

    Args:
        request: FastAPI request object
        entity_type: Filter by entity type
        entity_id: Filter by entity ID
        level: Minimum log level
        user: Authenticated user
        _db: Database session for permission checks.

    Returns:
        SSE response with real-time log updates

    Raises:
        HTTPException: If log level is invalid or service unavailable
    """
    # Get log storage from logging service
    storage = typing_cast(Any, logging_service).get_storage()
    if not storage:
        raise HTTPException(503, "Log storage not available")

    # Parse log level filter
    min_level = None
    if level:
        try:
            min_level = LogLevel(level.lower())
        except ValueError:
            raise HTTPException(400, f"Invalid log level: {level}")

    async def generate():
        """Generate SSE events for log streaming.

        Yields:
            Formatted SSE events containing log data
        """
        try:
            async for event in storage.subscribe():
                # Check if client disconnected
                if await request.is_disconnected():
                    break

                # Apply filters
                log_data = event.get("data", {})

                # Entity type filter
                if entity_type and log_data.get("entity_type") != entity_type:
                    continue

                # Entity ID filter
                if entity_id and log_data.get("entity_id") != entity_id:
                    continue

                # Level filter
                if min_level:
                    log_level = log_data.get("level")
                    if log_level:
                        try:
                            if not storage._meets_level_threshold(LogLevel(log_level), min_level):  # pylint: disable=protected-access
                                continue
                        except ValueError:
                            continue

                # Send SSE event
                yield f"data: {orjson.dumps(event).decode()}\n\n"

        except Exception as e:
            LOGGER.error(f"Error in log streaming: {e}")
            yield f"event: error\ndata: {orjson.dumps({'error': str(e)}).decode()}\n\n"

    return StreamingResponse(
        generate(),
        media_type="text/event-stream",
        headers={
            "Cache-Control": "no-cache",
            "X-Accel-Buffering": "no",  # Disable Nginx buffering
        },
    )


@admin_router.get("/logs/file")
@require_permission("admin.system_config", allow_admin_bypass=False)
async def admin_get_log_file(
    filename: Optional[str] = None,
    user=Depends(get_current_user_with_permissions),  # pylint: disable=unused-argument
    _db: Session = Depends(get_db),
):
    """Download log file.

    Args:
        filename: Specific log file to download (optional)
        user: Authenticated user
        _db: Database session for permission checks.

    Returns:
        File download response or list of available files

    Raises:
        HTTPException: If file doesn't exist or access denied
    """
    # Check if file logging is enabled
    if not settings.log_to_file or not settings.log_file:
        raise HTTPException(404, "File logging is not enabled")

    # Determine log directory
    log_dir = Path(settings.log_folder) if settings.log_folder else Path(".")

    if filename:
        # Download specific file
        file_path = log_dir / filename

        # Security: Ensure file is within log directory
        try:
            file_path = file_path.resolve()
            log_dir_resolved = log_dir.resolve()
            if not str(file_path).startswith(str(log_dir_resolved)):
                raise HTTPException(403, _ACCESS_DENIED_MSG)
        except Exception:
            raise HTTPException(400, "Invalid file path")

        # Check if file exists
        if not file_path.exists() or not file_path.is_file():
            raise HTTPException(404, f"Log file not found: {filename}")

        # Check if it's a log file
        if not (file_path.suffix in [".log", ".jsonl", ".json"] or file_path.stem.startswith(Path(settings.log_file).stem)):
            raise HTTPException(403, "Not a log file")

        # Return file for download using FileResponse (streams asynchronously)
        # Pre-stat the file to catch issues early and provide Content-Length
        try:
            file_stat = file_path.stat()
            LOGGER.info(f"Serving log file download: {file_path.name} ({file_stat.st_size} bytes)")
            return FileResponse(
                path=file_path,
                media_type="application/octet-stream",
                filename=file_path.name,
                stat_result=file_stat,
            )
        except FileNotFoundError:
            LOGGER.error(f"Log file disappeared before streaming: {filename}")
            raise HTTPException(404, f"Log file not found: {filename}")
        except Exception as e:
            LOGGER.error(f"Error preparing file for download: {e}")
            raise HTTPException(500, f"Error reading file for download: {e}")

    # List available log files
    log_files = []

    try:
        # Main log file
        main_log = log_dir / settings.log_file
        if main_log.exists():
            stat = main_log.stat()
            log_files.append(
                {
                    "name": main_log.name,
                    "size": stat.st_size,
                    "modified": datetime.fromtimestamp(stat.st_mtime).isoformat(),
                    "type": "main",
                }
            )

            # Rotated log files
            if settings.log_rotation_enabled:
                pattern = f"{Path(settings.log_file).stem}.*"
                for file in log_dir.glob(pattern):
                    if file.is_file() and file.name != main_log.name:  # Exclude main log file
                        stat = file.stat()
                        log_files.append(
                            {
                                "name": file.name,
                                "size": stat.st_size,
                                "modified": datetime.fromtimestamp(stat.st_mtime).isoformat(),
                                "type": "rotated",
                            }
                        )

            # Storage log file (JSON lines)
            storage_log = log_dir / f"{Path(settings.log_file).stem}_storage.jsonl"
            if storage_log.exists():
                stat = storage_log.stat()
                log_files.append(
                    {
                        "name": storage_log.name,
                        "size": stat.st_size,
                        "modified": datetime.fromtimestamp(stat.st_mtime).isoformat(),
                        "type": "storage",
                    }
                )

        # Sort by modified time (newest first)
        log_files.sort(key=lambda x: x["modified"], reverse=True)

    except Exception as e:
        LOGGER.error(f"Error listing log files: {e}")
        raise HTTPException(500, f"Error listing log files: {e}")

    return {
        "log_directory": str(log_dir),
        "files": log_files,
        "total": len(log_files),
    }


@admin_router.get("/logs/export")
@require_permission("admin.system_config", allow_admin_bypass=False)
async def admin_export_logs(
    export_format: QueryExportFormatAliased = "json",
    entity_type: Optional[str] = None,
    entity_id: Optional[str] = None,
    level: Optional[str] = None,
    start_time: Optional[str] = None,
    end_time: Optional[str] = None,
    request_id: Optional[str] = None,
    search: Optional[str] = None,
    user=Depends(get_current_user_with_permissions),  # pylint: disable=unused-argument
    _db: Session = Depends(get_db),
):
    """Export filtered logs in JSON or CSV format.

    Args:
        export_format: Export format (json or csv)
        entity_type: Filter by entity type
        entity_id: Filter by entity ID
        level: Minimum log level
        start_time: ISO format start time
        end_time: ISO format end time
        request_id: Filter by request ID
        search: Search in message text
        user: Authenticated user
        _db: Database session for permission checks.

    Returns:
        File download response with exported logs

    Raises:
        HTTPException: If validation fails or export format invalid
    """
    # Standard
    # Validate format
    if export_format not in ["json", "csv"]:
        raise HTTPException(400, f"Invalid format: {export_format}. Use 'json' or 'csv'")

    # Get log storage from logging service
    storage = typing_cast(Any, logging_service).get_storage()
    if not storage:
        raise HTTPException(503, "Log storage not available")

    # Parse timestamps if provided
    start_dt = None
    end_dt = None
    if start_time:
        try:
            start_dt = datetime.fromisoformat(start_time.replace("Z", "+00:00"))
        except ValueError:
            raise HTTPException(400, f"Invalid start_time format: {start_time}")

    if end_time:
        try:
            end_dt = datetime.fromisoformat(end_time.replace("Z", "+00:00"))
        except ValueError:
            raise HTTPException(400, f"Invalid end_time format: {end_time}")

    # Parse log level
    log_level = None
    if level:
        try:
            log_level = LogLevel(level.lower())
        except ValueError:
            raise HTTPException(400, f"Invalid log level: {level}")

    # Get all matching logs (no pagination for export)
    logs = await storage.get_logs(
        entity_type=entity_type,
        entity_id=entity_id,
        level=log_level,
        start_time=start_dt,
        end_time=end_dt,
        request_id=request_id,
        search=search,
        limit=10000,  # Reasonable max for export
        offset=0,
        order="desc",
    )

    # Generate filename
    timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
    filename = f"logs_export_{timestamp}.{export_format}"

    if export_format == "json":
        # Export as JSON
        content = orjson.dumps(logs, default=str, option=orjson.OPT_INDENT_2).decode()
        return Response(
            content=content,
            media_type="application/json",
            headers={
                "Content-Disposition": f'attachment; filename="{filename}"',
            },
        )

    # CSV format
    # Create CSV content
    output = io.StringIO()

    if logs:
        # Use first log to determine columns
        fieldnames = [
            "timestamp",
            "level",
            "entity_type",
            "entity_id",
            "entity_name",
            "message",
            "logger",
            "request_id",
        ]

        writer = csv.DictWriter(output, fieldnames=fieldnames, extrasaction="ignore")
        writer.writeheader()

        for log in logs:
            # Flatten the log entry for CSV
            row = {k: log.get(k, "") for k in fieldnames}
            writer.writerow(row)

    content = output.getvalue()

    return Response(
        content=content,
        media_type="text/csv",
        headers={
            "Content-Disposition": f'attachment; filename="{filename}"',
        },
    )


@admin_router.get("/export/configuration")
@require_permission("admin.system_config", allow_admin_bypass=False)
async def admin_export_configuration(
    request: Request,  # pylint: disable=unused-argument
    types: Optional[str] = None,
    exclude_types: Optional[str] = None,
    tags: Optional[str] = None,
    include_inactive: bool = False,
    include_dependencies: bool = True,
    db: Session = Depends(get_db),
    user=Depends(get_current_user_with_permissions),
):
    """
    Export gateway configuration via Admin UI.

    Args:
        request: FastAPI request object for extracting root path
        types: Comma-separated entity types to include
        exclude_types: Comma-separated entity types to exclude
        tags: Comma-separated tags to filter by
        include_inactive: Include inactive entities
        include_dependencies: Include dependent entities
        db: Database session
        user: Authenticated user

    Returns:
        JSON file download with configuration export

    Raises:
        HTTPException: If export fails
    """
    try:
        LOGGER.info(f"Admin user {user} requested configuration export")

        # Parse parameters
        include_types = None
        if types:
            include_types = [t.strip() for t in types.split(",") if t.strip()]

        exclude_types_list = None
        if exclude_types:
            exclude_types_list = [t.strip() for t in exclude_types.split(",") if t.strip()]

        tags_list = None
        if tags:
            tags_list = [t.strip() for t in tags.split(",") if t.strip()]

        # Extract username from user (which could be string or dict with token)
        username = user if isinstance(user, str) else user.get("username", "unknown")

        # Get root path for URL construction - prefer configured APP_ROOT_PATH
        root_path = settings.app_root_path

        # Perform export
        export_data = await export_service.export_configuration(
            db=db,
            include_types=include_types,
            exclude_types=exclude_types_list,
            tags=tags_list,
            include_inactive=include_inactive,
            include_dependencies=include_dependencies,
            exported_by=username,
            root_path=root_path,
        )

        # Generate filename
        timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
        filename = f"mcpgateway-config-export-{timestamp}.json"

        # Return as downloadable file
        content = orjson.dumps(export_data, option=orjson.OPT_INDENT_2).decode()
        return Response(
            content=content,
            media_type="application/json",
            headers={
                "Content-Disposition": f'attachment; filename="{filename}"',
            },
        )

    except ExportError as e:
        LOGGER.error(f"Admin export failed for user {user}: {str(e)}")
        raise HTTPException(status_code=400, detail=str(e))
    except Exception as e:
        LOGGER.error(f"Unexpected admin export error for user {user}: {str(e)}")
        raise HTTPException(status_code=500, detail="Export failed")


@admin_router.post("/export/selective")
@require_permission("admin.system_config", allow_admin_bypass=False)
async def admin_export_selective(request: Request, db: Session = Depends(get_db), user=Depends(get_current_user_with_permissions)):
    """
    Export selected entities via Admin UI with entity selection.

    Args:
        request: FastAPI request object
        db: Database session
        user: Authenticated user

    Returns:
        JSON file download with selective export data

    Raises:
        HTTPException: If export fails

    Expects JSON body with entity selections:
    {
        "entity_selections": {
            "tools": ["tool1", "tool2"],
            "servers": ["server1"]
        },
        "include_dependencies": true
    }
    """
    try:
        LOGGER.info(f"Admin user {user} requested selective configuration export")

        body = await _read_request_json(request)
        entity_selections = body.get("entity_selections", {})
        include_dependencies = body.get("include_dependencies", True)

        # Extract username from user (which could be string or dict with token)
        username = user if isinstance(user, str) else user.get("username", "unknown")

        # Get root path for URL construction - prefer configured APP_ROOT_PATH
        root_path = settings.app_root_path

        # Perform selective export
        export_data = await export_service.export_selective(db=db, entity_selections=entity_selections, include_dependencies=include_dependencies, exported_by=username, root_path=root_path)

        # Generate filename
        timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
        filename = f"mcpgateway-selective-export-{timestamp}.json"

        # Return as downloadable file
        content = orjson.dumps(export_data, option=orjson.OPT_INDENT_2).decode()
        return Response(
            content=content,
            media_type="application/json",
            headers={
                "Content-Disposition": f'attachment; filename="{filename}"',
            },
        )

    except ExportError as e:
        LOGGER.error(f"Admin selective export failed for user {user}: {str(e)}")
        raise HTTPException(status_code=400, detail=str(e))
    except Exception as e:
        LOGGER.error(f"Unexpected admin selective export error for user {user}: {str(e)}")
        raise HTTPException(status_code=500, detail="Export failed")


@admin_router.post("/import/preview")
@require_permission("admin.system_config", allow_admin_bypass=False)
async def admin_import_preview(request: Request, db: Session = Depends(get_db), user=Depends(get_current_user_with_permissions)):
    """
    Preview import file to show available items for selective import.

    Args:
        request: FastAPI request object with import file data
        db: Database session
        user: Authenticated user

    Returns:
        JSON response with categorized import preview data

    Raises:
        HTTPException: 400 for invalid JSON or missing data field, validation errors;
                      500 for unexpected preview failures

    Expects JSON body:
    {
        "data": { ... }  // The import file content
    }
    """
    try:
        LOGGER.info(f"Admin import preview requested by user: {user}")

        # Parse request data
        try:
            data = await _read_request_json(request)
        except ValueError:
            raise HTTPException(status_code=400, detail="Invalid JSON in request body")

        # Extract import data
        import_data = data.get("data")
        if not import_data:
            raise HTTPException(status_code=400, detail="Missing 'data' field with import content")

        # Validate user permissions for import preview
        username = user if isinstance(user, str) else user.get("username", "unknown")
        LOGGER.info(f"Processing import preview for user: {username}")

        # Generate preview
        preview_data = await import_service.preview_import(db=db, import_data=import_data)

        return ORJSONResponse(content={"success": True, "preview": preview_data, "message": f"Import preview generated. Found {preview_data['summary']['total_items']} total items."})

    except ImportValidationError as e:
        LOGGER.error(f"Import validation failed for user {user}: {str(e)}")
        raise HTTPException(status_code=400, detail="Invalid import data")
    except HTTPException:
        raise
    except Exception as e:
        LOGGER.error(f"Import preview failed for user {user}: {str(e)}")
        raise HTTPException(status_code=500, detail="Preview failed")


@admin_router.post("/import/configuration")
@require_permission("admin.system_config", allow_admin_bypass=False)
async def admin_import_configuration(request: Request, db: Session = Depends(get_db), user=Depends(get_current_user_with_permissions)):
    """
    Import configuration via Admin UI.

    Args:
        request: FastAPI request object
        db: Database session
        user: Authenticated user

    Returns:
        JSON response with import status

    Raises:
        HTTPException: If import fails

    Expects JSON body with import data and options:
    {
        "import_data": { ... },
        "conflict_strategy": "update",
        "dry_run": false,
        "rekey_secret": "optional-new-secret",
        "selected_entities": { ... }
    }
    """
    try:
        LOGGER.info(f"Admin user {user} requested configuration import")

        body = await _read_request_json(request)
        import_data = body.get("import_data")
        if not import_data:
            raise HTTPException(status_code=400, detail="Missing import_data in request body")

        conflict_strategy_str = body.get("conflict_strategy", "update")
        dry_run = body.get("dry_run", False)
        rekey_secret = body.get("rekey_secret")
        selected_entities = body.get("selected_entities")

        # Validate conflict strategy
        try:
            conflict_strategy = ConflictStrategy(conflict_strategy_str.lower())
        except ValueError:
            allowed = [s.value for s in ConflictStrategy.__members__.values()]
            raise HTTPException(status_code=400, detail=f"Invalid conflict strategy. Must be one of: {allowed}")

        # Extract username from user (which could be string or dict with token)
        username = user if isinstance(user, str) else user.get("username", "unknown")

        # Perform import
        status = await import_service.import_configuration(
            db=db, import_data=import_data, conflict_strategy=conflict_strategy, dry_run=dry_run, rekey_secret=rekey_secret, imported_by=username, selected_entities=selected_entities
        )

        return ORJSONResponse(content=status.to_dict())

    except ImportServiceError as e:
        LOGGER.error(f"Admin import failed for user {user}: {str(e)}")
        raise HTTPException(status_code=400, detail="Import failed")
    except HTTPException:
        raise
    except Exception as e:
        LOGGER.error(f"Unexpected admin import error for user {user}: {str(e)}")
        raise HTTPException(status_code=500, detail="Import failed")


@admin_router.get("/import/status/{import_id}")
@require_permission("admin.system_config", allow_admin_bypass=False)
async def admin_get_import_status(import_id: str, user=Depends(get_current_user_with_permissions), _db: Session = Depends(get_db)):
    """Get import status via Admin UI.

    Args:
        import_id: Import operation ID
        user: Authenticated user
        _db: Database session for permission checks.

    Returns:
        JSON response with import status

    Raises:
        HTTPException: If import not found
    """
    LOGGER.debug(f"Admin user {user} requested import status for {import_id}")

    status = import_service.get_import_status(import_id)
    if not status:
        raise HTTPException(status_code=404, detail=f"Import {import_id} not found")

    return ORJSONResponse(content=status.to_dict())


@admin_router.get("/import/status")
@require_permission("admin.system_config", allow_admin_bypass=False)
async def admin_list_import_statuses(user=Depends(get_current_user_with_permissions), _db: Session = Depends(get_db)):
    """List all import statuses via Admin UI.

    Args:
        user: Authenticated user
        _db: Database session for permission checks.

    Returns:
        JSON response with list of import statuses
    """
    LOGGER.debug(f"Admin user {user} requested all import statuses")

    statuses = import_service.list_import_statuses()
    return ORJSONResponse(content=[status.to_dict() for status in statuses])


# ============================================================================ #
#                             A2A AGENT ADMIN ROUTES                          #
# ============================================================================ #


@admin_router.get("/a2a/{agent_id}", response_model=A2AAgentRead)
@require_permission("a2a.read", allow_admin_bypass=False)
async def admin_get_agent(
    agent_id: str,
    db: Session = Depends(get_db),
    user=Depends(get_current_user_with_permissions),
) -> Dict[str, Any]:
    """Get A2A agent details for the admin UI.

    Args:
        agent_id: Agent ID.
        db: Database session.
        user: Authenticated user.

    Returns:
        Agent details.

    Raises:
        HTTPException: If the agent is not found.
        Exception: For any other unexpected errors.

    Examples:
        >>> callable(admin_get_agent)
        True
        >>> admin_get_agent.__name__
        'admin_get_agent'
    """
    LOGGER.debug(f"User {get_user_email(user)} requested details for agent ID {agent_id}")
    try:
        agent = await a2a_service.get_agent(db, agent_id)
        return agent.model_dump(by_alias=True)
    except A2AAgentNotFoundError as e:
        raise HTTPException(status_code=404, detail=str(e))
    except Exception as e:
        LOGGER.error(f"Error getting agent {agent_id}: {e}")
        raise e


@admin_router.get("/a2a", response_model=PaginatedResponse)
@require_permission("a2a.read", allow_admin_bypass=False)
async def admin_list_a2a_agents(
    page: int = Query(1, ge=1, description="Page number (1-indexed)"),
    per_page: int = Query(settings.pagination_default_page_size, ge=1, le=settings.pagination_max_page_size, description="Items per page"),
    include_inactive: bool = False,
    db: Session = Depends(get_db),
    user=Depends(get_current_user_with_permissions),
) -> Dict[str, Any]:
    """
    List A2A Agents for the admin UI with pagination support.

    This endpoint retrieves a paginated list of A2A (Agent-to-Agent) agents associated with
    the current user. Administrators can optionally include inactive agents for
    management or auditing purposes. Uses offset-based (page/per_page) pagination.

    Args:
        page (int): Page number (1-indexed) for offset pagination.
        per_page (int): Number of items per page.
        include_inactive (bool): Whether to include inactive agents in the results.
        db (Session): Database session dependency.
        user (dict): Authenticated user dependency.

    Returns:
        Dict[str, Any]: A dictionary containing:
            - data: List of A2A agent records formatted with by_alias=True
            - pagination: Pagination metadata
            - links: Pagination links (optional)

    Raises:
        HTTPException (500): If an error occurs while retrieving the agent list.

    Examples:
        >>> callable(admin_list_a2a_agents)
        True
        >>> admin_list_a2a_agents.__name__
        'admin_list_a2a_agents'
    """
    if a2a_service is None:
        LOGGER.warning("A2A features are disabled, returning empty paginated response")
        # First-Party

        return {
            "data": [],
            "pagination": PaginationMeta(page=page, per_page=per_page, total_items=0, total_pages=0, has_next=False, has_prev=False).model_dump(),
            "links": None,
        }

    LOGGER.debug(f"User {get_user_email(user)} requested A2A Agent list (page={page}, per_page={per_page})")
    user_email = get_user_email(user)

    # Call a2a_service.list_agents with page-based pagination
    paginated_result = await a2a_service.list_agents(
        db=db,
        include_inactive=include_inactive,
        page=page,
        per_page=per_page,
        user_email=user_email,
    )

    # Return standardized paginated response
    return {
        "data": [agent.model_dump(by_alias=True) for agent in paginated_result["data"]],
        "pagination": paginated_result["pagination"].model_dump(),
        "links": paginated_result["links"].model_dump() if paginated_result["links"] else None,
    }


@admin_router.post("/a2a")
@require_permission("a2a.create", allow_admin_bypass=False)
async def admin_add_a2a_agent(
    request: Request,
    db: Session = Depends(get_db),
    user=Depends(get_current_user_with_permissions),
) -> JSONResponse:
    """Add a new A2A agent via admin UI.

    Args:
        request: FastAPI request object
        db: Database session
        user: Authenticated user

    Returns:
        JSONResponse with success/error status

    Raises:
        HTTPException: If A2A features are disabled
    """
    LOGGER.info(f"A2A agent creation request from user {user}")

    if not a2a_service or not settings.mcpgateway_a2a_enabled:
        LOGGER.warning("A2A agent creation attempted but A2A features are disabled")
        return ORJSONResponse(
            content={"message": "A2A features are disabled!", "success": False},
            status_code=403,
        )

    form = await request.form()
    team_id = _form_team_id(form)
    try:
        LOGGER.info(f"A2A agent creation form data: {dict(form)}")

        _check_public_visibility_allowed(str(form.get("visibility", "private")), team_id=team_id)

        user_email = get_user_email(user)
        team_service = TeamManagementService(db)
        team_id = await team_service.verify_team_for_user(user_email, team_id)

        # Process tags
        ts_val = form.get("tags", "")
        tags_str = ts_val if isinstance(ts_val, str) else ""
        tags = [tag.strip() for tag in tags_str.split(",") if tag.strip()] if tags_str else []

        # Parse auth_headers JSON if present
        auth_headers_json = form.get("auth_headers") or ""
        auth_headers: list[dict[str, Any]] = []
        if auth_headers_json:
            try:
                auth_headers = orjson.loads(auth_headers_json)
            except (orjson.JSONDecodeError, ValueError):
                auth_headers = []

        # Parse OAuth configuration - support both JSON string and individual form fields
        oauth_config_json = str(form.get("oauth_config"))
        oauth_config: Optional[dict[str, Any]] = None

        LOGGER.info(f"DEBUG: oauth_config_json from form = '{oauth_config_json}'")
        LOGGER.info(f"DEBUG: Individual OAuth fields - grant_type='{form.get('oauth_grant_type')}', issuer='{form.get('oauth_issuer')}'")

        # Option 1: Pre-assembled oauth_config JSON (from API calls)
        if oauth_config_json and oauth_config_json != "None":
            try:
                oauth_config = orjson.loads(oauth_config_json)
                # Encrypt the client secret if present
                if oauth_config and "client_secret" in oauth_config:
                    encryption = get_encryption_service(settings.auth_encryption_secret)
                    oauth_config["client_secret"] = await encryption.encrypt_secret_async(oauth_config["client_secret"])
            except (orjson.JSONDecodeError, ValueError) as e:
                LOGGER.error(f"Failed to parse OAuth config: {e}")
                oauth_config = None

        # Option 2: Assemble from individual UI form fields
        if not oauth_config:
            oauth_grant_type = str(form.get("oauth_grant_type", ""))
            oauth_issuer = str(form.get("oauth_issuer", ""))
            oauth_token_url = str(form.get("oauth_token_url", ""))
            oauth_authorization_url = str(form.get("oauth_authorization_url", ""))
            oauth_redirect_uri = str(form.get("oauth_redirect_uri", ""))
            oauth_client_id = str(form.get("oauth_client_id", ""))
            oauth_client_secret = str(form.get("oauth_client_secret", ""))
            oauth_username = str(form.get("oauth_username", ""))
            oauth_password = str(form.get("oauth_password", ""))
            oauth_scopes_str = str(form.get("oauth_scopes", ""))

            # If any OAuth field is provided, assemble oauth_config
            if any([oauth_grant_type, oauth_issuer, oauth_token_url, oauth_authorization_url, oauth_client_id]):
                oauth_config = {}

                if oauth_grant_type:
                    oauth_config["grant_type"] = oauth_grant_type
                if oauth_issuer:
                    oauth_config["issuer"] = oauth_issuer
                if oauth_token_url:
                    oauth_config["token_url"] = oauth_token_url  # OAuthManager expects 'token_url', not 'token_endpoint'
                if oauth_authorization_url:
                    oauth_config["authorization_url"] = oauth_authorization_url  # OAuthManager expects 'authorization_url', not 'authorization_endpoint'
                if oauth_redirect_uri:
                    oauth_config["redirect_uri"] = oauth_redirect_uri
                if oauth_client_id:
                    oauth_config["client_id"] = oauth_client_id
                if oauth_client_secret:
                    # Encrypt the client secret
                    encryption = get_encryption_service(settings.auth_encryption_secret)
                    oauth_config["client_secret"] = await encryption.encrypt_secret_async(oauth_client_secret)

                # Add username and password for password grant type
                if oauth_username:
                    oauth_config["username"] = oauth_username
                if oauth_password:
                    oauth_config["password"] = oauth_password

                # Parse scopes (comma or space separated)
                if oauth_scopes_str:
                    scopes = [s.strip() for s in oauth_scopes_str.replace(",", " ").split() if s.strip()]
                    if scopes:
                        oauth_config["scopes"] = scopes

                LOGGER.info(f"✅ Assembled OAuth config from UI form fields: grant_type={oauth_grant_type}, issuer={oauth_issuer}")
                LOGGER.info(f"DEBUG: Complete oauth_config = {oauth_config}")

        passthrough_headers = str(form.get("passthrough_headers"))
        if passthrough_headers and passthrough_headers.strip():
            try:
                passthrough_headers = orjson.loads(passthrough_headers)
            except (orjson.JSONDecodeError, ValueError):
                # Fallback to comma-separated parsing
                passthrough_headers = [h.strip() for h in passthrough_headers.split(",") if h.strip()]
        else:
            passthrough_headers = None

        # Auto-detect OAuth: if oauth_config is present and auth_type not explicitly set, use "oauth"
        auth_type_from_form = str(form.get("auth_type", ""))
        LOGGER.info(f"DEBUG: auth_type from form: '{auth_type_from_form}', oauth_config present: {oauth_config is not None}")
        if oauth_config and not auth_type_from_form:
            auth_type_from_form = "oauth"
            LOGGER.info("✅ Auto-detected OAuth configuration, setting auth_type='oauth'")
        elif oauth_config and auth_type_from_form:
            LOGGER.info(f"✅ OAuth config present with explicit auth_type='{auth_type_from_form}'")

        # Extract UAID fields from form
        generate_uaid = form.get("generate_uaid") == "true"  # Checkbox sends "true" string
        uaid_registry = str(form.get("uaid_registry", "context-forge"))
        uaid_protocol = str(form.get("uaid_protocol", "a2a"))
        uaid_native_id_override = form.get("uaid_native_id_override") or None

        agent_data = A2AAgentCreate(
            name=form["name"],
            description=form.get("description"),
            endpoint_url=form["endpoint_url"],
            agent_type=form.get("agent_type", "generic"),
            auth_type=auth_type_from_form,
            auth_username=str(form.get("auth_username", "")),
            auth_password=str(form.get("auth_password", "")),
            auth_token=str(form.get("auth_token", "")),
            auth_header_key=str(form.get("auth_header_key", "")),
            auth_header_value=str(form.get("auth_header_value", "")),
            auth_headers=auth_headers if auth_headers else None,
            oauth_config=oauth_config,
            auth_value=form.get("auth_value") if form.get("auth_value") else None,
            auth_query_param_key=str(form.get("auth_query_param_key", "")) or None,
            auth_query_param_value=str(form.get("auth_query_param_value", "")) or None,
            tags=tags,
            visibility=form.get("visibility", "private"),
            team_id=team_id,
            owner_email=user_email,
            passthrough_headers=passthrough_headers,
            # UAID fields for cross-gateway routing
            generate_uaid=generate_uaid,
            uaid_registry=uaid_registry if generate_uaid else None,
            uaid_protocol=uaid_protocol if generate_uaid else None,
            uaid_native_id_override=uaid_native_id_override if generate_uaid else None,
        )

        LOGGER.info(f"Creating A2A agent: {agent_data.name} at {agent_data.endpoint_url}")

        # Extract metadata from request
        metadata = MetadataCapture.extract_creation_metadata(request, user)

        await a2a_service.register_agent(
            db,
            agent_data,
            created_by=metadata["created_by"],
            created_from_ip=metadata["created_from_ip"],
            created_via=metadata["created_via"],
            created_user_agent=metadata["created_user_agent"],
            import_batch_id=metadata["import_batch_id"],
            federation_source=metadata["federation_source"],
            team_id=team_id,
            owner_email=user_email,
            visibility=form.get("visibility", "private"),
        )

        return ORJSONResponse(
            content={"message": "A2A agent created successfully!", "success": True},
            status_code=200,
        )

    except CoreValidationError as ex:
        return ORJSONResponse(content={"message": str(ex), "success": False}, status_code=422)
    except A2AAgentNameConflictError as ex:
        LOGGER.error(f"A2A agent name conflict: {ex}")
        return ORJSONResponse(content={"message": str(ex), "success": False}, status_code=409)
    except A2AAgentError as ex:
        LOGGER.error(f"A2A agent error: {ex}")
        return ORJSONResponse(content={"message": str(ex), "success": False}, status_code=500)
    except IntegrityError as ex:
        return ORJSONResponse(
            content=ErrorFormatter.format_database_error(ex),
            status_code=409,
        )
    except HTTPException:
        raise
    except Exception as ex:
        LOGGER.exception(f"Unexpected error creating A2A agent: {ex}")
        return ORJSONResponse(content={"message": "An unexpected error occurred. Please try again or contact support.", "success": False}, status_code=500)


@admin_router.post("/a2a/{agent_id}/edit")
@require_permission("a2a.update", allow_admin_bypass=False)
async def admin_edit_a2a_agent(
    agent_id: str,
    request: Request,
    db: Session = Depends(get_db),
    user=Depends(get_current_user_with_permissions),
) -> JSONResponse:
    """
    Edit an existing A2A agent via the admin UI.

    Expects form fields:
      - name
      - description (optional)
      - endpoint_url
      - agent_type
      - tags (optional, comma-separated)
      - auth_type (optional)
      - auth_username (optional)
      - auth_password (optional)
      - auth_token (optional)
      - auth_header_key / auth_header_value (optional)
      - auth_headers (JSON array, optional)
      - oauth_config (JSON string or individual OAuth fields)
      - visibility (optional)
      - team_id (optional)
      - capabilities (JSON, optional)
      - config (JSON, optional)
      - passthrough_headers: Optional[List[str]]

    Args:
        agent_id (str): The ID of the agent being edited.
        request (Request): The incoming FastAPI request containing form data.
        db (Session): Active database session.
        user: The authenticated admin user performing the edit.

    Returns:
        JSONResponse: A JSON response indicating success or failure.

    Raises:
        HTTPException: 422 when public visibility is disabled and request is team-scoped.

    Examples:
        >>> callable(admin_edit_a2a_agent)
        True
        >>> admin_edit_a2a_agent.__name__
        'admin_edit_a2a_agent'
    """

    try:
        form = await request.form()
        team_id = _form_team_id(form)

        # Normalize tags
        tags_raw = str(form.get("tags", ""))
        tags = [t.strip() for t in tags_raw.split(",") if t.strip()] if tags_raw else []

        # Visibility
        visibility = str(form.get("visibility", "private"))
        _check_public_visibility_allowed(visibility, team_id=team_id)

        # Agent Type
        agent_type = str(form.get("agent_type", "generic"))

        # Capabilities
        raw_capabilities = form.get("capabilities")
        capabilities = {}
        if raw_capabilities:
            try:
                capabilities = orjson.loads(raw_capabilities)
            except (ValueError, orjson.JSONDecodeError):
                capabilities = {}

        # Config
        raw_config = form.get("config")
        config = {}
        if raw_config:
            try:
                config = orjson.loads(raw_config)
            except (ValueError, orjson.JSONDecodeError):
                config = {}

        # Parse auth_headers JSON if present
        auth_headers_json = form.get("auth_headers") or ""
        auth_headers = []
        if auth_headers_json:
            try:
                auth_headers = orjson.loads(auth_headers_json)
            except (orjson.JSONDecodeError, ValueError):
                auth_headers = []

        # Passthrough headers
        passthrough_headers = str(form.get("passthrough_headers"))
        if passthrough_headers and passthrough_headers.strip():
            try:
                passthrough_headers = orjson.loads(passthrough_headers)
            except (orjson.JSONDecodeError, ValueError):
                # Fallback to comma-separated parsing
                passthrough_headers = [h.strip() for h in passthrough_headers.split(",") if h.strip()]
        else:
            passthrough_headers = None

        # Parse OAuth configuration - support both JSON string and individual form fields
        oauth_config_json = str(form.get("oauth_config"))
        oauth_config: Optional[dict[str, Any]] = None

        # Option 1: Pre-assembled oauth_config JSON (from API calls)
        if oauth_config_json and oauth_config_json != "None":
            try:
                oauth_config = orjson.loads(oauth_config_json)
                # Encrypt the client secret if present and not empty
                if oauth_config and "client_secret" in oauth_config and oauth_config["client_secret"]:
                    encryption = get_encryption_service(settings.auth_encryption_secret)
                    oauth_config["client_secret"] = await encryption.encrypt_secret_async(oauth_config["client_secret"])
            except (orjson.JSONDecodeError, ValueError) as e:
                LOGGER.error(f"Failed to parse OAuth config: {e}")
                oauth_config = None

        # Option 2: Assemble from individual UI form fields
        if not oauth_config:
            oauth_grant_type = str(form.get("oauth_grant_type", ""))
            oauth_issuer = str(form.get("oauth_issuer", ""))
            oauth_token_url = str(form.get("oauth_token_url", ""))
            oauth_authorization_url = str(form.get("oauth_authorization_url", ""))
            oauth_redirect_uri = str(form.get("oauth_redirect_uri", ""))
            oauth_client_id = str(form.get("oauth_client_id", ""))
            oauth_client_secret = str(form.get("oauth_client_secret", ""))
            oauth_username = str(form.get("oauth_username", ""))
            oauth_password = str(form.get("oauth_password", ""))
            oauth_scopes_str = str(form.get("oauth_scopes", ""))

            # If any OAuth field is provided, assemble oauth_config
            if any([oauth_grant_type, oauth_issuer, oauth_token_url, oauth_authorization_url, oauth_client_id]):
                oauth_config = {}

                if oauth_grant_type:
                    oauth_config["grant_type"] = oauth_grant_type
                if oauth_issuer:
                    oauth_config["issuer"] = oauth_issuer
                if oauth_token_url:
                    oauth_config["token_url"] = oauth_token_url  # OAuthManager expects 'token_url', not 'token_endpoint'
                if oauth_authorization_url:
                    oauth_config["authorization_url"] = oauth_authorization_url  # OAuthManager expects 'authorization_url', not 'authorization_endpoint'
                if oauth_redirect_uri:
                    oauth_config["redirect_uri"] = oauth_redirect_uri
                if oauth_client_id:
                    oauth_config["client_id"] = oauth_client_id
                if oauth_client_secret:
                    # Encrypt the client secret
                    encryption = get_encryption_service(settings.auth_encryption_secret)
                    oauth_config["client_secret"] = await encryption.encrypt_secret_async(oauth_client_secret)

                # Add username and password for password grant type
                if oauth_username:
                    oauth_config["username"] = oauth_username
                if oauth_password:
                    oauth_config["password"] = oauth_password

                # Parse scopes (comma or space separated)
                if oauth_scopes_str:
                    scopes = [s.strip() for s in oauth_scopes_str.replace(",", " ").split() if s.strip()]
                    if scopes:
                        oauth_config["scopes"] = scopes

                LOGGER.info(f"✅ Assembled OAuth config from UI form fields (edit): grant_type={oauth_grant_type}, issuer={oauth_issuer}")

        user_email = get_user_email(user)

        # Preserve existing agent's team_id when no explicit team_id is provided.
        # Without this guard, verify_team_for_user() falls back to the user's
        # personal team, silently reassigning the agent on every edit.
        if not team_id:
            existing_agent = db.get(DbA2AAgent, agent_id)
            existing_team = getattr(existing_agent, "team_id", None) if existing_agent else None
            if isinstance(existing_team, str) and existing_team:
                team_id = existing_team

        team_service = TeamManagementService(db)
        team_id = await team_service.verify_team_for_user(user_email, team_id)

        # Extract UAID generation fields - allow adding UAID to agents that don't have one
        # UAID is immutable: once generated, it cannot be changed
        generate_uaid = form.get("generate_uaid") == "true"
        uaid_registry = str(form.get("uaid_registry", "")) if generate_uaid else None
        uaid_protocol = str(form.get("uaid_protocol", "")) if generate_uaid else None
        uaid_native_id_override = form.get("uaid_native_id_override") or None

        # Auto-detect OAuth: if oauth_config is present and auth_type not explicitly set, use "oauth"
        auth_type_from_form = str(form.get("auth_type", ""))
        if oauth_config and not auth_type_from_form:
            auth_type_from_form = "oauth"
            LOGGER.info("Auto-detected OAuth configuration in edit, setting auth_type='oauth'")

        agent_update = A2AAgentUpdate(
            name=form.get("name"),
            description=form.get("description"),
            endpoint_url=form.get("endpoint_url"),
            agent_type=agent_type,
            tags=tags,
            auth_type=auth_type_from_form,
            auth_username=str(form.get("auth_username", "")),
            auth_password=str(form.get("auth_password", "")),
            auth_token=str(form.get("auth_token", "")),
            auth_header_key=str(form.get("auth_header_key", "")),
            auth_header_value=str(form.get("auth_header_value", "")),
            auth_value=str(form.get("auth_value", "")),
            auth_query_param_key=str(form.get("auth_query_param_key", "")) or None,
            auth_query_param_value=str(form.get("auth_query_param_value", "")) or None,
            auth_headers=auth_headers if auth_headers else None,
            passthrough_headers=passthrough_headers,
            oauth_config=oauth_config,
            visibility=visibility,
            team_id=team_id,
            owner_email=user_email,
            capabilities=capabilities,  # Optional, not editable via UI
            config=config,  # Optional, not editable via UI
            # UAID generation fields (only applies if agent doesn't have UAID yet)
            generate_uaid=generate_uaid,
            uaid_registry=uaid_registry,
            uaid_protocol=uaid_protocol,
            uaid_native_id_override=uaid_native_id_override if generate_uaid else None,
        )

        mod_metadata = MetadataCapture.extract_modification_metadata(request, user, 0)
        await a2a_service.update_agent(
            db=db,
            agent_id=agent_id,
            agent_data=agent_update,
            modified_by=mod_metadata["modified_by"],
            modified_from_ip=mod_metadata["modified_from_ip"],
            modified_via=mod_metadata["modified_via"],
            modified_user_agent=mod_metadata["modified_user_agent"],
        )

        return ORJSONResponse({"message": "A2A agent updated successfully", "success": True}, status_code=200)

    except ValidationError as ve:
        return ORJSONResponse({"message": str(ve), "success": False}, status_code=422)
    except IntegrityError as ie:
        return ORJSONResponse({"message": str(ie), "success": False}, status_code=409)
    except HTTPException:
        raise
    except Exception as e:
        return ORJSONResponse({"message": str(e), "success": False}, status_code=500)


@admin_router.post("/a2a/{agent_id}/state")
@require_permission("a2a.update", allow_admin_bypass=False)
async def admin_set_a2a_agent_state(
    agent_id: str,
    request: Request,
    db: Session = Depends(get_db),
    user=Depends(get_current_user_with_permissions),  # pylint: disable=unused-argument
) -> RedirectResponse:
    """Toggle A2A agent status via admin UI.

    Args:
        agent_id: Agent ID
        request: FastAPI request object
        db: Database session
        user: Authenticated user

    Returns:
        Redirect response to admin page with A2A tab

    Raises:
        HTTPException: If A2A features are disabled
    """
    if not a2a_service or not settings.mcpgateway_a2a_enabled:
        root_path = _resolve_root_path(request)
        return RedirectResponse(f"{root_path}/admin#a2a-agents", status_code=303)

    user_email = get_user_email(user)
    error_message = None
    is_inactive_checked = "false"
    team_id = ""
    try:
        form = await request.form()
        act_val = form.get("activate", "false")
        activate = act_val.lower() == "true" if isinstance(act_val, str) else False
        is_inactive_checked = str(form.get("is_inactive_checked", "false"))
        team_id = str(form.get("team_id", "") or "")

        await a2a_service.set_agent_state(db, agent_id, activate, user_email=user_email)

    except PermissionError as e:
        LOGGER.warning(f"Permission denied for user {user_email} setting A2A agent state {agent_id}: {e}")
        error_message = str(e)
    except A2AAgentNotFoundError as e:
        LOGGER.error(f"A2A agent state change failed - not found: {e}")
        error_message = "A2A agent not found."
    except Exception as e:
        LOGGER.error(f"Error setting A2A agent state: {e}")
        error_message = "Failed to set state of A2A agent. Please try again."

    root_path = _resolve_root_path(request)
    redirect_url = _build_admin_redirect(root_path, "a2a-agents", error=error_message, include_inactive=is_inactive_checked.lower() == "true", team_id=team_id)
    return RedirectResponse(redirect_url, status_code=303)


@admin_router.post("/a2a/{agent_id}/delete")
@require_permission("a2a.delete", allow_admin_bypass=False)
async def admin_delete_a2a_agent(
    agent_id: str,
    request: Request,  # pylint: disable=unused-argument
    db: Session = Depends(get_db),
    user=Depends(get_current_user_with_permissions),  # pylint: disable=unused-argument
) -> RedirectResponse:
    """Delete A2A agent via admin UI.

    Args:
        agent_id: Agent ID
        request: FastAPI request object
        db: Database session
        user: Authenticated user

    Returns:
        Redirect response to admin page with A2A tab

    Raises:
        HTTPException: If A2A features are disabled
    """
    if not a2a_service or not settings.mcpgateway_a2a_enabled:
        root_path = _resolve_root_path(request)
        return RedirectResponse(f"{root_path}/admin#a2a-agents", status_code=303)

    error_message = None
    is_inactive_checked = "false"
    team_id = ""
    try:
        form = await request.form()
        purge_metrics = str(form.get("purge_metrics", "false")).lower() == "true"
        is_inactive_checked = str(form.get("is_inactive_checked", "false"))
        team_id = str(form.get("team_id", "") or "")
        user_email = get_user_email(user)
        await a2a_service.delete_agent(db, agent_id, user_email=user_email, purge_metrics=purge_metrics)
    except PermissionError as e:
        LOGGER.warning(f"Permission denied for user {get_user_email(user)} deleting A2A agent {agent_id}: {e}")
        error_message = str(e)
    except A2AAgentNotFoundError as e:
        LOGGER.error(f"A2A agent delete failed - not found: {e}")
        error_message = "A2A agent not found."
    except Exception as e:
        LOGGER.error(f"Error deleting A2A agent: {e}")
        error_message = "Failed to delete A2A agent. Please try again."

    root_path = _resolve_root_path(request)
    redirect_url = _build_admin_redirect(root_path, "a2a-agents", error=error_message, include_inactive=is_inactive_checked.lower() == "true", team_id=team_id)
    return RedirectResponse(redirect_url, status_code=303)


@admin_router.post("/a2a/{agent_id}/test")
@require_permission("a2a.invoke", allow_admin_bypass=False)
async def admin_test_a2a_agent(
    agent_id: str,
    request: Request,
    db: Session = Depends(get_db),
    user=Depends(get_current_user_with_permissions),
) -> JSONResponse:
    """Test A2A agent via admin UI.

    Invokes the specified A2A agent with a test message and returns the result.
    Returns appropriate HTTP status codes based on the failure type:
    - 404: Agent not found or user lacks access
    - 502: Agent disabled, unreachable, or returning errors
    - 422: Invalid test parameters
    - 500: Unexpected system errors

    Args:
        agent_id: Agent ID
        request: FastAPI request object containing optional 'query' field
        db: Database session
        user: Authenticated user

    Returns:
        JSON response with test results. On success, includes:
        - success: True
        - result: Agent response
        - agent_name: Name of the tested agent
        - test_timestamp: Unix timestamp of the test

        On failure, includes:
        - success: False
        - error: Error message (sanitized to prevent credential leakage)
        - error_type: Type of error (not_found, agent_error, validation_error, internal_error)
        - agent_id: ID of the agent that was tested

    Raises:
        HTTPException: If A2A features are disabled (403)

    Note:
        Error messages are sanitized by a2a_service to prevent credential leakage.
        Returns 404 (not 403) for access denied to avoid leaking agent existence.
    """
    if not a2a_service or not settings.mcpgateway_a2a_enabled:
        return ORJSONResponse(content={"success": False, "error": "A2A features are disabled"}, status_code=403)

    try:
        user_email = get_user_email(user)
        is_admin = user.get("is_admin", False) if isinstance(user, dict) else False
        token_teams = user.get("token_teams") if isinstance(user, dict) else None
        # Missing token_teams key for non-admin = public-only (per normalize_token_teams rules).
        # Only admin users retain None (admin bypass); all others default to [].
        if not is_admin and token_teams is None:
            token_teams = []
        # Admin users with unrestricted tokens get full bypass (both None);
        # non-admin users pass their actual email and team scoping.
        invoke_user_email = None if (is_admin and token_teams is None) else user_email
        # Get the agent by ID
        agent = await a2a_service.get_agent(db, agent_id, user_email=invoke_user_email, token_teams=token_teams)

        # Parse request body to get user-provided query
        default_message = "Hello from ContextForge Admin UI test!"
        try:
            body = await _read_request_json(request)
            # Use 'or' to also handle empty string queries
            user_query = (body.get("query") if body else None) or default_message
        except Exception:
            user_query = default_message

        # Prepare test parameters based on agent type and endpoint
        if agent.agent_type in ["generic", "jsonrpc"] or agent.endpoint_url.endswith("/"):
            # Let the A2A protocol helper choose v1 or legacy wire format from the agent record.
            test_params = {"query": user_query}
        else:
            # Generic test format
            test_params = {"query": user_query, "message": user_query, "test": True, "timestamp": int(time.time())}

        # Invoke the agent
        result = await a2a_service.invoke_agent(
            db,
            agent.name,
            test_params,
            "admin_test",
            user_email=invoke_user_email,
            user_id=user_email,
            token_teams=token_teams,
        )

        return ORJSONResponse(content={"success": True, "result": result, "agent_name": agent.name, "test_timestamp": time.time()})

    except A2AAgentNotFoundError as e:
        LOGGER.warning(f"A2A agent not found or access denied for {agent_id}: {e}")
        return ORJSONResponse(
            content={"success": False, "error": str(e), "error_type": "not_found", "agent_id": agent_id},
            status_code=404,
        )
    except A2AAgentError as e:
        LOGGER.error(f"A2A agent error for {agent_id}: {e}")
        return ORJSONResponse(
            content={"success": False, "error": str(e), "error_type": "agent_error", "agent_id": agent_id},
            status_code=502,
        )
    except ValidationError as e:
        LOGGER.warning(f"Validation error testing A2A agent {agent_id}: {e}")
        return ORJSONResponse(
            content={"success": False, "error": str(e), "error_type": "validation_error", "agent_id": agent_id},
            status_code=422,
        )
    except Exception as e:
        LOGGER.error(f"Unexpected error testing A2A agent {agent_id}: {e}")
        return ORJSONResponse(
            content={"success": False, "error": str(e), "error_type": "internal_error", "agent_id": agent_id},
            status_code=500,
        )


# gRPC Service Management Endpoints


@admin_router.get("/grpc", response_model=PaginatedResponse)
@require_permission("admin.grpc", allow_admin_bypass=False)
async def admin_list_grpc_services(
    page: int = Query(1, ge=1, description="Page number (1-indexed)"),
    per_page: int = Query(settings.pagination_default_page_size, ge=1, le=settings.pagination_max_page_size, description="Items per page"),
    include_inactive: bool = False,
    team_id: Optional[str] = Depends(_validated_team_id_param),
    db: Session = Depends(get_db),
    user=Depends(get_current_user_with_permissions),
) -> Dict[str, Any]:
    """List all gRPC services for the admin UI with pagination support.

    This endpoint retrieves a paginated list of gRPC services. Administrators can
    optionally include inactive services for management or auditing purposes.
    Uses offset-based (page/per_page) pagination.

    Args:
        page: Page number (1-indexed) for offset pagination
        per_page: Number of items per page
        include_inactive: Whether to include inactive services in the results
        team_id: Optional team ID to filter by specific team
        db: Database session dependency
        user: Authenticated user dependency

    Returns:
        Dict[str, Any]: A dictionary containing:
            - data: List of gRPC service records formatted with by_alias=True
            - pagination: Pagination metadata
            - links: Pagination links (optional)

    Raises:
        HTTPException: If gRPC support is disabled or not available
    """
    if not GRPC_AVAILABLE or not settings.mcpgateway_grpc_enabled:
        raise HTTPException(status_code=404, detail="gRPC support is not available or disabled")

    user_email = get_user_email(user)

    # Call grpc_service_mgr.list_services with page-based pagination
    paginated_result = await grpc_service_mgr.list_services(
        db=db,
        include_inactive=include_inactive,
        page=page,
        per_page=per_page,
        user_email=user_email,
        team_id=team_id,
    )

    # Return standardized paginated response
    return {
        "data": [service.model_dump(by_alias=True) for service in paginated_result["data"]],
        "pagination": paginated_result["pagination"].model_dump(),
        "links": paginated_result["links"].model_dump() if paginated_result["links"] else None,
    }


@admin_router.post("/grpc")
@require_permission("admin.grpc", allow_admin_bypass=False)
async def admin_create_grpc_service(
    service: GrpcServiceCreate,
    request: Request,
    db: Session = Depends(get_db),
    user=Depends(get_current_user_with_permissions),
):
    """Create a new gRPC service.

    Args:
        service: gRPC service creation data
        request: FastAPI request object
        db: Database session
        user: Authenticated user

    Returns:
        Created gRPC service

    Raises:
        HTTPException: If gRPC support is disabled or creation fails
    """
    if not GRPC_AVAILABLE or not settings.mcpgateway_grpc_enabled:
        raise HTTPException(status_code=404, detail="gRPC support is not available or disabled")

    try:
        _check_public_visibility_allowed(service.visibility or "", team_id=getattr(service, "team_id", None))
        metadata = MetadataCapture.extract_creation_metadata(request, user)
        user_email = get_user_email(user)
        result = await grpc_service_mgr.register_service(db, service, user_email, metadata)
        return ORJSONResponse(content=jsonable_encoder(result), status_code=201)
    except GrpcServiceNameConflictError as e:
        raise HTTPException(status_code=409, detail=str(e))
    except GrpcServiceError as e:
        LOGGER.error(f"gRPC service error: {e}")
        raise HTTPException(status_code=500, detail="gRPC service error")


@admin_router.get("/grpc/{service_id}", response_model=GrpcServiceRead)
@require_permission("admin.grpc", allow_admin_bypass=False)
async def admin_get_grpc_service(
    service_id: str,
    db: Session = Depends(get_db),
    user=Depends(get_current_user_with_permissions),
):
    """Get a specific gRPC service.

    Args:
        service_id: Service ID
        db: Database session
        user: Authenticated user

    Returns:
        The gRPC service

    Raises:
        HTTPException: If gRPC support is disabled or service not found
    """
    if not GRPC_AVAILABLE or not settings.mcpgateway_grpc_enabled:
        raise HTTPException(status_code=404, detail="gRPC support is not available or disabled")

    try:
        user_email = get_user_email(user)
        return await grpc_service_mgr.get_service(db, service_id, user_email)
    except GrpcServiceNotFoundError as e:
        raise HTTPException(status_code=404, detail=str(e))


@admin_router.put("/grpc/{service_id}")
@require_permission("admin.grpc", allow_admin_bypass=False)
async def admin_update_grpc_service(
    service_id: str,
    service: GrpcServiceUpdate,
    request: Request,
    db: Session = Depends(get_db),
    user=Depends(get_current_user_with_permissions),
):
    """Update a gRPC service.

    Args:
        service_id: Service ID
        service: Update data
        request: FastAPI request object
        db: Database session
        user: Authenticated user

    Returns:
        Updated gRPC service

    Raises:
        HTTPException: If gRPC support is disabled or update fails
    """
    if not GRPC_AVAILABLE or not settings.mcpgateway_grpc_enabled:
        raise HTTPException(status_code=404, detail="gRPC support is not available or disabled")

    try:
        _check_public_visibility_allowed(service.visibility or "", team_id=getattr(service, "team_id", None))
        metadata = MetadataCapture.extract_modification_metadata(request, user, 0)
        user_email = get_user_email(user)
        result = await grpc_service_mgr.update_service(db, service_id, service, user_email, metadata)
        return ORJSONResponse(content=jsonable_encoder(result))
    except GrpcServiceNotFoundError as e:
        raise HTTPException(status_code=404, detail=str(e))
    except GrpcServiceNameConflictError as e:
        raise HTTPException(status_code=409, detail=str(e))
    except GrpcServiceError as e:
        LOGGER.error(f"gRPC service error: {e}")
        raise HTTPException(status_code=500, detail="gRPC service error")


@admin_router.post("/grpc/{service_id}/state")
@require_permission("admin.grpc", allow_admin_bypass=False)
async def admin_set_grpc_service_state(
    service_id: str,
    activate: Optional[bool] = Query(None, description="Set enabled state. If not provided, inverts current state."),
    db: Session = Depends(get_db),
    user=Depends(get_current_user_with_permissions),  # pylint: disable=unused-argument
):
    """Set a gRPC service's enabled state.

    Args:
        service_id: Service ID
        activate: If provided, sets enabled to this value. If None, inverts current state (legacy behavior).
        db: Database session
        user: Authenticated user

    Returns:
        Updated gRPC service

    Raises:
        HTTPException: If gRPC support is disabled or state change fails
    """
    if not GRPC_AVAILABLE or not settings.mcpgateway_grpc_enabled:
        raise HTTPException(status_code=404, detail="gRPC support is not available or disabled")

    try:
        if activate is None:
            # Legacy toggle behavior - invert current state
            service = await grpc_service_mgr.get_service(db, service_id)
            activate = not service.enabled
        result = await grpc_service_mgr.set_service_state(db, service_id, activate)
        return ORJSONResponse(content=jsonable_encoder(result))
    except GrpcServiceNotFoundError as e:
        raise HTTPException(status_code=404, detail=str(e))


@admin_router.post("/grpc/{service_id}/delete")
@require_permission("admin.grpc", allow_admin_bypass=False)
async def admin_delete_grpc_service(
    service_id: str,
    db: Session = Depends(get_db),
    user=Depends(get_current_user_with_permissions),  # pylint: disable=unused-argument
):
    """Delete a gRPC service.

    Args:
        service_id: Service ID
        db: Database session
        user: Authenticated user

    Returns:
        No content response

    Raises:
        HTTPException: If gRPC support is disabled or deletion fails
    """
    if not GRPC_AVAILABLE or not settings.mcpgateway_grpc_enabled:
        raise HTTPException(status_code=404, detail="gRPC support is not available or disabled")

    try:
        await grpc_service_mgr.delete_service(db, service_id)
        return Response(status_code=204)
    except GrpcServiceNotFoundError as e:
        raise HTTPException(status_code=404, detail=str(e))


@admin_router.post("/grpc/{service_id}/reflect")
@require_permission("admin.grpc", allow_admin_bypass=False)
async def admin_reflect_grpc_service(
    service_id: str,
    db: Session = Depends(get_db),
    user=Depends(get_current_user_with_permissions),  # pylint: disable=unused-argument
):
    """Trigger re-reflection on a gRPC service.

    Args:
        service_id: Service ID
        db: Database session
        user: Authenticated user

    Returns:
        Updated gRPC service with reflection results

    Raises:
        HTTPException: If gRPC support is disabled or reflection fails
    """
    if not GRPC_AVAILABLE or not settings.mcpgateway_grpc_enabled:
        raise HTTPException(status_code=404, detail="gRPC support is not available or disabled")

    try:
        result = await grpc_service_mgr.reflect_service(db, service_id)
        return ORJSONResponse(content=jsonable_encoder(result))
    except GrpcServiceNotFoundError as e:
        raise HTTPException(status_code=404, detail=str(e))
    except GrpcServiceError as e:
        LOGGER.error(f"gRPC service error: {e}")
        raise HTTPException(status_code=500, detail="gRPC service error")


@admin_router.get("/grpc/{service_id}/methods")
@require_permission("admin.grpc", allow_admin_bypass=False)
async def admin_get_grpc_methods(
    service_id: str,
    db: Session = Depends(get_db),
    user=Depends(get_current_user_with_permissions),  # pylint: disable=unused-argument
):
    """Get methods for a gRPC service.

    Args:
        service_id: Service ID
        db: Database session
        user: Authenticated user

    Returns:
        List of gRPC methods

    Raises:
        HTTPException: If gRPC support is disabled or service not found
    """
    if not GRPC_AVAILABLE or not settings.mcpgateway_grpc_enabled:
        raise HTTPException(status_code=404, detail="gRPC support is not available or disabled")

    try:
        methods = await grpc_service_mgr.get_service_methods(db, service_id)
        return ORJSONResponse(content={"methods": methods})
    except GrpcServiceNotFoundError as e:
        raise HTTPException(status_code=404, detail=str(e))


@admin_router.get("/sections/resources")
@require_permission("resources.read", allow_admin_bypass=False)
async def get_resources_section(
    team_id: Optional[str] = None,
    db: Session = Depends(get_db),
    user=Depends(get_current_user_with_permissions),
):
    """Get resources data filtered by team.

    Args:
        team_id: Optional team ID to filter by
        db: Database session
        user: Current authenticated user context

    Returns:
        JSONResponse: Resources data with team filtering applied
    """
    try:
        local_resource_service = ResourceService()
        user_email = get_user_email(user)
        LOGGER.debug(f"User {user_email} requesting resources section with team_id={team_id}")

        # Get all resources and filter by team
        resources_result = await local_resource_service.list_resources(db, include_inactive=True)
        if isinstance(resources_result, tuple):
            resources_list = resources_result[0]
        else:
            resources_list = resources_result

        # Apply team filtering if specified
        if team_id:
            resources_list = [r for r in resources_list if getattr(r, "team_id", None) == team_id]

        # Convert to JSON-serializable format
        resources = []
        for resource in resources_list:
            resource_dict = (
                resource.model_dump(by_alias=True)
                if hasattr(resource, "model_dump")
                else {
                    "id": resource.id,
                    "name": resource.name,
                    "description": resource.description,
                    "uri": resource.uri,
                    "tags": resource.tags or [],
                    "isActive": resource.enabled,
                    "team_id": getattr(resource, "team_id", None),
                    "visibility": getattr(resource, "visibility", "private"),
                }
            )
            resources.append(resource_dict)

        return ORJSONResponse(content={"resources": resources, "team_id": team_id})

    except Exception as e:
        LOGGER.error(f"Error loading resources section: {e}")
        return ORJSONResponse(content={"error": str(e)}, status_code=500)


@admin_router.get("/sections/prompts")
@require_permission("prompts.read", allow_admin_bypass=False)
async def get_prompts_section(
    team_id: Optional[str] = None,
    db: Session = Depends(get_db),
    user=Depends(get_current_user_with_permissions),
):
    """Get prompts data filtered by team.

    Args:
        team_id: Optional team ID to filter by
        db: Database session
        user: Current authenticated user context

    Returns:
        JSONResponse: Prompts data with team filtering applied
    """
    try:
        local_prompt_service = PromptService()
        user_email = get_user_email(user)
        LOGGER.debug(f"User {user_email} requesting prompts section with team_id={team_id}")

        # Get all prompts and filter by team
        prompts_result = await local_prompt_service.list_prompts(db, include_inactive=True)
        if isinstance(prompts_result, tuple):
            prompts_list = prompts_result[0]
        else:
            prompts_list = prompts_result

        # Apply team filtering if specified
        if team_id:
            prompts_list = [p for p in prompts_list if getattr(p, "team_id", None) == team_id]

        # Convert to JSON-serializable format
        prompts = []
        for prompt in prompts_list:
            prompt_dict = (
                prompt.model_dump(by_alias=True)
                if hasattr(prompt, "model_dump")
                else {
                    "id": prompt.id,
                    "name": prompt.name,
                    "description": prompt.description,
                    "arguments": prompt.arguments or [],
                    "tags": prompt.tags or [],
                    # Prompt enabled/disabled state is stored on the prompt as `enabled`.
                    "isActive": getattr(prompt, "enabled", False),
                    "team_id": getattr(prompt, "team_id", None),
                    "visibility": getattr(prompt, "visibility", "private"),
                }
            )
            prompts.append(prompt_dict)

        return ORJSONResponse(content={"prompts": prompts, "team_id": team_id})

    except Exception as e:
        LOGGER.error(f"Error loading prompts section: {e}")
        return ORJSONResponse(content={"error": str(e)}, status_code=500)


@admin_router.get("/sections/servers")
@require_permission("servers.read", allow_admin_bypass=False)
async def get_servers_section(
    team_id: Optional[str] = None,
    include_inactive: bool = False,
    db: Session = Depends(get_db),
    user=Depends(get_current_user_with_permissions),
):
    """Get servers data filtered by team.

    Args:
        team_id: Optional team ID to filter by
        include_inactive: Whether to include inactive servers
        db: Database session
        user: Current authenticated user context

    Returns:
        JSONResponse: Servers data with team filtering applied
    """
    try:
        local_server_service = ServerService()
        user_email = get_user_email(user)
        LOGGER.debug(f"User {user_email} requesting servers section with team_id={team_id}, include_inactive={include_inactive}")

        # Get servers with optional include_inactive parameter
        servers_result = await local_server_service.list_servers(db, include_inactive=include_inactive)
        if isinstance(servers_result, tuple):
            servers_list = servers_result[0]
        else:
            servers_list = servers_result

        # Apply team filtering if specified
        if team_id:
            servers_list = [s for s in servers_list if getattr(s, "team_id", None) == team_id]

        # Convert to JSON-serializable format
        servers = []
        for server in servers_list:
            server_dict = (
                server.model_dump(by_alias=True)
                if hasattr(server, "model_dump")
                else {
                    "id": server.id,
                    "name": server.name,
                    "description": server.description,
                    "tags": server.tags or [],
                    "isActive": server.enabled,
                    "team_id": getattr(server, "team_id", None),
                    "visibility": getattr(server, "visibility", "private"),
                }
            )
            servers.append(server_dict)

        return ORJSONResponse(content={"servers": servers, "team_id": team_id})

    except Exception as e:
        LOGGER.error(f"Error loading servers section: {e}")
        return ORJSONResponse(content={"error": str(e)}, status_code=500)


@admin_router.get("/sections/gateways")
@require_permission("gateways.read", allow_admin_bypass=False)
async def get_gateways_section(
    team_id: Optional[str] = None,
    db: Session = Depends(get_db),
    user=Depends(get_current_user_with_permissions),
):
    """Get gateways data filtered by team.

    Args:
        team_id: Optional team ID to filter by
        db: Database session
        user: Current authenticated user context

    Returns:
        JSONResponse: Gateways data with team filtering applied
    """
    try:
        local_gateway_service = GatewayService()
        get_user_email(user)

        # Get all gateways and filter by team
        gateways_list, _ = await local_gateway_service.list_gateways(db, include_inactive=True)

        # Apply team filtering if specified
        if team_id:
            gateways_list = [g for g in gateways_list if g.team_id == team_id]

        # Convert to JSON-serializable format
        gateways = []
        for gateway in gateways_list:
            if hasattr(gateway, "model_dump"):
                # Get dict and serialize datetime objects
                gateway_dict = gateway.model_dump(by_alias=True)
                # Convert datetime objects to strings
                for key, value in gateway_dict.items():
                    gateway_dict[key] = serialize_datetime(value)
            else:
                # Parse URL to extract host and port
                parsed_url = urllib.parse.urlparse(gateway.url) if gateway.url else None
                gateway_dict = {
                    "id": gateway.id,
                    "name": gateway.name,
                    "host": parsed_url.hostname if parsed_url else "",
                    "port": parsed_url.port if parsed_url else 80,
                    "tags": gateway.tags or [],
                    "isActive": getattr(gateway, "enabled", False),
                    "team_id": getattr(gateway, "team_id", None),
                    "visibility": getattr(gateway, "visibility", "private"),
                    "created_at": serialize_datetime(getattr(gateway, "created_at", None)),
                    "updated_at": serialize_datetime(getattr(gateway, "updated_at", None)),
                }
            gateways.append(gateway_dict)

        return ORJSONResponse(content={"gateways": gateways, "team_id": team_id})

    except Exception as e:
        LOGGER.error(f"Error loading gateways section: {e}")
        return ORJSONResponse(content={"error": str(e)}, status_code=500)


####################
# Plugin Routes    #
####################


async def _sync_plugin_service_from_runtime(request: Request, plugin_service) -> None:
    """Self-heal the admin plugin cache from the live framework state.

    The framework's ``get_plugin_manager`` is the single source of truth — it
    reads the shared toggle (TTL-cached, so this is a cheap call) and returns
    ``None`` when plugins are globally disabled, even when the disable came
    from a *remote* node via the Redis toggle.

    Every admin read mirrors that answer back into ``app.state.plugin_manager``
    and the ``PluginService`` singleton. This closes three gaps:

    1. Processes that booted with plugins disabled never had ``app.state`` set,
       so admin views returned empty until restart even after the shared
       toggle was flipped on.
    2. If ``toggle_plugins_global`` swallowed an admin-cache sync failure, the
       stale cache would persist forever — now the next GET repairs it.
    3. A remote disable (``PUT /admin/plugins {"enabled": false}`` on another
       worker) would leave this worker's ``app.state.plugin_manager``
       populated from a prior enable, making admin views serve plugin
       metadata the cluster had already turned off.

    Best-effort: a failure logs a WARNING and leaves ``app.state`` alone. It
    never raises, so it can't turn a read into a 500.
    """
    try:
        # pylint: disable=import-outside-toplevel
        # First-Party
        from mcpgateway.plugins import get_plugin_manager

        plugin_manager = await get_plugin_manager()
        request.app.state.plugin_manager = plugin_manager
        plugin_service.set_plugin_manager(plugin_manager)
    except Exception as sync_exc:
        LOGGER.warning("Admin plugin-cache self-heal failed (%s) — view may render stale/empty", sync_exc)


@admin_router.get("/plugins/partial")
@require_permission("admin.plugins", allow_admin_bypass=False)
async def get_plugins_partial(request: Request, db: Session = Depends(get_db), user=Depends(get_current_user_with_permissions)) -> HTMLResponse:  # pylint: disable=unused-argument
    """Render the plugins partial HTML template.

    This endpoint returns a rendered HTML partial containing plugin information,
    similar to the version_info_partial pattern. It's designed to be loaded via HTMX
    into the admin interface.

    Args:
        request: FastAPI request object
        db: Database session
        user: Authenticated user

    Returns:
        HTMLResponse with rendered plugins partial template
    """
    LOGGER.debug(f"User {get_user_email(user)} requested plugins partial")

    try:
        # Get plugin service and check if plugins are enabled
        plugin_service = get_plugin_service()

        # Self-heal the cache so the partial reflects the live shared toggle.
        await _sync_plugin_service_from_runtime(request, plugin_service)

        # Get plugin data
        plugins = plugin_service.get_all_plugins()
        stats = await plugin_service.get_plugin_statistics()

        # Prepare context for template
        context = {"request": request, "plugins": plugins, "stats": stats, "plugins_enabled": plugin_service.get_plugin_manager() is not None, "root_path": _resolve_root_path(request)}

        # Render the partial template
        return request.app.state.templates.TemplateResponse(request, "plugins_partial.html", context)

    except Exception as e:
        LOGGER.error(f"Error rendering plugins partial: {e}")
        # Return error HTML that can be displayed in the UI
        error_html = f"""
        <div class="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
            <strong class="font-bold">Error loading plugins:</strong>
            <span class="block sm:inline">{html.escape(str(e))}</span>
        </div>
        """
        return HTMLResponse(content=error_html, status_code=500)


@admin_router.get("/plugins", response_model=PluginListResponse)
@require_permission("admin.plugins", allow_admin_bypass=False)
async def list_plugins(
    request: Request,
    search: Optional[str] = None,
    mode: Optional[str] = None,
    hook: Optional[str] = None,
    tag: Optional[str] = None,
    db: Session = Depends(get_db),  # pylint: disable=unused-argument
    user=Depends(get_current_user_with_permissions),
) -> PluginListResponse:
    """Get list of all plugins with optional filtering.

    Args:
        request: FastAPI request object
        search: Optional text search in name/description/author
        mode: Optional filter by mode (enforce/permissive/disabled)
        hook: Optional filter by hook type
        tag: Optional filter by tag
        db: Database session
        user: Authenticated user

    Returns:
        PluginListResponse with list of plugins and statistics

    Raises:
        HTTPException: If there's an error retrieving plugins
    """
    LOGGER.debug(f"User {get_user_email(user)} requested plugin list")
    structured_logger = get_structured_logger()

    try:
        # Get plugin service
        plugin_service = get_plugin_service()

        # Self-heal the cache from the live framework state.
        await _sync_plugin_service_from_runtime(request, plugin_service)

        # Get filtered plugins
        if any([search, mode, hook, tag]):
            plugins = plugin_service.search_plugins(query=search, mode=mode, hook=hook, tag=tag)
        else:
            plugins = plugin_service.get_all_plugins()

        # Count enabled/disabled
        enabled_count = sum(1 for p in plugins if p["status"] == "enabled")
        disabled_count = sum(1 for p in plugins if p["status"] == "disabled")

        # Log plugin marketplace browsing activity
        structured_logger.info(
            "User browsed plugin marketplace",
            user_id=get_user_id(user),
            user_email=get_user_email(user),
            component="plugin_marketplace",
            category="business_logic",
            resource_type="plugin_list",
            resource_action="browse",
            custom_fields={
                "search_query": search,
                "filter_mode": mode,
                "filter_hook": hook,
                "filter_tag": tag,
                "results_count": len(plugins),
                "enabled_count": enabled_count,
                "disabled_count": disabled_count,
                "has_filters": any([search, mode, hook, tag]),
            },
        )

        # First-Party
        from mcpgateway.plugins import are_plugins_enabled_shared  # pylint: disable=import-outside-toplevel

        return PluginListResponse(plugins_globally_enabled=await are_plugins_enabled_shared(), plugins=plugins, total=len(plugins), enabled_count=enabled_count, disabled_count=disabled_count)

    except Exception as e:
        LOGGER.error(f"Error listing plugins: {e}")
        structured_logger.error("Failed to list plugins in marketplace", user_id=get_user_id(user), user_email=get_user_email(user), error=e, component="plugin_marketplace", category="business_logic")
        raise HTTPException(status_code=500, detail="Failed to list plugins")


@admin_router.put("/plugins", response_model=PluginToggleResponse)
@require_permission("admin.plugins", allow_admin_bypass=False)
async def toggle_plugins_global(
    payload: PluginToggleRequest,
    request: Request,
    user=Depends(get_current_user_with_permissions),
) -> PluginToggleResponse:
    """Enable or disable the plugin subsystem globally and broadcast the change."""
    # pylint: disable=import-outside-toplevel
    # First-Party
    from mcpgateway.plugins import are_plugins_enabled_shared, enable_plugins_shared, get_plugin_manager

    redis_persisted = await enable_plugins_shared(payload.enabled)

    # Sync the admin-side cache so ``GET /admin/plugins`` and
    # ``GET /admin/plugins/{name}`` reflect the toggle on a process that
    # started with plugins disabled (``app.state.plugin_manager`` stayed unset
    # and ``PluginService`` was never wired). Without this, enabling plugins
    # at runtime leaves the admin surfaces reading stale/empty metadata until
    # the next restart; disabling likewise keeps the old manager visible.
    #
    # Treat the sync as best-effort: the shared toggle above already committed,
    # so an exception here (e.g. factory build failure on a degraded node) must
    # not turn into a 500 for a toggle that actually took effect. The admin
    # surfaces will self-correct on the next request that re-reads the manager.
    try:
        plugin_service = get_plugin_service()
        if payload.enabled:
            plugin_manager = await get_plugin_manager()
            if plugin_manager is not None:
                plugin_service.set_plugin_manager(plugin_manager)
                request.app.state.plugin_manager = plugin_manager
        else:
            plugin_service.set_plugin_manager(None)
            request.app.state.plugin_manager = None
    except Exception as sync_exc:
        LOGGER.warning(
            "Plugin global toggle applied (enabled=%s) but admin-cache sync failed (%s) — admin views will refresh on next request",
            payload.enabled,
            sync_exc,
        )

    LOGGER.info(f"Plugins globally {'enabled' if payload.enabled else 'disabled'} by {get_user_email(user)}")
    structured_logger = get_structured_logger()
    structured_logger.info(
        f"Plugin subsystem globally {'enabled' if payload.enabled else 'disabled'}",
        user_id=get_user_id(user),
        user_email=get_user_email(user),
        component="plugin_runtime",
        category="security",
        resource_type="plugin_global_toggle",
        resource_action="update",
        custom_fields={"enabled": payload.enabled, "redis_persisted": redis_persisted},
    )

    return PluginToggleResponse(plugins_enabled=await are_plugins_enabled_shared(), redis_persisted=redis_persisted)


@admin_router.get("/plugins/stats", response_model=PluginStatsResponse)
@require_permission("admin.plugins", allow_admin_bypass=False)
async def get_plugin_stats(request: Request, db: Session = Depends(get_db), user=Depends(get_current_user_with_permissions)) -> PluginStatsResponse:  # pylint: disable=unused-argument
    """Get plugin statistics.

    Args:
        request: FastAPI request object
        db: Database session
        user: Authenticated user

    Returns:
        PluginStatsResponse with aggregated plugin statistics

    Raises:
        HTTPException: If there's an error getting plugin statistics
    """
    LOGGER.debug(f"User {get_user_email(user)} requested plugin statistics")
    structured_logger = get_structured_logger()

    try:
        # Get plugin service
        plugin_service = get_plugin_service()

        # Self-heal the cache from the live framework state.
        await _sync_plugin_service_from_runtime(request, plugin_service)

        # Get statistics
        stats = await plugin_service.get_plugin_statistics()

        # Log marketplace analytics access
        structured_logger.info(
            "User accessed plugin marketplace statistics",
            user_id=get_user_id(user),
            user_email=get_user_email(user),
            component="plugin_marketplace",
            category="business_logic",
            resource_type="plugin_stats",
            resource_action="view",
            custom_fields={
                "total_plugins": stats.get("total_plugins", 0),
                "enabled_plugins": stats.get("enabled_plugins", 0),
                "disabled_plugins": stats.get("disabled_plugins", 0),
                "hooks_count": len(stats.get("plugins_by_hook", {})),
                "tags_count": len(stats.get("plugins_by_tag", {})),
                "authors_count": len(stats.get("plugins_by_author", {})),
            },
        )

        return PluginStatsResponse(**stats)

    except Exception as e:
        LOGGER.error(f"Error getting plugin statistics: {e}")
        structured_logger.error(
            "Failed to get plugin marketplace statistics", user_id=get_user_id(user), user_email=get_user_email(user), error=e, component="plugin_marketplace", category="business_logic"
        )
        raise HTTPException(status_code=500, detail="Failed to retrieve plugin statistics")


@admin_router.get("/plugins/{name}", response_model=PluginDetail)
@require_permission("admin.plugins", allow_admin_bypass=False)
async def get_plugin_details(name: str, request: Request, db: Session = Depends(get_db), user=Depends(get_current_user_with_permissions)) -> PluginDetail:  # pylint: disable=unused-argument
    """Get detailed information about a specific plugin.

    Args:
        name: Plugin name
        request: FastAPI request object
        db: Database session
        user: Authenticated user

    Returns:
        PluginDetail with full plugin information

    Raises:
        HTTPException: If plugin not found
    """
    LOGGER.debug(f"User {get_user_email(user)} requested details for plugin {name}")
    structured_logger = get_structured_logger()
    audit_service = get_audit_trail_service()

    try:
        # Get plugin service
        plugin_service = get_plugin_service()

        # Self-heal the cache from the live framework state.
        await _sync_plugin_service_from_runtime(request, plugin_service)

        # Get plugin details
        plugin = plugin_service.get_plugin_by_name(name)

        if not plugin:
            structured_logger.warning(
                f"Plugin '{name}' not found in marketplace",
                user_id=get_user_id(user),
                user_email=get_user_email(user),
                component="plugin_marketplace",
                category="business_logic",
                custom_fields={"plugin_name": name, "action": "view_details"},
            )
            raise HTTPException(status_code=404, detail=f"Plugin '{name}' not found")

        # Log plugin view activity
        structured_logger.info(
            f"User viewed plugin details: '{name}'",
            user_id=get_user_id(user),
            user_email=get_user_email(user),
            component="plugin_marketplace",
            category="business_logic",
            resource_type="plugin",
            resource_id=name,
            resource_action="view_details",
            custom_fields={
                "plugin_name": name,
                "plugin_version": plugin.get("version"),
                "plugin_author": plugin.get("author"),
                "plugin_status": plugin.get("status"),
                "plugin_mode": plugin.get("mode"),
                "plugin_hooks": plugin.get("hooks", []),
                "plugin_tags": plugin.get("tags", []),
            },
        )

        # Create audit trail for plugin access
        audit_service.log_audit(
            user_id=get_user_id(user), user_email=get_user_email(user), resource_type="plugin", resource_id=name, action="view", description=f"Viewed plugin '{name}' details in marketplace", db=db
        )

        return PluginDetail(**plugin)

    except HTTPException:
        raise
    except Exception as e:
        LOGGER.error(f"Error getting plugin details: {e}")
        structured_logger.error(
            f"Failed to get plugin details: '{name}'", user_id=get_user_id(user), user_email=get_user_email(user), error=e, component="plugin_marketplace", category="business_logic"
        )
        raise HTTPException(status_code=500, detail="Failed to retrieve plugin details")


@admin_router.put("/plugins/{name}", response_model=PluginModeUpdateResponse)
@require_permission("admin.plugins", allow_admin_bypass=False)
async def update_plugin_mode(
    name: str,
    payload: PluginModeUpdateRequest,
    _db: Session = Depends(get_db),  # required by rbac decorator's session lookup
    user=Depends(get_current_user_with_permissions),
) -> PluginModeUpdateResponse:
    """Persist a per-plugin mode override in Redis and invalidate cached managers."""
    # pylint: disable=import-outside-toplevel
    # First-Party
    from mcpgateway.plugins import invalidate_all_plugin_managers, list_configured_plugin_names, publish_plugin_mode_change

    mode = payload.mode

    # Validate against the *configured* plugin set, not the live manager. On a
    # process that booted with plugins globally disabled, no manager is wired
    # and ``PluginService.get_all_plugins()`` returns ``[]``; without this the
    # handler 404s for every valid name and blocks operators from pre-staging
    # a per-plugin mode before turning the subsystem on.
    plugin_names = list_configured_plugin_names()
    if name not in plugin_names:
        raise HTTPException(status_code=404, detail=f"Plugin '{name}' not found. Available: {', '.join(plugin_names[:10])}")

    # publish_plugin_mode_change always updates the in-process override map
    # (so single-node-no-Redis deployments still work) and additionally
    # attempts a Redis SET + publish. The Redis outcome is surfaced back to
    # the caller as redis_persisted so they know whether the change reached
    # other workers.
    redis_persisted = await publish_plugin_mode_change(name, mode)
    # The override is already stored (in-process map and/or Redis) by the line
    # above. Treat the cache sweep as best-effort — if it raises, a background
    # TTL refresh still reaches the new mode, and the operator must not see a
    # 500 for an override that actually took effect.
    try:
        await invalidate_all_plugin_managers()
    except Exception as invalidate_exc:
        LOGGER.warning(
            "Plugin '%s' mode override stored but cache invalidation failed (%s) — fresh managers will rebuild on next TTL expiry",
            name,
            invalidate_exc,
        )

    if redis_persisted:
        LOGGER.info(f"Plugin '{name}' mode changed to '{mode}' by {get_user_email(user)} (Redis persisted, 24h TTL)")
    else:
        LOGGER.warning(f"Plugin '{name}' mode changed to '{mode}' by {get_user_email(user)} (this worker only — Redis unavailable)")

    structured_logger = get_structured_logger()
    structured_logger.info(
        f"Plugin '{name}' mode changed to '{mode}'",
        user_id=get_user_id(user),
        user_email=get_user_email(user),
        component="plugin_runtime",
        category="security",
        resource_type="plugin_mode",
        resource_id=name,
        resource_action="update",
        custom_fields={"plugin_name": name, "new_mode": mode, "redis_persisted": redis_persisted},
    )

    return PluginModeUpdateResponse(plugin=name, mode=mode, redis_persisted=redis_persisted)


##################################################
# MCP Registry Endpoints
##################################################


@admin_router.get("/mcp-registry/servers", response_model=CatalogListResponse)
@require_permission("servers.read", allow_admin_bypass=False)
async def list_catalog_servers(
    _request: Request,
    category: Optional[str] = None,
    auth_type: Optional[str] = None,
    provider: Optional[str] = None,
    search: Optional[str] = None,
    tags: Optional[List[str]] = Query(None),
    show_registered_only: bool = False,
    show_available_only: bool = True,
    limit: int = 100,
    offset: int = 0,
    db: Session = Depends(get_db),
    _user=Depends(get_current_user_with_permissions),
) -> CatalogListResponse:
    """Get list of catalog servers with filtering.

    Args:
        _request: FastAPI request object
        category: Filter by category
        auth_type: Filter by authentication type
        provider: Filter by provider
        search: Search in name/description
        tags: Filter by tags
        show_registered_only: Show only already registered servers
        show_available_only: Show only available servers
        limit: Maximum results
        offset: Pagination offset
        db: Database session
        _user: Authenticated user

    Returns:
        List of catalog servers matching filters

    Raises:
        HTTPException: If the catalog feature is disabled.
    """
    if not settings.mcpgateway_catalog_enabled:
        raise HTTPException(status_code=404, detail="Catalog feature is disabled")

    catalog_request = CatalogListRequest(
        category=category,
        auth_type=auth_type,
        provider=provider,
        search=search,
        tags=tags or [],
        show_registered_only=show_registered_only,
        show_available_only=show_available_only,
        limit=limit,
        offset=offset,
    )

    return await catalog_service.get_catalog_servers(catalog_request, db)


@admin_router.post("/mcp-registry/{server_id}/register", response_model=CatalogServerRegisterResponse)
@require_permission("servers.create", allow_admin_bypass=False)
async def register_catalog_server(
    server_id: str,
    http_request: Request,
    request: Optional[CatalogServerRegisterRequest] = None,
    db: Session = Depends(get_db),
    _user=Depends(get_current_user_with_permissions),
) -> Union[CatalogServerRegisterResponse, HTMLResponse]:
    """Register a catalog server.

    Args:
        server_id: Catalog server ID to register
        http_request: FastAPI request object (for HTMX detection)
        request: Optional registration parameters
        db: Database session
        _user: Authenticated user

    Returns:
        Registration response with success status (JSON or HTML)

    Raises:
        HTTPException: If the catalog feature is disabled.
    """
    if not settings.mcpgateway_catalog_enabled:
        raise HTTPException(status_code=404, detail="Catalog feature is disabled")

    result = await catalog_service.register_catalog_server(catalog_id=server_id, request=request, db=db)

    # Check if this is an HTMX request
    is_htmx = http_request.headers.get("HX-Request") == "true"

    if is_htmx:
        # Return HTML fragment for HTMX - properly escape all dynamic values
        safe_server_id = html.escape(server_id, quote=True)
        safe_message = html.escape(result.message, quote=True)

        if result.success:
            # Check if this is an OAuth server requiring configuration (use explicit flag, not string matching)
            if result.oauth_required:
                # OAuth servers are registered but disabled until configured
                button_fragment = f"""
                <button
                    class="w-full px-4 py-2 bg-yellow-600 text-white rounded-md cursor-default"
                    disabled
                    title="{safe_message}"
                >
                    <svg class="inline-block h-4 w-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
                    </svg>
                    OAuth Config Required
                </button>
                """
                # Trigger refresh - template will show yellow state from requires_oauth_config field
                response = HTMLResponse(content=button_fragment)
                response.headers["HX-Trigger-After-Swap"] = orjson.dumps({"catalogRegistrationSuccess": {"delayMs": 1500}}).decode()
                return response
            # Success: Show success button state
            button_fragment = f"""
            <button
                class="w-full px-4 py-2 bg-green-600 text-white rounded-md cursor-default"
                disabled
                title="{safe_message}"
            >
                <svg class="inline-block h-4 w-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                    <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
                </svg>
                Registered Successfully
            </button>
            """
            # Only non-OAuth success triggers delayed table refresh
            response = HTMLResponse(content=button_fragment)
            response.headers["HX-Trigger-After-Swap"] = orjson.dumps({"catalogRegistrationSuccess": {"delayMs": 1500}}).decode()
            return response
        # Error: Show error state with retry button (no auto-refresh so retry persists)
        error_msg = html.escape(result.error or result.message, quote=True)
        button_fragment = f"""
        <button
            id="{safe_server_id}-register-btn"
            class="w-full px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 transition-colors"
            hx-post="{settings.app_root_path}/admin/mcp-registry/{safe_server_id}/register"
            hx-target="#{safe_server_id}-button-container"
            hx-swap="innerHTML"
            hx-disabled-elt="this"
            hx-on::before-request="this.innerHTML = '<span class=\\'inline-flex items-center\\'><span class=\\'inline-block animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2\\'></span>Retrying...</span>'"
            hx-on::response-error="this.innerHTML = '<span class=\\'inline-flex items-center\\'><svg class=\\'inline-block h-4 w-4 mr-2\\' fill=\\'none\\' stroke=\\'currentColor\\' viewBox=\\'0 0 24 24\\'><path stroke-linecap=\\'round\\' stroke-linejoin=\\'round\\' stroke-width=\\'2\\' d=\\'M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z\\'></path></svg>Network Error - Click to Retry</span>'"
            title="{error_msg}"
        >
            <svg class="inline-block h-4 w-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
            </svg>
            Failed - Click to Retry
        </button>
        """
        # No HX-Trigger for errors - let the retry button persist
        return HTMLResponse(content=button_fragment)

    # Return JSON for non-HTMX requests (API clients)
    return result


@admin_router.get("/mcp-registry/{server_id}/status", response_model=CatalogServerStatusResponse)
@require_permission("servers.read", allow_admin_bypass=False)
async def check_catalog_server_status(
    server_id: str,
    _db: Session = Depends(get_db),
    _user=Depends(get_current_user_with_permissions),
) -> CatalogServerStatusResponse:
    """Check catalog server availability.

    Args:
        server_id: Catalog server ID to check
        _db: Database session
        _user: Authenticated user

    Returns:
        Server status including availability and response time

    Raises:
        HTTPException: If the catalog feature is disabled.
    """
    if not settings.mcpgateway_catalog_enabled:
        raise HTTPException(status_code=404, detail="Catalog feature is disabled")

    return await catalog_service.check_server_availability(server_id)


@admin_router.post("/mcp-registry/bulk-register", response_model=CatalogBulkRegisterResponse)
@require_permission("servers.create", allow_admin_bypass=False)
async def bulk_register_catalog_servers(
    request: CatalogBulkRegisterRequest,
    db: Session = Depends(get_db),
    _user=Depends(get_current_user_with_permissions),
) -> CatalogBulkRegisterResponse:
    """Register multiple catalog servers at once.

    Args:
        request: Bulk registration request with server IDs
        db: Database session
        _user: Authenticated user

    Returns:
        Bulk registration response with success/failure details

    Raises:
        HTTPException: If the catalog feature is disabled.
    """
    if not settings.mcpgateway_catalog_enabled:
        raise HTTPException(status_code=404, detail="Catalog feature is disabled")

    return await catalog_service.bulk_register_servers(request, db)


@admin_router.get("/mcp-registry/partial")
@require_permission("servers.read", allow_admin_bypass=False)
async def catalog_partial(
    request: Request,
    category: Optional[str] = None,
    auth_type: Optional[str] = None,
    search: Optional[str] = None,
    page: int = 1,
    db: Session = Depends(get_db),
    _user=Depends(get_current_user_with_permissions),
) -> HTMLResponse:
    """Get HTML partial for catalog servers (used by HTMX).

    Args:
        request: FastAPI request object
        category: Filter by category
        auth_type: Filter by authentication type
        search: Search term
        page: Page number (1-indexed)
        db: Database session
        _user: Authenticated user

    Returns:
        HTML partial with filtered catalog servers

    Raises:
        HTTPException: If the catalog feature is disabled.
    """
    if not settings.mcpgateway_catalog_enabled:
        raise HTTPException(status_code=404, detail="Catalog feature is disabled")

    root_path = _resolve_root_path(request)

    # Calculate pagination
    page_size = settings.mcpgateway_catalog_page_size
    offset = (page - 1) * page_size

    catalog_request = CatalogListRequest(category=category, auth_type=auth_type, search=search, show_available_only=False, limit=page_size, offset=offset)

    response = await catalog_service.get_catalog_servers(catalog_request, db)

    # Get ALL servers (no filters, no pagination) for counting statistics
    all_servers_request = CatalogListRequest(show_available_only=False, limit=1000, offset=0)
    all_servers_response = await catalog_service.get_catalog_servers(all_servers_request, db)

    # Pass filter parameters to template for pagination links
    filter_params = {
        "category": category,
        "auth_type": auth_type,
        "search": search,
    }

    # Calculate statistics and pagination info
    total_servers = response.total
    registered_count = sum(1 for s in response.servers if s.is_registered)
    total_pages = (total_servers + page_size - 1) // page_size  # Ceiling division

    # Count ALL servers by category, auth type, and provider (not just current page)
    servers_by_category = {}
    servers_by_auth_type = {}
    servers_by_provider = {}

    for server in all_servers_response.servers:
        servers_by_category[server.category] = servers_by_category.get(server.category, 0) + 1
        servers_by_auth_type[server.auth_type] = servers_by_auth_type.get(server.auth_type, 0) + 1
        servers_by_provider[server.provider] = servers_by_provider.get(server.provider, 0) + 1

    stats = {
        "total_servers": all_servers_response.total,  # Use total from all servers
        "registered_servers": registered_count,
        "categories": all_servers_response.categories,
        "auth_types": all_servers_response.auth_types,
        "providers": all_servers_response.providers,
        "servers_by_category": servers_by_category,
        "servers_by_auth_type": servers_by_auth_type,
        "servers_by_provider": servers_by_provider,
    }

    context = {
        "request": request,
        "servers": response.servers,
        "stats": stats,
        "root_path": root_path,
        "page": page,
        "total_pages": total_pages,
        "page_size": page_size,
        "filter_params": filter_params,
    }

    return request.app.state.templates.TemplateResponse(request, "mcp_registry_partial.html", context)


# ===================================
# System Metrics Endpoints
# ===================================


@admin_router.get("/system/stats")
@require_permission("admin.system_config", allow_admin_bypass=False)
async def get_system_stats(
    request: Request,
    db: Session = Depends(get_db),
    user=Depends(get_current_user_with_permissions),
):
    """Get comprehensive system metrics for administrators.

    Returns detailed counts across all entity types including users, teams,
    MCP resources (servers, tools, resources, prompts, A2A agents, gateways),
    API tokens, sessions, metrics, security events, and workflow state.

    Designed for capacity planning, performance optimization, and demonstrating
    system capabilities to administrators.

    Args:
        request: FastAPI request object
        db: Database session dependency
        user: Authenticated user from dependency (must have admin access)

    Returns:
        HTMLResponse or JSONResponse: Comprehensive system metrics
        Returns HTML partial when requested via HTMX, JSON otherwise

    Raises:
        HTTPException: If metrics collection fails

    Examples:
        >>> # Request system metrics via API
        >>> # GET /admin/system/stats
        >>> # Returns JSON with users, teams, mcp_resources, tokens, sessions, metrics, security, workflow
    """
    try:
        LOGGER.info(f"System metrics requested by user: {user}")

        # First-Party
        from mcpgateway.services.system_stats_service import SystemStatsService  # pylint: disable=import-outside-toplevel

        # Get metrics (using cached version for performance)
        service = SystemStatsService()
        stats = await service.get_comprehensive_stats_cached(db)

        LOGGER.info(f"System metrics retrieved successfully for user {user}")

        # Check if this is an HTMX request for HTML partial
        if request.headers.get("hx-request"):
            # Return HTML partial for HTMX
            return request.app.state.templates.TemplateResponse(
                request,
                "metrics_partial.html",
                {
                    "request": request,
                    "stats": stats,
                    "root_path": _resolve_root_path(request),
                    "db_metrics_recording_enabled": settings.db_metrics_recording_enabled,
                },
            )

        # Return JSON for API requests
        return ORJSONResponse(content=stats)

    except Exception as e:
        LOGGER.error(f"System metrics retrieval failed for user {user}: {str(e)}", exc_info=True)
        raise HTTPException(status_code=500, detail="Failed to retrieve system metrics")


# ===================================
# Support Bundle Endpoints
# ===================================


@admin_router.get("/support-bundle/generate")
@require_permission("admin.system_config", allow_admin_bypass=False)
async def admin_generate_support_bundle(
    log_lines: int = Query(default=1000, description="Number of log lines to include"),
    include_logs: bool = Query(default=True, description="Include log files"),
    include_env: bool = Query(default=True, description="Include environment config"),
    include_system: bool = Query(default=True, description="Include system info"),
    user=Depends(get_current_user_with_permissions),
    _db: Session = Depends(get_db),
):
    """
    Generate and download a support bundle with sanitized diagnostics.

    Creates a ZIP file containing version info, system diagnostics, configuration,
    and logs with automatic sanitization of sensitive data (passwords, tokens, secrets).

    Args:
        log_lines: Number of log lines to include (default: 1000, 0 = all)
        include_logs: Include log files in bundle (default: True)
        include_env: Include environment configuration (default: True)
        include_system: Include system diagnostics (default: True)
        user: Authenticated user from dependency
        _db: Database session for permission checks.

    Returns:
        Response: ZIP file download with support bundle

    Raises:
        HTTPException: If bundle generation fails

    Examples:
        >>> # Request support bundle via API
        >>> # GET /admin/support-bundle/generate?log_lines=500
        >>> # Returns: mcpgateway-support-YYYY-MM-DD-HHMMSS.zip
    """
    try:
        LOGGER.info(f"Support bundle generation requested by user: {user}")

        # First-Party
        from mcpgateway.services.support_bundle_service import SupportBundleConfig, SupportBundleService  # pylint: disable=import-outside-toplevel

        # Create configuration
        config = SupportBundleConfig(
            include_logs=include_logs,
            include_env=include_env,
            include_system_info=include_system,
            log_tail_lines=log_lines,
            output_dir=Path(tempfile.gettempdir()),
        )

        # Generate bundle
        service = SupportBundleService()
        bundle_path = service.generate_bundle(config)

        # Return as downloadable file using FileResponse (streams asynchronously)
        timestamp = datetime.now().strftime("%Y-%m-%d-%H%M%S")
        filename = f"mcpgateway-support-{timestamp}.zip"

        # Pre-stat for Content-Length header and logging
        bundle_stat = bundle_path.stat()
        LOGGER.info(f"Support bundle generated successfully for user {user}: {filename} ({bundle_stat.st_size} bytes)")

        # Use BackgroundTask to clean up temp file after response is sent
        return FileResponse(
            path=bundle_path,
            media_type="application/zip",
            filename=filename,
            stat_result=bundle_stat,
            background=BackgroundTask(lambda: bundle_path.unlink(missing_ok=True)),
        )

    except Exception as e:
        LOGGER.error(f"Support bundle generation failed for user {user}: {str(e)}", exc_info=True)
        raise HTTPException(status_code=500, detail="Failed to generate support bundle")


# ============================================================================
# Maintenance Routes (Platform Admin Only)
# ============================================================================


@admin_router.get("/maintenance/partial", response_class=HTMLResponse)
@require_permission("admin.system_config", allow_admin_bypass=False)
async def get_maintenance_partial(
    request: Request,
    _user=Depends(get_current_user_with_permissions),
    _db: Session = Depends(get_db),
):
    """Render the maintenance dashboard partial (platform admin only).

    This endpoint returns the maintenance UI panel which includes:
    - Metrics cleanup controls
    - Metrics rollup controls
    - System health status

    Only platform administrators can access this endpoint.

    Args:
        request: FastAPI request object
        _user: Authenticated user with admin permissions
        _db: Database session for permission checks.

    Returns:
        HTMLResponse: Rendered maintenance dashboard template

    Raises:
        HTTPException: 403 if user is not a platform admin
    """
    root_path = _resolve_root_path(request)

    # Build payload with settings for the template
    payload = {
        "settings": {
            "metrics_cleanup_enabled": getattr(settings, "metrics_cleanup_enabled", False),
            "metrics_rollup_enabled": getattr(settings, "metrics_rollup_enabled", False),
            "metrics_retention_days": getattr(settings, "metrics_retention_days", 30),
        }
    }

    return request.app.state.templates.TemplateResponse(
        request,
        "maintenance_partial.html",
        {"request": request, "payload": payload, "root_path": root_path},
    )


# ============================================================================
# Observability Routes
# ============================================================================


@admin_router.get("/observability/partial", response_class=HTMLResponse)
@require_permission("admin.system_config", allow_admin_bypass=False)
async def get_observability_partial(request: Request, _user=Depends(get_current_user_with_permissions), _db: Session = Depends(get_db)):
    """Render the observability dashboard partial.

    Args:
        request: FastAPI request object
        _user: Authenticated user with admin permissions (required by dependency)
        _db: Database session for permission checks.

    Returns:
        HTMLResponse: Rendered observability dashboard template
    """
    root_path = _resolve_root_path(request)
    return request.app.state.templates.TemplateResponse(request, "observability_partial.html", {"request": request, "root_path": root_path})


@admin_router.get("/observability/metrics/partial", response_class=HTMLResponse)
@require_permission("admin.system_config", allow_admin_bypass=False)
async def get_observability_metrics_partial(request: Request, _user=Depends(get_current_user_with_permissions), _db: Session = Depends(get_db)):
    """Render the advanced metrics dashboard partial.

    Args:
        request: FastAPI request object
        _user: Authenticated user with admin permissions (required by dependency)
        _db: Database session for permission checks.

    Returns:
        HTMLResponse: Rendered metrics dashboard template
    """
    root_path = _resolve_root_path(request)
    return request.app.state.templates.TemplateResponse(request, "observability_metrics.html", {"request": request, "root_path": root_path})


@admin_router.get("/observability/stats", response_class=HTMLResponse)
@require_permission("admin.system_config", allow_admin_bypass=False)
async def get_observability_stats(request: Request, hours: int = Query(24, ge=1, le=168), _user=Depends(get_current_user_with_permissions), db: Session = Depends(get_db)):
    """Get observability statistics for the dashboard.

    Args:
        request: FastAPI request object
        hours: Number of hours to look back for statistics (1-168)
        _user: Authenticated user with admin permissions (required by dependency)
        db: Database session for permission checks.

    Returns:
        HTMLResponse: Rendered statistics template with trace counts and averages
    """
    db = next(get_db())
    try:
        cutoff_time = datetime.now() - timedelta(hours=hours)

        # Consolidate multiple count queries into a single aggregated select
        # Filter by start_time first (uses index), then aggregate by status
        result = db.execute(
            select(
                func.count(ObservabilityTrace.trace_id).label("total_traces"),  # pylint: disable=not-callable
                func.sum(case((ObservabilityTrace.status == "ok", 1), else_=0)).label("success_count"),
                func.sum(case((ObservabilityTrace.status == "error", 1), else_=0)).label("error_count"),
                func.avg(ObservabilityTrace.duration_ms).label("avg_duration_ms"),
            ).where(ObservabilityTrace.start_time >= cutoff_time)
        ).one()

        stats = {
            "total_traces": int(result.total_traces or 0),
            "success_count": int(result.success_count or 0),
            "error_count": int(result.error_count or 0),
            "avg_duration_ms": float(result.avg_duration_ms or 0),
        }

        return request.app.state.templates.TemplateResponse(request, "observability_stats.html", {"request": request, "stats": stats})
    finally:
        # Ensure close() always runs even if commit() fails
        try:
            db.commit()  # Commit read-only transaction to avoid implicit rollback
        finally:
            db.close()


@admin_router.get("/observability/traces", response_class=HTMLResponse)
@require_permission("admin.system_config", allow_admin_bypass=False)
async def get_observability_traces(
    request: Request,
    time_range: QueryTimeRange = "24h",
    status_filter: QueryStatusFilter = "all",
    limit: int = Query(50, ge=1, le=1000),
    min_duration: Optional[float] = Query(None, ge=0),
    max_duration: Optional[float] = Query(None, ge=0),
    http_method: QueryHttpMethod = None,
    user_email: QueryUserIdentifierNoDescription = None,
    name_search: Optional[str] = Query(None, max_length=500),
    attribute_search: Optional[str] = Query(None, max_length=500),
    # tool_name pattern follows MCP SEP-986 (Specify Format for Tool Names), matching
    # mcpgateway.config.Settings.validation_tool_name_pattern. Allows namespacing via '/'.
    tool_name: QueryToolName = None,
    _user=Depends(get_current_user_with_permissions),
    db: Session = Depends(get_db),
):
    """Get list of traces for the dashboard.

    Args:
        request: FastAPI request object
        time_range: Time range filter (1h, 6h, 24h, 7d)
        status_filter: Status filter (all, ok, error)
        limit: Maximum number of traces to return
        min_duration: Minimum duration in ms
        max_duration: Maximum duration in ms
        http_method: HTTP method filter
        user_email: User email filter
        name_search: Trace name search
        attribute_search: Full-text attribute search
        tool_name: Filter by tool name (shows traces that invoked this tool)
        _user: Authenticated user with admin permissions (required by dependency)
        db: Database session for permission checks.

    Returns:
        HTMLResponse: Rendered traces list template
    """
    db = next(get_db())
    try:
        # Parse time range
        time_map = {"1h": 1, "6h": 6, "24h": 24, "7d": 168}
        hours = time_map.get(time_range, 24)
        cutoff_time = datetime.now() - timedelta(hours=hours)

        query = db.query(ObservabilityTrace).filter(ObservabilityTrace.start_time >= cutoff_time)

        # Apply status filter
        if status_filter != "all":
            query = query.filter(ObservabilityTrace.status == status_filter)

        # Apply duration filters
        if min_duration is not None:
            query = query.filter(ObservabilityTrace.duration_ms >= min_duration)
        if max_duration is not None:
            query = query.filter(ObservabilityTrace.duration_ms <= max_duration)

        # Apply HTTP method filter
        if http_method:
            query = query.filter(ObservabilityTrace.http_method == http_method)

        # Apply user email filter
        if user_email:
            query = query.filter(ObservabilityTrace.user_email.ilike(f"%{user_email}%"))

        # Apply name search
        if name_search:
            query = query.filter(ObservabilityTrace.name.ilike(f"%{name_search}%"))

        # Apply attribute search
        if attribute_search:
            # Escape special characters for SQL LIKE
            safe_search = attribute_search.replace("%", "\\%").replace("_", "\\_")
            query = query.filter(cast(ObservabilityTrace.attributes, String).ilike(f"%{safe_search}%"))

        # Apply tool name filter (join with spans to find traces that invoked a specific tool)
        if tool_name:
            # Subquery to find trace_ids that have tool invocations matching the tool name
            tool_trace_ids = (
                db.query(ObservabilitySpan.trace_id)
                .filter(
                    ObservabilitySpan.name == "tool.invoke",
                    extract_json_field(ObservabilitySpan.attributes, '$."tool.name"').ilike(f"%{tool_name}%"),
                )
                .distinct()
                .subquery()
            )
            query = query.filter(ObservabilityTrace.trace_id.in_(select(tool_trace_ids.c.trace_id)))

        # Get traces ordered by most recent
        traces = query.order_by(ObservabilityTrace.start_time.desc()).limit(limit).all()

        root_path = _resolve_root_path(request)
        return request.app.state.templates.TemplateResponse(request, "observability_traces_list.html", {"request": request, "traces": traces, "root_path": root_path})
    finally:
        # Ensure close() always runs even if commit() fails
        try:
            db.commit()  # Commit read-only transaction to avoid implicit rollback
        finally:
            db.close()


@admin_router.get("/observability/trace/{trace_id}", response_class=HTMLResponse)
@require_permission("admin.system_config", allow_admin_bypass=False)
async def get_observability_trace_detail(request: Request, trace_id: str, _user=Depends(get_current_user_with_permissions), db: Session = Depends(get_db)):
    """Get detailed trace information with spans.

    Args:
        request: FastAPI request object
        trace_id: UUID of the trace to retrieve
        _user: Authenticated user with admin permissions (required by dependency)
        db: Database session for permission checks.

    Returns:
        HTMLResponse: Rendered trace detail template with waterfall view

    Raises:
        HTTPException: 404 if trace not found
    """
    db = next(get_db())
    try:
        trace = db.query(ObservabilityTrace).filter_by(trace_id=trace_id).options(joinedload(ObservabilityTrace.spans).joinedload(ObservabilitySpan.events)).first()

        if not trace:
            raise HTTPException(status_code=404, detail="Trace not found")

        root_path = _resolve_root_path(request)
        return request.app.state.templates.TemplateResponse(request, "observability_trace_detail.html", {"request": request, "trace": trace, "root_path": root_path})
    finally:
        # Ensure close() always runs even if commit() fails
        try:
            db.commit()  # Commit read-only transaction to avoid implicit rollback
        finally:
            db.close()


@admin_router.post("/observability/queries", response_model=dict)
@require_permission("admin.system_config", allow_admin_bypass=False)
async def save_observability_query(
    request: Request,  # pylint: disable=unused-argument
    name: str = Body(..., description="Name for the saved query"),
    description: Optional[str] = Body(None, description="Optional description"),
    filter_config: dict = Body(..., description="Filter configuration as JSON"),
    is_shared: bool = Body(False, description="Whether query is shared with team"),
    user=Depends(get_current_user_with_permissions),
    db: Session = Depends(get_db),
):
    """Save a new observability query filter configuration.

    Args:
        request: FastAPI request object
        name: User-given name for the query
        description: Optional description
        filter_config: Dictionary containing all filter values
        is_shared: Whether this query is visible to other users
        user: Authenticated user (required by dependency)
        db: Database session for permission checks.

    Returns:
        dict: Created query details with id

    Raises:
        HTTPException: 400 if validation fails
    """
    db = next(get_db())
    try:
        # Get user email from authenticated user
        user_email = user.email if hasattr(user, "email") else "unknown"

        # Create new saved query
        query = ObservabilitySavedQuery(name=name, description=description, user_email=user_email, filter_config=filter_config, is_shared=is_shared)

        db.add(query)
        db.commit()
        db.refresh(query)

        return {"id": query.id, "name": query.name, "description": query.description, "filter_config": query.filter_config, "is_shared": query.is_shared, "created_at": query.created_at.isoformat()}
    except Exception as e:
        db.rollback()
        LOGGER.error(f"Failed to save query: {e}")
        raise HTTPException(status_code=400, detail=str(e))
    finally:
        # Ensure close() always runs even if commit() fails
        try:
            db.commit()  # Commit read-only transaction to avoid implicit rollback
        finally:
            db.close()


@admin_router.get("/observability/queries", response_model=list)
@require_permission("admin.system_config", allow_admin_bypass=False)
async def list_observability_queries(request: Request, user=Depends(get_current_user_with_permissions), db: Session = Depends(get_db)):  # pylint: disable=unused-argument
    """List saved observability queries for the current user.

    Returns user's own queries plus any shared queries.

    Args:
        request: FastAPI request object
        user: Authenticated user (required by dependency)
        db: Database session for permission checks.

    Returns:
        list: List of saved query dictionaries
    """
    db = next(get_db())
    try:
        user_email = user.email if hasattr(user, "email") else "unknown"

        # Get user's own queries + shared queries
        queries = (
            db.query(ObservabilitySavedQuery)
            .filter(or_(ObservabilitySavedQuery.user_email == user_email, ObservabilitySavedQuery.is_shared is True))
            .order_by(desc(ObservabilitySavedQuery.created_at))
            .all()
        )

        return [
            {
                "id": q.id,
                "name": q.name,
                "description": q.description,
                "filter_config": q.filter_config,
                "is_shared": q.is_shared,
                "user_email": q.user_email,
                "created_at": q.created_at.isoformat(),
                "last_used_at": q.last_used_at.isoformat() if q.last_used_at else None,
                "use_count": q.use_count,
            }
            for q in queries
        ]
    finally:
        # Ensure close() always runs even if commit() fails
        try:
            db.commit()  # Commit read-only transaction to avoid implicit rollback
        finally:
            db.close()


@admin_router.get("/observability/queries/{query_id}", response_model=dict)
@require_permission("admin.system_config", allow_admin_bypass=False)
async def get_observability_query(request: Request, query_id: int, user=Depends(get_current_user_with_permissions), db: Session = Depends(get_db)):  # pylint: disable=unused-argument
    """Get a specific saved query by ID.

    Args:
        request: FastAPI request object
        query_id: ID of the saved query
        user: Authenticated user (required by dependency)
        db: Database session for permission checks.

    Returns:
        dict: Query details

    Raises:
        HTTPException: 404 if query not found or unauthorized
    """
    db = next(get_db())
    try:
        user_email = user.email if hasattr(user, "email") else "unknown"

        # Can only access own queries or shared queries
        query = (
            db.query(ObservabilitySavedQuery).filter(ObservabilitySavedQuery.id == query_id, or_(ObservabilitySavedQuery.user_email == user_email, ObservabilitySavedQuery.is_shared is True)).first()
        )

        if not query:
            raise HTTPException(status_code=404, detail="Query not found or unauthorized")

        return {
            "id": query.id,
            "name": query.name,
            "description": query.description,
            "filter_config": query.filter_config,
            "is_shared": query.is_shared,
            "user_email": query.user_email,
            "created_at": query.created_at.isoformat(),
            "last_used_at": query.last_used_at.isoformat() if query.last_used_at else None,
            "use_count": query.use_count,
        }
    finally:
        # Ensure close() always runs even if commit() fails
        try:
            db.commit()  # Commit read-only transaction to avoid implicit rollback
        finally:
            db.close()


@admin_router.put("/observability/queries/{query_id}", response_model=dict)
@require_permission("admin.system_config", allow_admin_bypass=False)
async def update_observability_query(
    request: Request,  # pylint: disable=unused-argument
    query_id: int,
    name: Optional[str] = Body(None),
    description: Optional[str] = Body(None),
    filter_config: Optional[dict] = Body(None),
    is_shared: Optional[bool] = Body(None),
    user=Depends(get_current_user_with_permissions),
    db: Session = Depends(get_db),
):
    """Update an existing saved query.

    Args:
        request: FastAPI request object
        query_id: ID of the query to update
        name: New name (optional)
        description: New description (optional)
        filter_config: New filter configuration (optional)
        is_shared: New sharing status (optional)
        user: Authenticated user (required by dependency)
        db: Database session for permission checks.

    Returns:
        dict: Updated query details

    Raises:
        HTTPException: 404 if query not found, 403 if unauthorized
    """
    db = next(get_db())
    try:
        user_email = user.email if hasattr(user, "email") else "unknown"

        # Can only update own queries
        query = db.query(ObservabilitySavedQuery).filter(ObservabilitySavedQuery.id == query_id, ObservabilitySavedQuery.user_email == user_email).first()

        if not query:
            raise HTTPException(status_code=404, detail="Query not found or unauthorized")

        # Update fields if provided
        if name is not None:
            query.name = name
        if description is not None:
            query.description = description
        if filter_config is not None:
            query.filter_config = filter_config
        if is_shared is not None:
            query.is_shared = is_shared

        db.commit()
        db.refresh(query)

        return {
            "id": query.id,
            "name": query.name,
            "description": query.description,
            "filter_config": query.filter_config,
            "is_shared": query.is_shared,
            "updated_at": query.updated_at.isoformat(),
        }
    except HTTPException:
        raise
    except Exception as e:
        db.rollback()
        LOGGER.error(f"Failed to update query: {e}")
        raise HTTPException(status_code=400, detail=str(e))
    finally:
        # Ensure close() always runs even if commit() fails
        try:
            db.commit()  # Commit read-only transaction to avoid implicit rollback
        finally:
            db.close()


@admin_router.delete("/observability/queries/{query_id}", status_code=204)
@require_permission("admin.system_config", allow_admin_bypass=False)
async def delete_observability_query(request: Request, query_id: int, user=Depends(get_current_user_with_permissions), db: Session = Depends(get_db)):  # pylint: disable=unused-argument
    """Delete a saved query.

    Args:
        request: FastAPI request object
        query_id: ID of the query to delete
        user: Authenticated user (required by dependency)
        db: Database session for permission checks.

    Raises:
        HTTPException: 404 if query not found, 403 if unauthorized
    """
    db = next(get_db())
    try:
        user_email = user.email if hasattr(user, "email") else "unknown"

        # Can only delete own queries
        query = db.query(ObservabilitySavedQuery).filter(ObservabilitySavedQuery.id == query_id, ObservabilitySavedQuery.user_email == user_email).first()

        if not query:
            raise HTTPException(status_code=404, detail="Query not found or unauthorized")

        db.delete(query)
        db.commit()
    finally:
        # Ensure close() always runs even if commit() fails
        try:
            db.commit()  # Commit read-only transaction to avoid implicit rollback
        finally:
            db.close()


@admin_router.post("/observability/queries/{query_id}/use", response_model=dict)
@require_permission("admin.system_config", allow_admin_bypass=False)
async def track_query_usage(request: Request, query_id: int, user=Depends(get_current_user_with_permissions), db: Session = Depends(get_db)):  # pylint: disable=unused-argument
    """Track usage of a saved query (increments use count and updates last_used_at).

    Args:
        request: FastAPI request object
        query_id: ID of the query being used
        user: Authenticated user (required by dependency)
        db: Database session for permission checks.

    Returns:
        dict: Updated query usage stats

    Raises:
        HTTPException: 404 if query not found or unauthorized
    """
    db = next(get_db())
    try:
        user_email = user.email if hasattr(user, "email") else "unknown"

        # Can track usage for own queries or shared queries
        query = (
            db.query(ObservabilitySavedQuery).filter(ObservabilitySavedQuery.id == query_id, or_(ObservabilitySavedQuery.user_email == user_email, ObservabilitySavedQuery.is_shared is True)).first()
        )

        if not query:
            raise HTTPException(status_code=404, detail="Query not found or unauthorized")

        # Update usage tracking
        query.use_count += 1
        query.last_used_at = utc_now()

        db.commit()
        db.refresh(query)

        return {"use_count": query.use_count, "last_used_at": query.last_used_at.isoformat()}
    except HTTPException:
        raise
    except Exception as e:
        db.rollback()
        LOGGER.error(f"Failed to track query usage: {e}")
        raise HTTPException(status_code=400, detail=str(e))
    finally:
        # Ensure close() always runs even if commit() fails
        try:
            db.commit()  # Commit read-only transaction to avoid implicit rollback
        finally:
            db.close()


@admin_router.get("/observability/metrics/percentiles", response_model=dict)
@require_permission("admin.system_config", allow_admin_bypass=False)
async def get_latency_percentiles(
    request: Request,  # pylint: disable=unused-argument
    hours: int = Query(24, ge=1, le=168, description="Time range in hours"),
    interval_minutes: int = Query(60, ge=5, le=1440, description="Aggregation interval in minutes"),
    _user=Depends(get_current_user_with_permissions),
    db: Session = Depends(get_db),
):
    """Get latency percentiles (p50, p90, p95, p99) over time.

    Args:
        request: FastAPI request object
        hours: Number of hours to look back (1-168)
        interval_minutes: Aggregation interval in minutes (5-1440)
        _user: Authenticated user (required by dependency)
        db: Database session for permission checks.

    Returns:
        dict: Time-series data with percentiles

    Raises:
        HTTPException: 500 if calculation fails
    """
    db = next(get_db())
    try:
        cutoff_time = datetime.now(timezone.utc) - timedelta(hours=hours)

        # Use SQL aggregation for PostgreSQL, Python fallback for SQLite
        dialect_name = db.get_bind().dialect.name
        if dialect_name == "postgresql":
            return _get_latency_percentiles_postgresql(db, cutoff_time, interval_minutes)
        return _get_latency_percentiles_python(db, cutoff_time, interval_minutes)
    except Exception as e:
        LOGGER.error(f"Failed to calculate latency percentiles: {e}")
        raise HTTPException(status_code=500, detail="Failed to calculate latency percentiles")
    finally:
        # Ensure close() always runs even if commit() fails
        try:
            db.commit()  # Commit read-only transaction to avoid implicit rollback
        finally:
            db.close()


def _get_latency_percentiles_postgresql(db: Session, cutoff_time: datetime, interval_minutes: int) -> dict:
    """Compute time-bucketed latency percentiles using PostgreSQL.

    Args:
        db: Database session
        cutoff_time: Start time for analysis
        interval_minutes: Bucket size in minutes

    Returns:
        dict: Time-series percentile data
    """
    # PostgreSQL query with epoch-based bucketing (works for any interval including > 60 min)
    stats_sql = text("""
        SELECT
            TO_TIMESTAMP(FLOOR(EXTRACT(EPOCH FROM start_time) / :interval_seconds) * :interval_seconds) as bucket,
            percentile_cont(0.50) WITHIN GROUP (ORDER BY duration_ms) as p50,
            percentile_cont(0.90) WITHIN GROUP (ORDER BY duration_ms) as p90,
            percentile_cont(0.95) WITHIN GROUP (ORDER BY duration_ms) as p95,
            percentile_cont(0.99) WITHIN GROUP (ORDER BY duration_ms) as p99
        FROM observability_traces
        WHERE start_time >= :cutoff_time AND duration_ms IS NOT NULL
        GROUP BY bucket
        ORDER BY bucket
        """)

    interval_seconds = interval_minutes * 60
    results = db.execute(stats_sql, {"cutoff_time": cutoff_time, "interval_seconds": interval_seconds}).fetchall()

    if not results:
        return {"timestamps": [], "p50": [], "p90": [], "p95": [], "p99": []}

    timestamps = []
    p50_values = []
    p90_values = []
    p95_values = []
    p99_values = []

    for row in results:
        timestamps.append(row.bucket.isoformat() if row.bucket else "")
        p50_values.append(round(float(row.p50), 2) if row.p50 else 0)
        p90_values.append(round(float(row.p90), 2) if row.p90 else 0)
        p95_values.append(round(float(row.p95), 2) if row.p95 else 0)
        p99_values.append(round(float(row.p99), 2) if row.p99 else 0)

    return {"timestamps": timestamps, "p50": p50_values, "p90": p90_values, "p95": p95_values, "p99": p99_values}


def _get_latency_percentiles_python(db: Session, cutoff_time: datetime, interval_minutes: int) -> dict:
    """Compute time-bucketed latency percentiles using Python (fallback for SQLite).

    Args:
        db: Database session
        cutoff_time: Start time for analysis
        interval_minutes: Bucket size in minutes

    Returns:
        dict: Time-series percentile data
    """
    # Query all traces with duration in time range
    traces = (
        db.query(ObservabilityTrace.start_time, ObservabilityTrace.duration_ms)
        .filter(ObservabilityTrace.start_time >= cutoff_time, ObservabilityTrace.duration_ms.isnot(None))
        .order_by(ObservabilityTrace.start_time)
        .all()
    )

    if not traces:
        return {"timestamps": [], "p50": [], "p90": [], "p95": [], "p99": []}

    # Group traces into time buckets using epoch-based bucketing (works for any interval)
    interval_seconds = interval_minutes * 60
    buckets: Dict[datetime, List[float]] = defaultdict(list)
    for trace in traces:
        trace_time = trace.start_time
        if trace_time.tzinfo is None:
            trace_time = trace_time.replace(tzinfo=timezone.utc)
        epoch = trace_time.timestamp()
        bucket_epoch = (epoch // interval_seconds) * interval_seconds
        bucket_time = datetime.fromtimestamp(bucket_epoch, tz=timezone.utc)
        buckets[bucket_time].append(trace.duration_ms)

    # Calculate percentiles for each bucket
    timestamps = []
    p50_values = []
    p90_values = []
    p95_values = []
    p99_values = []

    def percentile_cont(data: List[float], p: float) -> float:
        """Linear interpolation percentile matching PostgreSQL percentile_cont.

        Args:
            data: Sorted list of float values.
            p: Percentile value between 0 and 1.

        Returns:
            float: Interpolated percentile value.
        """
        n = len(data)
        k = p * (n - 1)
        f = int(k)
        c = k - f
        next_i = min(f + 1, n - 1)
        return data[f] + c * (data[next_i] - data[f])

    for bucket_time in sorted(buckets.keys()):
        durations = sorted(buckets[bucket_time])

        if durations:
            timestamps.append(bucket_time.isoformat())
            p50_values.append(round(percentile_cont(durations, 0.50), 2))
            p90_values.append(round(percentile_cont(durations, 0.90), 2))
            p95_values.append(round(percentile_cont(durations, 0.95), 2))
            p99_values.append(round(percentile_cont(durations, 0.99), 2))

    return {"timestamps": timestamps, "p50": p50_values, "p90": p90_values, "p95": p95_values, "p99": p99_values}


@admin_router.get("/observability/metrics/timeseries", response_model=dict)
@require_permission("admin.system_config", allow_admin_bypass=False)
async def get_timeseries_metrics(
    request: Request,  # pylint: disable=unused-argument
    hours: int = Query(24, ge=1, le=168, description="Time range in hours"),
    interval_minutes: int = Query(60, ge=5, le=1440, description="Aggregation interval in minutes"),
    _user=Depends(get_current_user_with_permissions),
    db: Session = Depends(get_db),
):
    """Get time-series metrics (request rate, error rate, throughput).

    Args:
        request: FastAPI request object
        hours: Number of hours to look back (1-168)
        interval_minutes: Aggregation interval in minutes (5-1440)
        _user: Authenticated user (required by dependency)
        db: Database session for permission checks.

    Returns:
        dict: Time-series data with request counts, error rates, and throughput

    Raises:
        HTTPException: 500 if calculation fails
    """
    db = next(get_db())
    try:
        cutoff_time = datetime.now(timezone.utc) - timedelta(hours=hours)

        # Use SQL aggregation for PostgreSQL, Python fallback for SQLite
        dialect_name = db.get_bind().dialect.name
        if dialect_name == "postgresql":
            return _get_timeseries_metrics_postgresql(db, cutoff_time, interval_minutes)
        return _get_timeseries_metrics_python(db, cutoff_time, interval_minutes)
    except Exception as e:
        LOGGER.error(f"Failed to calculate timeseries metrics: {e}")
        raise HTTPException(status_code=500, detail="Failed to calculate timeseries metrics")
    finally:
        # Ensure close() always runs even if commit() fails
        try:
            db.commit()  # Commit read-only transaction to avoid implicit rollback
        finally:
            db.close()


def _get_timeseries_metrics_postgresql(db: Session, cutoff_time: datetime, interval_minutes: int) -> dict:
    """Compute time-series metrics using PostgreSQL.

    Args:
        db: Database session
        cutoff_time: Start time for analysis
        interval_minutes: Bucket size in minutes

    Returns:
        dict: Time-series metrics data
    """
    # Use epoch-based bucketing (works for any interval including > 60 min)
    stats_sql = text("""
        SELECT
            TO_TIMESTAMP(FLOOR(EXTRACT(EPOCH FROM start_time) / :interval_seconds) * :interval_seconds) as bucket,
            COUNT(*) as total,
            SUM(CASE WHEN status = 'ok' THEN 1 ELSE 0 END) as success,
            SUM(CASE WHEN status = 'error' THEN 1 ELSE 0 END) as error
        FROM observability_traces
        WHERE start_time >= :cutoff_time
        GROUP BY bucket
        ORDER BY bucket
        """)

    interval_seconds = interval_minutes * 60
    results = db.execute(stats_sql, {"cutoff_time": cutoff_time, "interval_seconds": interval_seconds}).fetchall()

    if not results:
        return {"timestamps": [], "request_count": [], "success_count": [], "error_count": [], "error_rate": []}

    timestamps = []
    request_counts = []
    success_counts = []
    error_counts = []
    error_rates = []

    for row in results:
        total = row.total or 0
        error = row.error or 0
        error_rate = (error / total * 100) if total > 0 else 0

        timestamps.append(row.bucket.isoformat() if row.bucket else "")
        request_counts.append(total)
        success_counts.append(row.success or 0)
        error_counts.append(error)
        error_rates.append(round(error_rate, 2))

    return {
        "timestamps": timestamps,
        "request_count": request_counts,
        "success_count": success_counts,
        "error_count": error_counts,
        "error_rate": error_rates,
    }


def _get_timeseries_metrics_python(db: Session, cutoff_time: datetime, interval_minutes: int) -> dict:
    """Compute time-series metrics using Python (fallback for SQLite).

    Args:
        db: Database session
        cutoff_time: Start time for analysis
        interval_minutes: Bucket size in minutes

    Returns:
        dict: Time-series metrics data
    """
    # Query traces grouped by time bucket
    traces = db.query(ObservabilityTrace.start_time, ObservabilityTrace.status).filter(ObservabilityTrace.start_time >= cutoff_time).order_by(ObservabilityTrace.start_time).all()

    if not traces:
        return {"timestamps": [], "request_count": [], "success_count": [], "error_count": [], "error_rate": []}

    # Group traces into time buckets using epoch-based bucketing (works for any interval)
    interval_seconds = interval_minutes * 60
    buckets: Dict[datetime, Dict[str, int]] = defaultdict(lambda: {"total": 0, "success": 0, "error": 0})
    for trace in traces:
        trace_time = trace.start_time
        if trace_time.tzinfo is None:
            trace_time = trace_time.replace(tzinfo=timezone.utc)
        epoch = trace_time.timestamp()
        bucket_epoch = (epoch // interval_seconds) * interval_seconds
        bucket_time = datetime.fromtimestamp(bucket_epoch, tz=timezone.utc)

        buckets[bucket_time]["total"] += 1
        if trace.status == "ok":
            buckets[bucket_time]["success"] += 1
        elif trace.status == "error":
            buckets[bucket_time]["error"] += 1

    # Build time-series arrays
    timestamps = []
    request_counts = []
    success_counts = []
    error_counts = []
    error_rates = []

    for bucket_time in sorted(buckets.keys()):
        bucket = buckets[bucket_time]
        error_rate = (bucket["error"] / bucket["total"] * 100) if bucket["total"] > 0 else 0

        timestamps.append(bucket_time.isoformat())
        request_counts.append(bucket["total"])
        success_counts.append(bucket["success"])
        error_counts.append(bucket["error"])
        error_rates.append(round(error_rate, 2))

    return {
        "timestamps": timestamps,
        "request_count": request_counts,
        "success_count": success_counts,
        "error_count": error_counts,
        "error_rate": error_rates,
    }


def _get_latency_heatmap_postgresql(db: Session, cutoff_time: datetime, hours: int, time_buckets: int, latency_buckets: int) -> dict:
    """Compute latency heatmap using PostgreSQL (optimized path).

    Uses SQL arithmetic for efficient 2D histogram computation.

    Args:
        db: Database session
        cutoff_time: Start time for analysis
        hours: Time range in hours
        time_buckets: Number of time buckets
        latency_buckets: Number of latency buckets

    Returns:
        dict: Heatmap data with time and latency dimensions
    """
    # First, get min/max durations
    stats_query = text("""
        SELECT MIN(duration_ms) as min_d, MAX(duration_ms) as max_d
        FROM observability_traces
        WHERE start_time >= :cutoff_time AND duration_ms IS NOT NULL
    """)
    stats_row = db.execute(stats_query, {"cutoff_time": cutoff_time}).fetchone()

    if not stats_row or stats_row.min_d is None:
        return {"time_labels": [], "latency_labels": [], "data": []}

    min_duration = float(stats_row.min_d)
    max_duration = float(stats_row.max_d)
    latency_range = max_duration - min_duration

    # Handle case where all durations are the same
    if latency_range == 0:
        latency_range = 1.0

    time_range_minutes = hours * 60
    latency_bucket_size = latency_range / latency_buckets
    time_bucket_minutes = time_range_minutes / time_buckets

    # Use SQL arithmetic for 2D histogram bucketing
    heatmap_query = text("""
        SELECT
            LEAST(GREATEST(
                (EXTRACT(EPOCH FROM (start_time - :cutoff_time)) / 60.0 / :time_bucket_minutes)::int,
                0
            ), :time_buckets - 1) as time_idx,
            LEAST(GREATEST(
                ((duration_ms - :min_duration) / :latency_bucket_size)::int,
                0
            ), :latency_buckets - 1) as latency_idx,
            COUNT(*) as cnt
        FROM observability_traces
        WHERE start_time >= :cutoff_time AND duration_ms IS NOT NULL
        GROUP BY time_idx, latency_idx
    """)

    rows = db.execute(
        heatmap_query,
        {
            "cutoff_time": cutoff_time,
            "time_bucket_minutes": time_bucket_minutes,
            "time_buckets": time_buckets,
            "min_duration": min_duration,
            "latency_bucket_size": latency_bucket_size,
            "latency_buckets": latency_buckets,
        },
    ).fetchall()

    # Initialize heatmap matrix
    heatmap = [[0 for _ in range(time_buckets)] for _ in range(latency_buckets)]

    # Populate from SQL results
    for row in rows:
        time_idx = int(row.time_idx)
        latency_idx = int(row.latency_idx)
        if 0 <= time_idx < time_buckets and 0 <= latency_idx < latency_buckets:
            heatmap[latency_idx][time_idx] = int(row.cnt)

    # Generate labels
    time_labels = []
    for i in range(time_buckets):
        bucket_time = cutoff_time + timedelta(minutes=i * time_bucket_minutes)
        time_labels.append(bucket_time.strftime("%H:%M"))

    latency_labels = []
    for i in range(latency_buckets):
        bucket_min = min_duration + i * latency_bucket_size
        bucket_max = bucket_min + latency_bucket_size
        latency_labels.append(f"{bucket_min:.0f}-{bucket_max:.0f}ms")

    return {"time_labels": time_labels, "latency_labels": latency_labels, "data": heatmap}


def _get_latency_heatmap_python(db: Session, cutoff_time: datetime, hours: int, time_buckets: int, latency_buckets: int) -> dict:
    """Compute latency heatmap using Python (fallback for SQLite).

    Args:
        db: Database session
        cutoff_time: Start time for analysis
        hours: Time range in hours
        time_buckets: Number of time buckets
        latency_buckets: Number of latency buckets

    Returns:
        dict: Heatmap data with time and latency dimensions
    """
    # Query all traces with duration
    traces = (
        db.query(ObservabilityTrace.start_time, ObservabilityTrace.duration_ms)
        .filter(ObservabilityTrace.start_time >= cutoff_time, ObservabilityTrace.duration_ms.isnot(None))
        .order_by(ObservabilityTrace.start_time)
        .all()
    )

    if not traces:
        return {"time_labels": [], "latency_labels": [], "data": []}

    # Calculate time bucket size
    time_range = hours * 60  # minutes
    time_bucket_minutes = time_range / time_buckets

    # Find latency range and create buckets
    durations = [t.duration_ms for t in traces]
    min_duration = min(durations)
    max_duration = max(durations)
    latency_range = max_duration - min_duration
    latency_bucket_size = latency_range / latency_buckets if latency_range > 0 else 1

    # Initialize heatmap matrix
    heatmap = [[0 for _ in range(time_buckets)] for _ in range(latency_buckets)]

    # Populate heatmap
    for trace in traces:
        trace_time = trace.start_time
        # Convert naive SQLite datetime to UTC aware
        if trace_time.tzinfo is None:
            trace_time = trace_time.replace(tzinfo=timezone.utc)

        # Calculate time bucket index
        time_diff = (trace_time - cutoff_time).total_seconds() / 60  # minutes
        time_idx = min(int(time_diff / time_bucket_minutes), time_buckets - 1)

        # Calculate latency bucket index
        latency_idx = min(int((trace.duration_ms - min_duration) / latency_bucket_size), latency_buckets - 1)

        heatmap[latency_idx][time_idx] += 1

    # Generate labels
    time_labels = []
    for i in range(time_buckets):
        bucket_time = cutoff_time + timedelta(minutes=i * time_bucket_minutes)
        time_labels.append(bucket_time.strftime("%H:%M"))

    latency_labels = []
    for i in range(latency_buckets):
        bucket_min = min_duration + i * latency_bucket_size
        bucket_max = bucket_min + latency_bucket_size
        latency_labels.append(f"{bucket_min:.0f}-{bucket_max:.0f}ms")

    return {"time_labels": time_labels, "latency_labels": latency_labels, "data": heatmap}


@admin_router.get("/observability/metrics/top-slow", response_model=dict)
@require_permission("admin.system_config", allow_admin_bypass=False)
async def get_top_slow_endpoints(
    request: Request,  # pylint: disable=unused-argument
    hours: int = Query(24, ge=1, le=168, description="Time range in hours"),
    limit: int = Query(10, ge=1, le=settings.pagination_max_page_size, description="Number of results"),
    _user=Depends(get_current_user_with_permissions),
    db: Session = Depends(get_db),
):
    """Get top N slowest endpoints by average duration.

    Args:
        request: FastAPI request object
        hours: Number of hours to look back (1-168)
        limit: Number of results to return (1-pagination_max_page_size)
        _user: Authenticated user (required by dependency)
        db: Database session for permission checks.

    Returns:
        dict: List of slowest endpoints with stats

    Raises:
        HTTPException: 500 if query fails
    """
    db = next(get_db())
    try:
        cutoff_time = datetime.now(timezone.utc) - timedelta(hours=hours)

        # Group by endpoint and calculate average duration
        results = (
            db.query(
                ObservabilityTrace.http_url,
                ObservabilityTrace.http_method,
                func.count(ObservabilityTrace.trace_id).label("count"),  # pylint: disable=not-callable
                func.avg(ObservabilityTrace.duration_ms).label("avg_duration"),
                func.max(ObservabilityTrace.duration_ms).label("max_duration"),
            )
            .filter(ObservabilityTrace.start_time >= cutoff_time, ObservabilityTrace.duration_ms.isnot(None))
            .group_by(ObservabilityTrace.http_url, ObservabilityTrace.http_method)
            .order_by(desc("avg_duration"))
            .limit(limit)
            .all()
        )

        endpoints = []
        for row in results:
            endpoints.append(
                {
                    "endpoint": f"{row.http_method} {row.http_url}",
                    "method": row.http_method,
                    "url": row.http_url,
                    "count": row.count,
                    "avg_duration_ms": round(row.avg_duration, 2),
                    "max_duration_ms": round(row.max_duration, 2),
                }
            )

        return {"endpoints": endpoints}
    except Exception as e:
        LOGGER.error(f"Failed to get top slow endpoints: {e}")
        raise HTTPException(status_code=500, detail="Failed to retrieve slow endpoints")
    finally:
        # Ensure close() always runs even if commit() fails
        try:
            db.commit()  # Commit read-only transaction to avoid implicit rollback
        finally:
            db.close()


@admin_router.get("/observability/metrics/top-volume", response_model=dict)
@require_permission("admin.system_config", allow_admin_bypass=False)
async def get_top_volume_endpoints(
    request: Request,  # pylint: disable=unused-argument
    hours: int = Query(24, ge=1, le=168, description="Time range in hours"),
    limit: int = Query(10, ge=1, le=settings.pagination_max_page_size, description="Number of results"),
    _user=Depends(get_current_user_with_permissions),
    db: Session = Depends(get_db),
):
    """Get top N highest volume endpoints by request count.

    Args:
        request: FastAPI request object
        hours: Number of hours to look back (1-168)
        limit: Number of results to return (1-pagination_max_page_size)
        _user: Authenticated user (required by dependency)
        db: Database session for permission checks.

    Returns:
        dict: List of highest volume endpoints with stats

    Raises:
        HTTPException: 500 if query fails
    """
    db = next(get_db())
    try:
        cutoff_time = datetime.now(timezone.utc) - timedelta(hours=hours)

        # Group by endpoint and count requests
        results = (
            db.query(
                ObservabilityTrace.http_url,
                ObservabilityTrace.http_method,
                func.count(ObservabilityTrace.trace_id).label("count"),  # pylint: disable=not-callable
                func.avg(ObservabilityTrace.duration_ms).label("avg_duration"),
            )
            .filter(ObservabilityTrace.start_time >= cutoff_time)
            .group_by(ObservabilityTrace.http_url, ObservabilityTrace.http_method)
            .order_by(desc("count"))
            .limit(limit)
            .all()
        )

        endpoints = []
        for row in results:
            endpoints.append(
                {
                    "endpoint": f"{row.http_method} {row.http_url}",
                    "method": row.http_method,
                    "url": row.http_url,
                    "count": row.count,
                    "avg_duration_ms": round(row.avg_duration, 2) if row.avg_duration else 0,
                }
            )

        return {"endpoints": endpoints}
    except Exception as e:
        LOGGER.error(f"Failed to get top volume endpoints: {e}")
        raise HTTPException(status_code=500, detail="Failed to retrieve volume endpoints")
    finally:
        # Ensure close() always runs even if commit() fails
        try:
            db.commit()  # Commit read-only transaction to avoid implicit rollback
        finally:
            db.close()


@admin_router.get("/observability/metrics/top-errors", response_model=dict)
@require_permission("admin.system_config", allow_admin_bypass=False)
async def get_top_error_endpoints(
    request: Request,  # pylint: disable=unused-argument
    hours: int = Query(24, ge=1, le=168, description="Time range in hours"),
    limit: int = Query(10, ge=1, le=settings.pagination_max_page_size, description="Number of results"),
    _user=Depends(get_current_user_with_permissions),
    db: Session = Depends(get_db),
):
    """Get top N error-prone endpoints by error count and rate.

    Args:
        request: FastAPI request object
        hours: Number of hours to look back (1-168)
        limit: Number of results to return (1-pagination_max_page_size)
        _user: Authenticated user (required by dependency)
        db: Database session for permission checks.

    Returns:
        dict: List of error-prone endpoints with stats

    Raises:
        HTTPException: 500 if query fails
    """
    db = next(get_db())
    try:
        cutoff_time = datetime.now(timezone.utc) - timedelta(hours=hours)

        # Group by endpoint and count errors
        results = (
            db.query(
                ObservabilityTrace.http_url,
                ObservabilityTrace.http_method,
                func.count(ObservabilityTrace.trace_id).label("total_count"),  # pylint: disable=not-callable
                func.sum(case((ObservabilityTrace.status == "error", 1), else_=0)).label("error_count"),
            )
            .filter(ObservabilityTrace.start_time >= cutoff_time)
            .group_by(ObservabilityTrace.http_url, ObservabilityTrace.http_method)
            .having(func.sum(case((ObservabilityTrace.status == "error", 1), else_=0)) > 0)
            .order_by(desc("error_count"))
            .limit(limit)
            .all()
        )

        endpoints = []
        for row in results:
            error_rate = (row.error_count / row.total_count * 100) if row.total_count > 0 else 0
            endpoints.append(
                {
                    "endpoint": f"{row.http_method} {row.http_url}",
                    "method": row.http_method,
                    "url": row.http_url,
                    "total_count": row.total_count,
                    "error_count": row.error_count,
                    "error_rate": round(error_rate, 2),
                }
            )

        return {"endpoints": endpoints}
    except Exception as e:
        LOGGER.error(f"Failed to get top error endpoints: {e}")
        raise HTTPException(status_code=500, detail="Failed to retrieve error endpoints")
    finally:
        # Ensure close() always runs even if commit() fails
        try:
            db.commit()  # Commit read-only transaction to avoid implicit rollback
        finally:
            db.close()


@admin_router.get("/observability/metrics/heatmap", response_model=dict)
@require_permission("admin.system_config", allow_admin_bypass=False)
async def get_latency_heatmap(
    request: Request,  # pylint: disable=unused-argument
    hours: int = Query(24, ge=1, le=168, description="Time range in hours"),
    time_buckets: int = Query(24, ge=10, le=100, description="Number of time buckets"),
    latency_buckets: int = Query(20, ge=5, le=50, description="Number of latency buckets"),
    _user=Depends(get_current_user_with_permissions),
    db: Session = Depends(get_db),
):
    """Get latency distribution heatmap data.

    Uses PostgreSQL SQL aggregation for efficient computation when available,
    falls back to Python for SQLite.

    Args:
        request: FastAPI request object
        hours: Number of hours to look back (1-168)
        time_buckets: Number of time buckets (10-100)
        latency_buckets: Number of latency buckets (5-50)
        _user: Authenticated user (required by dependency)
        db: Database session for permission checks.

    Returns:
        dict: Heatmap data with time and latency dimensions

    Raises:
        HTTPException: 500 if calculation fails
    """
    db = next(get_db())
    try:
        cutoff_time = datetime.now(timezone.utc) - timedelta(hours=hours)

        # Route to appropriate implementation based on database dialect
        dialect_name = db.get_bind().dialect.name
        if dialect_name == "postgresql":
            return _get_latency_heatmap_postgresql(db, cutoff_time, hours, time_buckets, latency_buckets)
        return _get_latency_heatmap_python(db, cutoff_time, hours, time_buckets, latency_buckets)
    except Exception as e:
        LOGGER.error(f"Failed to generate latency heatmap: {e}")
        raise HTTPException(status_code=500, detail="Failed to generate latency heatmap")
    finally:
        # Ensure close() always runs even if commit() fails
        try:
            db.commit()  # Commit read-only transaction to avoid implicit rollback
        finally:
            db.close()


@admin_router.get("/observability/tools/usage", response_model=dict)
@require_permission("admin.system_config", allow_admin_bypass=False)
async def get_tool_usage(
    request: Request,  # pylint: disable=unused-argument
    hours: int = Query(24, ge=1, le=168, description="Time range in hours"),
    limit: int = Query(20, ge=5, le=settings.pagination_max_page_size, description="Number of tools to return"),
    _user=Depends(get_current_user_with_permissions),
    db: Session = Depends(get_db),
):
    """Get tool usage frequency statistics.

    Args:
        request: FastAPI request object
        hours: Number of hours to look back (1-168)
        limit: Maximum number of tools to return (5-pagination_max_page_size)
        _user: Authenticated user (required by dependency)
        db: Database session for permission checks.

    Returns:
        dict: Tool usage statistics with counts and percentages

    Raises:
        HTTPException: 500 if calculation fails
    """
    db = next(get_db())
    try:
        cutoff_time = datetime.now(timezone.utc) - timedelta(hours=hours)
        cutoff_time_naive = cutoff_time.replace(tzinfo=None)
        dialect_name = db.get_bind().dialect.name

        # Query tool invocations from spans
        # Note: Using $."tool.name" because the JSON key contains a dot
        # Create expression once and reuse to avoid PostgreSQL GROUP BY errors
        tool_name_expr = extract_json_field(ObservabilitySpan.attributes, '$."tool.name"', dialect_name=dialect_name)
        tool_usage = (
            db.query(
                tool_name_expr.label("tool_name"),
                func.count(ObservabilitySpan.span_id).label("count"),  # pylint: disable=not-callable
            )
            .filter(
                ObservabilitySpan.name == "tool.invoke",
                ObservabilitySpan.start_time >= cutoff_time_naive,
                tool_name_expr.isnot(None),
            )
            .group_by(tool_name_expr)
            .order_by(func.count(ObservabilitySpan.span_id).desc())  # pylint: disable=not-callable
            .limit(limit)
            .all()
        )

        total_invocations = sum(row.count for row in tool_usage)

        tools = [
            {
                "tool_name": row.tool_name,
                "count": row.count,
                "percentage": round((row.count / total_invocations * 100) if total_invocations > 0 else 0, 2),
            }
            for row in tool_usage
        ]

        return {"tools": tools, "total_invocations": total_invocations, "time_range_hours": hours}
    except Exception as e:
        LOGGER.error(f"Failed to get tool usage statistics: {e}")
        raise HTTPException(status_code=500, detail="Failed to retrieve tool usage statistics")
    finally:
        # Ensure close() always runs even if commit() fails
        try:
            db.commit()  # Commit read-only transaction to avoid implicit rollback
        finally:
            db.close()


@admin_router.get("/observability/tools/performance", response_model=dict)
@require_permission("admin.system_config", allow_admin_bypass=False)
async def get_tool_performance(
    request: Request,  # pylint: disable=unused-argument
    hours: int = Query(24, ge=1, le=168, description="Time range in hours"),
    limit: int = Query(20, ge=5, le=settings.pagination_max_page_size, description="Number of tools to return"),
    _user=Depends(get_current_user_with_permissions),
    db: Session = Depends(get_db),
):
    """Get tool performance metrics (avg, min, max duration).

    Args:
        request: FastAPI request object
        hours: Number of hours to look back (1-168)
        limit: Maximum number of tools to return (5-pagination_max_page_size)
        _user: Authenticated user (required by dependency)
        db: Database session for permission checks.

    Returns:
        dict: Tool performance metrics

    Raises:
        HTTPException: 500 if calculation fails
    """
    db = next(get_db())
    try:
        cutoff_time = datetime.now(timezone.utc) - timedelta(hours=hours)
        cutoff_time_naive = cutoff_time.replace(tzinfo=None)

        # Use shared helper to compute performance grouped by the JSON attribute
        tools = _get_span_entity_performance(
            db=db,
            cutoff_time=cutoff_time,
            cutoff_time_naive=cutoff_time_naive,
            span_names=["tool.invoke"],
            json_key="tool.name",
            result_key="tool_name",
            limit=limit,
        )

        return {"tools": tools, "time_range_hours": hours}
    except Exception as e:
        LOGGER.error(f"Failed to get tool performance metrics: {e}")
        raise HTTPException(status_code=500, detail="Failed to retrieve tool performance metrics")
    finally:
        # Ensure close() always runs even if commit() fails
        try:
            db.commit()  # Commit read-only transaction to avoid implicit rollback
        finally:
            db.close()


@admin_router.get("/observability/tools/errors", response_model=dict)
@require_permission("admin.system_config", allow_admin_bypass=False)
async def get_tool_errors(
    request: Request,  # pylint: disable=unused-argument
    hours: int = Query(24, ge=1, le=168, description="Time range in hours"),
    limit: int = Query(20, ge=5, le=settings.pagination_max_page_size, description="Number of tools to return"),
    _user=Depends(get_current_user_with_permissions),
    db: Session = Depends(get_db),
):
    """Get tool error rates and statistics.

    Args:
        request: FastAPI request object
        hours: Number of hours to look back (1-168)
        limit: Maximum number of tools to return (5-pagination_max_page_size)
        _user: Authenticated user (required by dependency)
        db: Database session for permission checks.

    Returns:
        dict: Tool error statistics

    Raises:
        HTTPException: 500 if calculation fails
    """
    db = next(get_db())
    try:
        cutoff_time = datetime.now(timezone.utc) - timedelta(hours=hours)
        cutoff_time_naive = cutoff_time.replace(tzinfo=None)
        dialect_name = db.get_bind().dialect.name

        # Query tool error rates
        # Create expression once and reuse to avoid PostgreSQL GROUP BY errors
        tool_name_expr = extract_json_field(ObservabilitySpan.attributes, '$."tool.name"', dialect_name=dialect_name)
        tool_errors = (
            db.query(
                tool_name_expr.label("tool_name"),
                func.count(ObservabilitySpan.span_id).label("total_count"),  # pylint: disable=not-callable
                func.sum(case((ObservabilitySpan.status == "error", 1), else_=0)).label("error_count"),  # pylint: disable=not-callable
            )
            .filter(
                ObservabilitySpan.name == "tool.invoke",
                ObservabilitySpan.start_time >= cutoff_time_naive,
                tool_name_expr.isnot(None),
            )
            .group_by(tool_name_expr)
            .order_by(func.sum(case((ObservabilitySpan.status == "error", 1), else_=0)).desc())  # pylint: disable=not-callable
            .limit(limit)
            .all()
        )

        tools = [
            {
                "tool_name": row.tool_name,
                "total_count": row.total_count,
                "error_count": row.error_count or 0,
                "error_rate": round((row.error_count / row.total_count * 100) if row.total_count > 0 and row.error_count else 0, 2),
            }
            for row in tool_errors
        ]

        return {"tools": tools, "time_range_hours": hours}
    except Exception as e:
        LOGGER.error(f"Failed to get tool error statistics: {e}")
        raise HTTPException(status_code=500, detail="Failed to retrieve tool error statistics")
    finally:
        # Ensure close() always runs even if commit() fails
        try:
            db.commit()  # Commit read-only transaction to avoid implicit rollback
        finally:
            db.close()


@admin_router.get("/observability/tools/chains", response_model=dict)
@require_permission("admin.system_config", allow_admin_bypass=False)
async def get_tool_chains(
    request: Request,  # pylint: disable=unused-argument
    hours: int = Query(24, ge=1, le=168, description="Time range in hours"),
    limit: int = Query(20, ge=5, le=settings.pagination_max_page_size, description="Number of chains to return"),
    _user=Depends(get_current_user_with_permissions),
    db: Session = Depends(get_db),
):
    """Get tool chain analysis (which tools are invoked together in the same trace).

    Args:
        request: FastAPI request object
        hours: Number of hours to look back (1-168)
        limit: Maximum number of chains to return (5-pagination_max_page_size)
        _user: Authenticated user (required by dependency)
        db: Database session for permission checks.

    Returns:
        dict: Tool chain statistics showing common tool sequences

    Raises:
        HTTPException: 500 if calculation fails
    """
    db = next(get_db())
    try:
        cutoff_time = datetime.now(timezone.utc) - timedelta(hours=hours)
        cutoff_time_naive = cutoff_time.replace(tzinfo=None)
        dialect_name = db.get_bind().dialect.name

        # Get all tool invocations grouped by trace_id
        # Create expression once and reuse to avoid PostgreSQL GROUP BY errors
        tool_name_expr = extract_json_field(ObservabilitySpan.attributes, '$."tool.name"', dialect_name=dialect_name)
        tool_spans = (
            db.query(
                ObservabilitySpan.trace_id,
                tool_name_expr.label("tool_name"),
                ObservabilitySpan.start_time,
            )
            .filter(
                ObservabilitySpan.name == "tool.invoke",
                ObservabilitySpan.start_time >= cutoff_time_naive,
                tool_name_expr.isnot(None),
            )
            .order_by(ObservabilitySpan.trace_id, ObservabilitySpan.start_time)
            .all()
        )

        # Group tools by trace and create chains
        trace_tools = {}
        for span in tool_spans:
            if span.trace_id not in trace_tools:
                trace_tools[span.trace_id] = []
            trace_tools[span.trace_id].append(span.tool_name)

        # Count tool chain frequencies
        chain_counts = {}
        for tools in trace_tools.values():
            if len(tools) > 1:
                # Create a chain string (sorted to treat [A,B] and [B,A] as same chain)
                chain = " -> ".join(tools)
                chain_counts[chain] = chain_counts.get(chain, 0) + 1

        # Sort by frequency and take top N
        sorted_chains = sorted(chain_counts.items(), key=lambda x: x[1], reverse=True)[:limit]

        chains = [{"chain": chain, "count": count} for chain, count in sorted_chains]

        return {"chains": chains, "total_traces_with_tools": len(trace_tools), "time_range_hours": hours}
    except Exception as e:
        LOGGER.error(f"Failed to get tool chain statistics: {e}")
        raise HTTPException(status_code=500, detail="Failed to retrieve tool chain statistics")
    finally:
        # Ensure close() always runs even if commit() fails
        try:
            db.commit()  # Commit read-only transaction to avoid implicit rollback
        finally:
            db.close()


@admin_router.get("/observability/tools/partial", response_class=HTMLResponse)
@require_permission("admin.system_config", allow_admin_bypass=False)
async def get_tools_partial(
    request: Request,
    _user=Depends(get_current_user_with_permissions),
    _db: Session = Depends(get_db),
):
    """Render the tool invocation metrics dashboard HTML partial.

    Args:
        request: FastAPI request object
        _user: Authenticated user (required by dependency)
        _db: Database session for permission checks.

    Returns:
        HTMLResponse: Rendered tool metrics dashboard partial
    """
    root_path = _resolve_root_path(request)
    return request.app.state.templates.TemplateResponse(
        request,
        "observability_tools.html",
        {
            "request": request,
            "root_path": root_path,
        },
    )


# ==============================================================================
# Prompts Observability Endpoints
# ==============================================================================


@admin_router.get("/observability/prompts/usage", response_model=dict)
@require_permission("admin.system_config", allow_admin_bypass=False)
async def get_prompt_usage(
    request: Request,  # pylint: disable=unused-argument
    hours: int = Query(24, ge=1, le=168, description="Time range in hours"),
    limit: int = Query(20, ge=5, le=settings.pagination_max_page_size, description="Number of prompts to return"),
    _user=Depends(get_current_user_with_permissions),
    db: Session = Depends(get_db),
):
    """Get prompt rendering frequency statistics.

    Args:
        request: FastAPI request object
        hours: Number of hours to look back (1-168)
        limit: Maximum number of prompts to return (5-pagination_max_page_size)
        _user: Authenticated user (required by dependency)
        db: Database session for permission checks.

    Returns:
        dict: Prompt usage statistics with counts and percentages

    Raises:
        HTTPException: 500 if calculation fails
    """
    db = next(get_db())
    try:
        cutoff_time = datetime.now(timezone.utc) - timedelta(hours=hours)
        cutoff_time_naive = cutoff_time.replace(tzinfo=None)
        dialect_name = db.get_bind().dialect.name

        # Query prompt renders from spans (looking for prompts/get calls)
        # The prompt id should be in attributes as "prompt.id"
        # Create expression once and reuse to avoid PostgreSQL GROUP BY errors
        prompt_id_expr = extract_json_field(ObservabilitySpan.attributes, '$."prompt.id"', dialect_name=dialect_name)
        prompt_usage = (
            db.query(
                prompt_id_expr.label("prompt_id"),
                func.count(ObservabilitySpan.span_id).label("count"),  # pylint: disable=not-callable
            )
            .filter(
                ObservabilitySpan.name.in_(["prompt.get", "prompts.get", "prompt.render"]),
                ObservabilitySpan.start_time >= cutoff_time_naive,
                prompt_id_expr.isnot(None),
            )
            .group_by(prompt_id_expr)
            .order_by(func.count(ObservabilitySpan.span_id).desc())  # pylint: disable=not-callable
            .limit(limit)
            .all()
        )

        total_renders = sum(row.count for row in prompt_usage)

        prompts = [
            {
                "prompt_id": row.prompt_id,
                "count": row.count,
                "percentage": round((row.count / total_renders * 100) if total_renders > 0 else 0, 2),
            }
            for row in prompt_usage
        ]

        return {"prompts": prompts, "total_renders": total_renders, "time_range_hours": hours}
    except Exception as e:
        LOGGER.error(f"Failed to get prompt usage statistics: {e}")
        raise HTTPException(status_code=500, detail="Failed to retrieve prompt usage statistics")
    finally:
        # Ensure close() always runs even if commit() fails
        try:
            db.commit()  # Commit read-only transaction to avoid implicit rollback
        finally:
            db.close()


@admin_router.get("/observability/prompts/performance", response_model=dict)
@require_permission("admin.system_config", allow_admin_bypass=False)
async def get_prompt_performance(
    request: Request,  # pylint: disable=unused-argument
    hours: int = Query(24, ge=1, le=168, description="Time range in hours"),
    limit: int = Query(20, ge=5, le=settings.pagination_max_page_size, description="Number of prompts to return"),
    _user=Depends(get_current_user_with_permissions),
    db: Session = Depends(get_db),
):
    """Get prompt performance metrics (avg, min, max duration).

    Args:
        request: FastAPI request object
        hours: Number of hours to look back (1-168)
        limit: Maximum number of prompts to return (5-pagination_max_page_size)
        _user: Authenticated user (required by dependency)
        db: Database session for permission checks.

    Returns:
        dict: Prompt performance metrics

    Raises:
        HTTPException: 500 if calculation fails
    """
    db = next(get_db())
    try:
        cutoff_time = datetime.now(timezone.utc) - timedelta(hours=hours)
        cutoff_time_naive = cutoff_time.replace(tzinfo=None)

        # Use shared helper to compute performance grouped by the JSON attribute
        prompts = _get_span_entity_performance(
            db=db,
            cutoff_time=cutoff_time,
            cutoff_time_naive=cutoff_time_naive,
            span_names=["prompt.get", "prompts.get", "prompt.render"],
            json_key="prompt.id",
            result_key="prompt_id",
            limit=limit,
        )

        return {"prompts": prompts, "time_range_hours": hours}
    except Exception as e:
        LOGGER.error(f"Failed to get prompt performance metrics: {e}")
        raise HTTPException(status_code=500, detail="Failed to retrieve prompt performance metrics")
    finally:
        # Ensure close() always runs even if commit() fails
        try:
            db.commit()  # Commit read-only transaction to avoid implicit rollback
        finally:
            db.close()


@admin_router.get("/observability/prompts/errors", response_model=dict)
@require_permission("admin.system_config", allow_admin_bypass=False)
async def get_prompts_errors(
    hours: int = Query(24, description="Time range in hours"),
    limit: int = Query(20, description="Maximum number of results"),
    _user=Depends(get_current_user_with_permissions),
    db: Session = Depends(get_db),
):
    """Get prompt error rates.

    Args:
        hours: Time range in hours to analyze
        limit: Maximum number of prompts to return
        _user: Authenticated user (required by dependency)
        db: Database session for permission checks.

    Returns:
        dict: Prompt error statistics
    """
    db = next(get_db())
    try:
        cutoff_time = datetime.now(timezone.utc) - timedelta(hours=hours)
        cutoff_time_naive = cutoff_time.replace(tzinfo=None)
        dialect_name = db.get_bind().dialect.name

        # Get all prompt spans with their status
        # Create expression once and reuse to avoid PostgreSQL GROUP BY errors
        prompt_id_expr = extract_json_field(ObservabilitySpan.attributes, '$."prompt.id"', dialect_name=dialect_name)
        prompt_stats = (
            db.query(
                prompt_id_expr.label("prompt_id"),
                func.count().label("total_count"),  # pylint: disable=not-callable
                func.sum(case((ObservabilitySpan.status == "error", 1), else_=0)).label("error_count"),
            )
            .filter(
                ObservabilitySpan.name == "prompt.render",
                ObservabilitySpan.start_time >= cutoff_time_naive,
                prompt_id_expr.isnot(None),
            )
            .group_by(prompt_id_expr)
            .all()
        )

        prompts_data = []
        for stat in prompt_stats:
            total = stat.total_count
            errors = stat.error_count or 0
            error_rate = round((errors / total * 100), 2) if total > 0 else 0

            prompts_data.append({"prompt_id": stat.prompt_id, "total_count": total, "error_count": errors, "error_rate": error_rate})

        # Sort by error rate descending
        prompts_data.sort(key=lambda x: x["error_rate"], reverse=True)
        prompts_data = prompts_data[:limit]

        return {"prompts": prompts_data, "time_range_hours": hours}
    finally:
        # Ensure close() always runs even if commit() fails
        try:
            db.commit()  # Commit read-only transaction to avoid implicit rollback
        finally:
            db.close()


@admin_router.get("/observability/prompts/partial", response_class=HTMLResponse)
@require_permission("admin.system_config", allow_admin_bypass=False)
async def get_prompts_partial(
    request: Request,
    _user=Depends(get_current_user_with_permissions),
    _db: Session = Depends(get_db),
):
    """Render the prompt rendering metrics dashboard HTML partial.

    Args:
        request: FastAPI request object
        _user: Authenticated user (required by dependency)
        _db: Database session for permission checks.

    Returns:
        HTMLResponse: Rendered prompt metrics dashboard partial
    """
    root_path = _resolve_root_path(request)
    return request.app.state.templates.TemplateResponse(
        request,
        "observability_prompts.html",
        {
            "request": request,
            "root_path": root_path,
        },
    )


# ==============================================================================
# Resources Observability Endpoints
# ==============================================================================


@admin_router.get("/observability/resources/usage", response_model=dict)
@require_permission("admin.system_config", allow_admin_bypass=False)
async def get_resource_usage(
    request: Request,  # pylint: disable=unused-argument
    hours: int = Query(24, ge=1, le=168, description="Time range in hours"),
    limit: int = Query(20, ge=5, le=settings.pagination_max_page_size, description="Number of resources to return"),
    _user=Depends(get_current_user_with_permissions),
    db: Session = Depends(get_db),
):
    """Get resource fetch frequency statistics.

    Args:
        request: FastAPI request object
        hours: Number of hours to look back (1-168)
        limit: Maximum number of resources to return (5-pagination_max_page_size)
        _user: Authenticated user (required by dependency)
        db: Database session for permission checks.

    Returns:
        dict: Resource usage statistics with counts and percentages

    Raises:
        HTTPException: 500 if calculation fails
    """
    db = next(get_db())
    try:
        cutoff_time = datetime.now(timezone.utc) - timedelta(hours=hours)
        cutoff_time_naive = cutoff_time.replace(tzinfo=None)
        dialect_name = db.get_bind().dialect.name

        # Query resource reads from spans (looking for resources/read calls)
        # The resource URI should be in attributes
        # Create expression once and reuse to avoid PostgreSQL GROUP BY errors
        resource_uri_expr = extract_json_field(ObservabilitySpan.attributes, '$."resource.uri"', dialect_name=dialect_name)
        resource_usage = (
            db.query(
                resource_uri_expr.label("resource_uri"),
                func.count(ObservabilitySpan.span_id).label("count"),  # pylint: disable=not-callable
            )
            .filter(
                ObservabilitySpan.name.in_(["resource.read", "resources.read", "resource.fetch"]),
                ObservabilitySpan.start_time >= cutoff_time_naive,
                resource_uri_expr.isnot(None),
            )
            .group_by(resource_uri_expr)
            .order_by(func.count(ObservabilitySpan.span_id).desc())  # pylint: disable=not-callable
            .limit(limit)
            .all()
        )

        total_fetches = sum(row.count for row in resource_usage)

        resources = [
            {
                "resource_uri": row.resource_uri,
                "count": row.count,
                "percentage": round((row.count / total_fetches * 100) if total_fetches > 0 else 0, 2),
            }
            for row in resource_usage
        ]

        return {"resources": resources, "total_fetches": total_fetches, "time_range_hours": hours}
    except Exception as e:
        LOGGER.error(f"Failed to get resource usage statistics: {e}")
        raise HTTPException(status_code=500, detail="Failed to retrieve resource usage statistics")
    finally:
        # Ensure close() always runs even if commit() fails
        try:
            db.commit()  # Commit read-only transaction to avoid implicit rollback
        finally:
            db.close()


@admin_router.get("/observability/resources/performance", response_model=dict)
@require_permission("admin.system_config", allow_admin_bypass=False)
async def get_resource_performance(
    request: Request,  # pylint: disable=unused-argument
    hours: int = Query(24, ge=1, le=168, description="Time range in hours"),
    limit: int = Query(20, ge=5, le=settings.pagination_max_page_size, description="Number of resources to return"),
    _user=Depends(get_current_user_with_permissions),
    db: Session = Depends(get_db),
):
    """Get resource performance metrics (avg, min, max duration).

    Args:
        request: FastAPI request object
        hours: Number of hours to look back (1-168)
        limit: Maximum number of resources to return (5-pagination_max_page_size)
        _user: Authenticated user (required by dependency)
        db: Database session for permission checks.

    Returns:
        dict: Resource performance metrics

    Raises:
        HTTPException: 500 if calculation fails
    """
    db = next(get_db())
    try:
        cutoff_time = datetime.now(timezone.utc) - timedelta(hours=hours)
        cutoff_time_naive = cutoff_time.replace(tzinfo=None)

        # Use shared helper to compute performance grouped by the JSON attribute
        resources = _get_span_entity_performance(
            db=db,
            cutoff_time=cutoff_time,
            cutoff_time_naive=cutoff_time_naive,
            span_names=["resource.read", "resources.read", "resource.fetch"],
            json_key="resource.uri",
            result_key="resource_uri",
            limit=limit,
        )

        return {"resources": resources, "time_range_hours": hours}
    except Exception as e:
        LOGGER.error(f"Failed to get resource performance metrics: {e}")
        raise HTTPException(status_code=500, detail="Failed to retrieve resource performance metrics")
    finally:
        # Ensure close() always runs even if commit() fails
        try:
            db.commit()  # Commit read-only transaction to avoid implicit rollback
        finally:
            db.close()


@admin_router.get("/observability/resources/errors", response_model=dict)
@require_permission("admin.system_config", allow_admin_bypass=False)
async def get_resources_errors(
    hours: int = Query(24, description="Time range in hours"),
    limit: int = Query(20, description="Maximum number of results"),
    _user=Depends(get_current_user_with_permissions),
    db: Session = Depends(get_db),
):
    """Get resource error rates.

    Args:
        hours: Time range in hours to analyze
        limit: Maximum number of resources to return
        _user: Authenticated user (required by dependency)
        db: Database session for permission checks.

    Returns:
        dict: Resource error statistics
    """
    db = next(get_db())
    try:
        cutoff_time = datetime.now(timezone.utc) - timedelta(hours=hours)
        cutoff_time_naive = cutoff_time.replace(tzinfo=None)
        dialect_name = db.get_bind().dialect.name

        # Get all resource spans with their status
        # Create expression once and reuse to avoid PostgreSQL GROUP BY errors
        resource_uri_expr = extract_json_field(ObservabilitySpan.attributes, '$."resource.uri"', dialect_name=dialect_name)
        resource_stats = (
            db.query(
                resource_uri_expr.label("resource_uri"),
                func.count().label("total_count"),  # pylint: disable=not-callable
                func.sum(case((ObservabilitySpan.status == "error", 1), else_=0)).label("error_count"),
            )
            .filter(
                ObservabilitySpan.name.in_(["resource.read", "resources.read", "resource.fetch"]),
                ObservabilitySpan.start_time >= cutoff_time_naive,
                resource_uri_expr.isnot(None),
            )
            .group_by(resource_uri_expr)
            .all()
        )

        resources_data = []
        for stat in resource_stats:
            total = stat.total_count
            errors = stat.error_count or 0
            error_rate = round((errors / total * 100), 2) if total > 0 else 0

            resources_data.append({"resource_uri": stat.resource_uri, "total_count": total, "error_count": errors, "error_rate": error_rate})

        # Sort by error rate descending
        resources_data.sort(key=lambda x: x["error_rate"], reverse=True)
        resources_data = resources_data[:limit]

        return {"resources": resources_data, "time_range_hours": hours}
    finally:
        # Ensure close() always runs even if commit() fails
        try:
            db.commit()  # Commit read-only transaction to avoid implicit rollback
        finally:
            db.close()


@admin_router.get("/observability/resources/partial", response_class=HTMLResponse)
@require_permission("admin.system_config", allow_admin_bypass=False)
async def get_resources_partial(
    request: Request,
    _user=Depends(get_current_user_with_permissions),
    _db: Session = Depends(get_db),
):
    """Render the resource fetch metrics dashboard HTML partial.

    Args:
        request: FastAPI request object
        _user: Authenticated user (required by dependency)
        _db: Database session for permission checks.

    Returns:
        HTMLResponse: Rendered resource metrics dashboard partial
    """
    root_path = _resolve_root_path(request)
    return request.app.state.templates.TemplateResponse(
        request,
        "observability_resources.html",
        {
            "request": request,
            "root_path": root_path,
        },
    )


# ===================================
# Performance Monitoring Endpoints
# ===================================


@admin_router.get("/performance/stats", response_class=HTMLResponse)
@require_permission("admin.system_config", allow_admin_bypass=False)
async def get_performance_stats(
    request: Request,
    db: Session = Depends(get_db),
    _user=Depends(get_current_user_with_permissions),
):
    """Get comprehensive performance metrics for the dashboard.

    Returns either an HTML partial for HTMX requests or JSON for API requests.
    Includes system metrics, request metrics, worker status, and cache stats.

    Args:
        request: FastAPI request object
        db: Database session dependency
        _user: Authenticated user (required by dependency)

    Returns:
        HTMLResponse or JSONResponse: Performance dashboard data

    Raises:
        HTTPException: 404 if performance tracking is disabled, 500 on retrieval error
    """
    if not settings.mcpgateway_performance_tracking:
        if request.headers.get("hx-request"):
            return HTMLResponse(content='<div class="text-center py-8 text-gray-500">Performance tracking is disabled. Enable with MCPGATEWAY_PERFORMANCE_TRACKING=true</div>')
        raise HTTPException(status_code=404, detail="Performance monitoring is disabled")

    try:
        service = get_performance_service(db)
        dashboard = await service.get_dashboard()

        # Convert to dict for template
        dashboard_data = dashboard.model_dump()

        # Format datetime fields for display
        if dashboard_data.get("timestamp"):
            dashboard_data["timestamp"] = dashboard_data["timestamp"].isoformat()
        if dashboard_data.get("system", {}).get("boot_time"):
            dashboard_data["system"]["boot_time"] = dashboard_data["system"]["boot_time"].isoformat()
        for worker in dashboard_data.get("workers", []):
            if worker.get("create_time"):
                worker["create_time"] = worker["create_time"].isoformat()

        if request.headers.get("hx-request"):
            root_path = _resolve_root_path(request)
            return request.app.state.templates.TemplateResponse(
                request,
                "performance_partial.html",
                {
                    "request": request,
                    "dashboard": dashboard_data,
                    "root_path": root_path,
                },
            )

        return ORJSONResponse(content=dashboard_data)

    except Exception as e:
        LOGGER.error(f"Performance metrics retrieval failed: {str(e)}", exc_info=True)
        raise HTTPException(status_code=500, detail="Failed to retrieve performance metrics")


@admin_router.get("/performance/system")
@require_permission("admin.system_config", allow_admin_bypass=False)
async def get_performance_system(
    db: Session = Depends(get_db),
    _user=Depends(get_current_user_with_permissions),
):
    """Get current system resource metrics.

    Args:
        db: Database session dependency
        _user: Authenticated user (required by dependency)

    Returns:
        JSONResponse: System metrics (CPU, memory, disk, network)

    Raises:
        HTTPException: 404 if performance tracking is disabled
    """
    if not settings.mcpgateway_performance_tracking:
        raise HTTPException(status_code=404, detail="Performance tracking is disabled")

    service = get_performance_service(db)
    metrics = service.get_system_metrics()
    return metrics.model_dump()


@admin_router.get("/performance/workers")
@require_permission("admin.system_config", allow_admin_bypass=False)
async def get_performance_workers(
    db: Session = Depends(get_db),
    _user=Depends(get_current_user_with_permissions),
):
    """Get metrics for all worker processes.

    Args:
        db: Database session dependency
        _user: Authenticated user (required by dependency)

    Returns:
        JSONResponse: List of worker metrics

    Raises:
        HTTPException: 404 if performance tracking is disabled
    """
    if not settings.mcpgateway_performance_tracking:
        raise HTTPException(status_code=404, detail="Performance tracking is disabled")

    service = get_performance_service(db)
    workers = service.get_worker_metrics()
    return [w.model_dump() for w in workers]


@admin_router.get("/performance/requests")
@require_permission("admin.system_config", allow_admin_bypass=False)
async def get_performance_requests(
    db: Session = Depends(get_db),
    _user=Depends(get_current_user_with_permissions),
):
    """Get HTTP request performance metrics.

    Args:
        db: Database session dependency
        _user: Authenticated user (required by dependency)

    Returns:
        JSONResponse: Request metrics from Prometheus

    Raises:
        HTTPException: 404 if performance tracking is disabled
    """
    if not settings.mcpgateway_performance_tracking:
        raise HTTPException(status_code=404, detail="Performance tracking is disabled")

    service = get_performance_service(db)
    metrics = service.get_request_metrics()
    return metrics.model_dump()


@admin_router.get("/performance/cache")
@require_permission("admin.system_config", allow_admin_bypass=False)
async def get_performance_cache(
    db: Session = Depends(get_db),
    _user=Depends(get_current_user_with_permissions),
):
    """Get Redis cache metrics.

    Args:
        db: Database session dependency
        _user: Authenticated user (required by dependency)

    Returns:
        JSONResponse: Redis cache metrics

    Raises:
        HTTPException: 404 if performance tracking is disabled
    """
    if not settings.mcpgateway_performance_tracking:
        raise HTTPException(status_code=404, detail="Performance tracking is disabled")

    service = get_performance_service(db)
    metrics = await service.get_cache_metrics()
    return metrics.model_dump()


@admin_router.get("/performance/history")
@require_permission("admin.system_config", allow_admin_bypass=False)
async def get_performance_history(
    period_type: QueryPeriodType = "hourly",
    hours: int = Query(24, ge=1, le=168, description="Number of hours to look back"),
    db: Session = Depends(get_db),
    _user=Depends(get_current_user_with_permissions),
):
    """Get historical performance aggregates.

    Args:
        period_type: Aggregation type (hourly, daily)
        hours: Hours of history to retrieve
        db: Database session dependency
        _user: Authenticated user (required by dependency)

    Returns:
        JSONResponse: Historical performance aggregates

    Raises:
        HTTPException: 404 if performance tracking is disabled
    """
    if not settings.mcpgateway_performance_tracking:
        raise HTTPException(status_code=404, detail="Performance tracking is disabled")

    service = get_performance_service(db)
    start_time = datetime.now(timezone.utc) - timedelta(hours=hours)

    history = await service.get_history(
        db=db,
        period_type=period_type,
        start_time=start_time,
    )

    return history.model_dump()
