import logging
import httpx
from typing import Any, Dict, Optional
from jose import JWTError, jwt

from core.config import settings
from fastapi import HTTPException
from sqlalchemy.orm import Session

from .dto import (
    LoginRequestDTO,
    LogoutRequestDTO,
    RefreshTokenRequestDTO,
    TokenResponseDTO,
    UserInfoResponseDTO,
)

logger = logging.getLogger(__name__)


class AuthService:
    """Servicio de autenticación centralizado vía Hub"""

    def __init__(self, db: Session):
        self.db = db

    @staticmethod
    def _clean_text(value: Any) -> Optional[str]:
        if isinstance(value, str):
            cleaned = value.strip()
            if cleaned:
                return cleaned
        return None

    def _pick_text(self, *candidates: Any) -> Optional[str]:
        for candidate in candidates:
            value = self._clean_text(candidate)
            if value:
                return value
        return None

    def _decode_kc_user_from_token(self, access_token: str) -> Dict[str, Any]:
        try:
            claims = jwt.get_unverified_claims(access_token)
            return claims if isinstance(claims, dict) else {}
        except JWTError:
            return {}
        except Exception:
            return {}

    async def _get_kc_admin_user(self, keycloak_user_id: Optional[str]) -> Optional[Dict[str, Any]]:
        """
        Fallback de datos de usuario consultando el Hub admin API.
        Es opcional y no debe romper /me si falla.
        """
        if not keycloak_user_id:
            return None
        if not settings.HUB_ADMIN_EMAIL or not settings.HUB_ADMIN_PASSWORD:
            return None

        try:
            async with httpx.AsyncClient(timeout=10.0) as client:
                login_resp = await client.post(
                    f"{settings.HUB_URL}api/v1/auth/login",
                    json={
                        "username": settings.HUB_ADMIN_EMAIL,
                        "password": settings.HUB_ADMIN_PASSWORD,
                    },
                )
                if login_resp.status_code != 200:
                    return None

                admin_token = login_resp.json().get("access_token")
                if not admin_token:
                    return None

                user_resp = await client.get(
                    f"{settings.HUB_URL}api/v1/hub/admins/{keycloak_user_id}",
                    headers={"Authorization": f"Bearer {admin_token}"},
                )
                if user_resp.status_code == 200:
                    payload = user_resp.json()
                    return payload if isinstance(payload, dict) else None
        except Exception as exc:
            logger.debug("kc_admin_user_lookup_failed: %s", exc)

        return None

    def _extract_avatar_url(self, *sources: Any) -> Optional[str]:
        for source in sources:
            if not isinstance(source, dict):
                continue

            direct = self._pick_text(
                source.get("avatar_url"),
                source.get("avatarUrl"),
                source.get("picture"),
                source.get("photo"),
            )
            if direct:
                return direct

            attrs = source.get("attributes")
            if isinstance(attrs, dict):
                attr_candidate = attrs.get("avatar_url")
                if isinstance(attr_candidate, list) and attr_candidate:
                    value = self._clean_text(attr_candidate[0])
                    if value:
                        return value
                if isinstance(attr_candidate, str):
                    value = self._clean_text(attr_candidate)
                    if value:
                        return value

        return None

    async def login(
        self, 
        login_data: LoginRequestDTO, 
        ip_address: str = None, 
        user_agent: str = None
    ):
        """
        Autentica usuario a través del Hub y obtiene tokens.
        """
        try:
            async with httpx.AsyncClient(timeout=10.0) as client:
                response = await client.post(
                    f"{settings.HUB_URL}api/v1/auth/login",
                    json=login_data.model_dump()
                )

            if response.status_code == 200:
                data = response.json()
                
                # Si el Hub devolvió una lista de tenants (hubo login exitoso pero falta seleccionar tenant)
                if "tenants" in data:
                    from .dto import LoginChoiceResponseDTO, TenantInfoDTO
                    return LoginChoiceResponseDTO(
                        tenants=[TenantInfoDTO(**t) for t in data["tenants"]]
                    )

                # Si devolvió tokens — lazy-link: verificar si hay invite pendiente
                try:
                    await self._link_pending_invite(login_data.username)
                except Exception as exc:
                    logger.warning("Lazy-link invite check failed (non-blocking): %s", exc)

                # Backfill: crear UserCompanyRole faltantes para usuarios ya registrados
                try:
                    login_sub = data.get("sub") or data.get("user_id")
                    if not login_sub:
                        claims = self._decode_kc_user_from_token(data.get("access_token", ""))
                        login_sub = claims.get("sub")
                    self._backfill_company_roles(login_sub)
                except Exception as exc:
                    logger.warning("backfill_company_roles failed on login (non-blocking): %s", exc)

                # Sync de perfil/avatar desde Workspace usando el mismo bearer.
                # No bloquea login si Workspace no responde.
                access_token = data.get("access_token")
                if access_token:
                    from core.workspace_profile_sync import sync_workspace_profile_for_user
                    from core.workspace_profile_client import WorkspaceProfileClient

                    workspace_profile = None
                    try:
                        workspace_profile = await WorkspaceProfileClient().get_me(access_token)
                    except Exception as exc:
                        logger.warning(
                            "workspace_profile_sync_failed",
                            extra={
                                "event": "workspace_profile_sync_failed",
                                "phase": "login",
                                "error": str(exc),
                            },
                        )
                        workspace_profile = None

                    await sync_workspace_profile_for_user(
                        self.db,
                        access_token=access_token,
                        keycloak_user_id=(workspace_profile or {}).get("sub")
                        or data.get("sub")
                        or data.get("user_id"),
                        tenant_id=data.get("tenant_id"),
                        workspace_profile=workspace_profile,
                        force=True,
                    )

                # AUDIT LOG: implementa tu servicio de auditoría aquí si lo necesitas.

                return TokenResponseDTO(**data)
            
            # Pasar el mensaje de error real del Hub al cliente
            try:
                hub_detail = response.json().get("detail", None)
            except Exception:
                hub_detail = None

            if response.status_code == 401:
                raise HTTPException(status_code=401, detail=hub_detail or "Credenciales inválidas")

            logger.error(f"Hub login failed with status {response.status_code}: {response.text}")
            raise HTTPException(status_code=response.status_code, detail=hub_detail or "Error en el servidor de autenticación")

        except httpx.HTTPError as e:
            logger.error(f"Hub unreachable during login: {str(e)}")
            raise HTTPException(status_code=503, detail="Authentication service unavailable")
        except HTTPException:
            raise
        except Exception as e:
            logger.error(f"Unexpected login error: {str(e)}")
            raise HTTPException(status_code=500, detail="Authentication error")

    def _decode_local_session(self, session_token: Optional[str]) -> Optional[Dict[str, Any]]:
        """
        Decodifica una sesión local del CRM (HS256) verificando la firma pero
        SIN exigir exp — para poder re-emitirla en el refresh. Retorna los claims
        o None si la firma no valida o no es una sesión local del CRM.
        """
        if not session_token:
            return None
        try:
            claims = jwt.decode(
                session_token,
                settings.SECRET_KEY,
                algorithms=["HS256"],
                options={"verify_exp": False},
            )
        except JWTError:
            return None
        if not claims.get("crm_session") or claims.get("source") != "local":
            return None
        return claims

    def _session_claims_from_kc(self, data: Dict[str, Any]) -> Dict[str, Any]:
        """Construye los claims de la sesión local a partir del token KC (decode)."""
        kc_claims = self._decode_kc_user_from_token(data.get("access_token", ""))
        claims: Dict[str, Any] = dict(kc_claims)
        # tenant_id/tenant_slug explícitos del Hub tienen precedencia sobre el token
        if data.get("tenant_id") is not None:
            claims["tenant_id"] = data.get("tenant_id")
        if data.get("tenant_slug") is not None:
            claims["tenant_slug"] = data.get("tenant_slug")
        return claims

    async def _session_claims(self, data: Dict[str, Any]) -> Dict[str, Any]:
        """
        Claims AUTORITATIVOS para la sesión local: se prefiere /auth/me del Hub (trae
        is_hub_admin, roles, etc. que el token KC crudo no incluye). Si el Hub no
        responde, se cae al decode del token KC. Así la sesión local sabe si el
        usuario es hub_admin sin volver a consultar al Hub en cada request.
        """
        from core.security import verify_token

        claims: Dict[str, Any] = {}
        try:
            info = await verify_token(data.get("access_token", ""))
            if isinstance(info, dict):
                claims = dict(info)
        except Exception as exc:
            logger.warning("session_claims: /auth/me no disponible, uso decode KC: %s", exc)

        if not claims:
            return self._session_claims_from_kc(data)

        # tenant_id/tenant_slug explícitos del Hub tienen precedencia.
        if data.get("tenant_id") is not None:
            claims["tenant_id"] = data.get("tenant_id")
        if data.get("tenant_slug") is not None:
            claims["tenant_slug"] = data.get("tenant_slug")
        return claims

    async def refresh_token(self, refresh_data: RefreshTokenRequestDTO) -> TokenResponseDTO:
        """
        Refresca la sesión.

        - Intenta el refresh del token KC contra el Hub (comportamiento histórico).
        - Con SESSION_STORE_ENABLED, además emite/actualiza la sesión local del CRM
          (patrón SIWEB) que la app usa como bearer y que dura por inactividad, de
          modo que el refresh KC solo se intenta al expirar esa sesión (no cada ~60s).
        - Si el Hub RECHAZA el refresh se devuelve 401 y la sesión termina: se
          RESPETA la revocación central de Keycloak (sin re-emisión de fallback).
        """
        from datetime import datetime, timezone

        session_enabled = bool(getattr(settings, "SESSION_STORE_ENABLED", False))
        prev_claims = self._decode_local_session(refresh_data.session_token) if session_enabled else None
        prev_sst = prev_claims.get("sst") if prev_claims else None
        prev_session_id = refresh_data.session_id if session_enabled else None

        # Fuente del refresh KC: valkey (sesión) tiene precedencia sobre lo que
        # mande el cliente (puede estar desactualizado). Fail-silent.
        kc_refresh = refresh_data.refresh_token
        if session_enabled and prev_session_id:
            from core import session_store

            sess = session_store.get_session(prev_session_id)
            if sess and sess.get("refresh_token"):
                kc_refresh = sess["refresh_token"]

        # ── Intento de refresh del token KC contra el Hub ────────────────────────
        kc_ok = False
        data: Optional[Dict[str, Any]] = None
        try:
            async with httpx.AsyncClient(timeout=10.0) as client:
                response = await client.post(
                    f"{settings.HUB_URL}api/v1/auth/refresh",
                    json={"refresh_token": kc_refresh},
                )
            kc_ok = response.status_code == 200
            if kc_ok:
                data = response.json()
            else:
                logger.warning("Hub rechazó el refresh (status %s)", response.status_code)
        except Exception as exc:
            logger.warning("Hub inalcanzable en refresh: %s", exc)
            kc_ok = False

        # ── Camino feliz: el Hub renovó el token KC ──────────────────────────────
        if kc_ok and data is not None:
            from core.workspace_profile_sync import sync_workspace_profile_for_user
            from core.workspace_profile_client import WorkspaceProfileClient

            workspace_profile = None
            try:
                workspace_profile = await WorkspaceProfileClient().get_me(data.get("access_token", ""))
            except Exception as exc:
                logger.warning(
                    "workspace_profile_sync_failed",
                    extra={"event": "workspace_profile_sync_failed", "phase": "refresh", "error": str(exc)},
                )
                workspace_profile = None

            await sync_workspace_profile_for_user(
                self.db,
                access_token=data.get("access_token"),
                keycloak_user_id=(workspace_profile or {}).get("sub") or data.get("sub") or data.get("user_id"),
                tenant_id=data.get("tenant_id"),
                workspace_profile=workspace_profile,
                force=True,
            )

            resp = TokenResponseDTO(**data)

            if session_enabled:
                from core import local_session, session_store

                start = int(prev_sst) if prev_sst else int(datetime.now(timezone.utc).timestamp())
                claims = await self._session_claims(data)
                new_access = data.get("access_token", "")
                new_refresh = data.get("refresh_token", "")
                # Reutiliza la sesión de valkey si ya existía; si no, la crea.
                if prev_session_id and session_store.get_session(prev_session_id):
                    session_store.update_session_tokens(prev_session_id, new_access, new_refresh)
                    resp.session_id = prev_session_id
                else:
                    resp.session_id = session_store.create_session(new_access, new_refresh, start)
                resp.session_token = local_session.mint_session_token(claims, session_start=start)

            return resp

        # El Hub rechazó el refresh: la sesión termina y se RESPETA la revocación
        # central de Keycloak (no hay re-emisión local de fallback). El usuario
        # re-entra por el App Launcher. La sesión local de larga duración evita el
        # bucle: el refresh solo se intenta al expirar la sesión local por
        # inactividad (idle), no cada ~60s como con el token KC crudo.
        raise HTTPException(status_code=401, detail="Invalid or expired refresh token")

    async def get_user_info(self, access_token: str) -> UserInfoResponseDTO:
        """
        Obtiene información del usuario desde el Hub
        """
        from core.security import verify_token
        from core.workspace_profile_sync import sync_workspace_profile_for_user
        # Aprovechamos la verificación (y cache) de security.py
        user_info = await verify_token(access_token)

        kc_user = self._decode_kc_user_from_token(access_token)
        keycloak_user_id = self._pick_text(user_info.get("sub"), kc_user.get("sub"))

        needs_admin_fallback = any(
            not self._clean_text(user_info.get(field))
            for field in ("email", "preferred_username")
        ) or self._extract_avatar_url(user_info) is None

        kc_admin_user = None
        if needs_admin_fallback:
            kc_admin_user = await self._get_kc_admin_user(keycloak_user_id)

        first_name = self._pick_text(
            user_info.get("first_name"),
            user_info.get("given_name"),
            kc_user.get("given_name"),
            kc_user.get("first_name"),
            (kc_admin_user or {}).get("firstName"),
            (kc_admin_user or {}).get("first_name"),
        )
        last_name = self._pick_text(
            user_info.get("last_name"),
            user_info.get("family_name"),
            kc_user.get("family_name"),
            kc_user.get("last_name"),
            (kc_admin_user or {}).get("lastName"),
            (kc_admin_user or {}).get("last_name"),
        )
        full_name = self._pick_text(
            f"{first_name} {last_name}" if first_name and last_name else None,
            first_name,
            last_name,
        )

        enriched_user_info = dict(user_info)
        enriched_user_info["sub"] = keycloak_user_id or user_info.get("sub")
        enriched_user_info["email"] = self._pick_text(
            user_info.get("email"),
            (kc_admin_user or {}).get("email"),
            kc_user.get("email"),
        )
        enriched_user_info["preferred_username"] = self._pick_text(
            user_info.get("preferred_username"),
            user_info.get("username"),
            kc_user.get("preferred_username"),
            kc_user.get("username"),
            (kc_admin_user or {}).get("username"),
        )
        enriched_user_info["avatar_url"] = self._extract_avatar_url(
            user_info,
            kc_user,
            kc_admin_user or {},
        )
        enriched_user_info["name"] = self._pick_text(
            user_info.get("name"),
            full_name,
            kc_user.get("name"),
            enriched_user_info.get("preferred_username"),
        )

        await sync_workspace_profile_for_user(
            self.db,
            access_token=access_token,
            keycloak_user_id=enriched_user_info.get("sub"),
            tenant_id=enriched_user_info.get("tenant_id"),
            workspace_profile=enriched_user_info,
        )
        return UserInfoResponseDTO(**enriched_user_info)

    async def logout(self, logout_data: LogoutRequestDTO) -> dict:
        """
        Cierra sesión a través del Hub
        """
        try:
            async with httpx.AsyncClient(timeout=10.0) as client:
                await client.post(
                    f"{settings.HUB_URL}api/v1/auth/logout",
                    json=logout_data.model_dump()
                )
            return {"message": "Logged out successfully"}
        except Exception as e:
            logger.error(f"Logout error: {str(e)}")
            return {"message": "Logged out"}

    async def register(self, register_data: Any) -> Any:
        """
        Registra un usuario.
        - Si trae invite_token: valida el token local, crea usuario en Hub y
          genera la fila UserTenant local, luego consume el token.
        - Si no trae invite_token: reenvía directamente al Hub (flujo original).
        """
        if getattr(register_data, "invite_token", None):
            return await self._register_with_invite(register_data)

        # Flujo original — reenviar al Hub sin invite_token
        try:
            payload = register_data.model_dump(exclude={"invite_token"})
            async with httpx.AsyncClient(timeout=10.0) as client:
                response = await client.post(
                    f"{settings.HUB_URL}api/v1/auth/register",
                    json=payload,
                )
            if response.status_code == 201:
                return response.json()
            raise HTTPException(status_code=response.status_code, detail=response.text)
        except HTTPException:
            raise
        except Exception as e:
            logger.error(f"Registration error: {str(e)}")
            raise HTTPException(status_code=500, detail="Registration error")

    async def _register_with_invite(self, register_data: Any) -> Any:
        """Flujo de registro con token de invitación local."""
        from api.v1.modules.core.invites.service import InviteService
        from api.v1.modules.core.tenants.models import Tenant
        from api.v1.modules.core.user_tenant.models import UserTenant

        invite_service = InviteService(self.db)

        # 1. Validar invite token (sin consumir)
        invite_result = invite_service.validate(
            register_data.invite_token,
            register_data.tenant_slug,
            str(register_data.email),
        )

        # 2. Buscar tenant local
        tenant = (
            self.db.query(Tenant)
            .filter(Tenant.slug == register_data.tenant_slug)
            .first()
        )
        if not tenant:
            raise HTTPException(status_code=404, detail="Tenant no encontrado")

        # 3. Obtener token de service account y gestionar usuario en Hub
        hub_user_id = None
        try:
            async with httpx.AsyncClient(timeout=15.0) as client:
                # Login con service account
                login_resp = await client.post(
                    f"{settings.HUB_URL}api/v1/auth/login",
                    json={
                        "username": settings.HUB_ADMIN_EMAIL,
                        "password": settings.HUB_ADMIN_PASSWORD,
                    },
                )
                if login_resp.status_code != 200:
                    raise HTTPException(status_code=503, detail="No se pudo autenticar con el sistema de autenticación")
                svc_token = login_resp.json().get("access_token", "")

                # Verificar si el usuario ya existe en el Hub
                search_resp = await client.get(
                    f"{settings.HUB_URL}api/v1/hub/admins",
                    params={"email": str(register_data.email)},
                    headers={"Authorization": f"Bearer {svc_token}"},
                )
                existing_user = None
                if search_resp.status_code == 200:
                    admins = search_resp.json()
                    items = admins if isinstance(admins, list) else admins.get("items", [])
                    matches = [a for a in items if a.get("email", "").lower() == str(register_data.email).lower()]
                    if matches:
                        existing_user = matches[0]

                if existing_user:
                    # Usuario ya existe — solo vinculamos (no creamos nuevo)
                    hub_user_id = existing_user.get("id")
                else:
                    # Crear usuario via admin endpoint (no requiere invite_token)
                    hub_payload = {
                        "username": register_data.username,
                        "email": str(register_data.email),
                        "password": register_data.password,
                        "first_name": register_data.first_name,
                        "last_name": register_data.last_name,
                        "tenant_slug": register_data.tenant_slug,
                    }
                    create_resp = await client.post(
                        f"{settings.HUB_URL}api/v1/hub/admins",
                        json=hub_payload,
                        headers={"Authorization": f"Bearer {svc_token}"},
                    )
                    if create_resp.status_code in (200, 201):
                        hub_user_id = create_resp.json().get("id")
                    else:
                        try:
                            detail = create_resp.json().get("detail", create_resp.text)
                        except Exception:
                            detail = create_resp.text
                        raise HTTPException(status_code=create_resp.status_code, detail=detail)

        except HTTPException:
            raise
        except Exception as exc:
            logger.error("Hub admin create error during invite flow: %s", exc)
            raise HTTPException(status_code=503, detail="Error al crear usuario en el sistema de autenticación")

        # 4. Crear fila UserTenant y UserCompanyRole local
        if hub_user_id and invite_result.company_id:
            try:
                ut = UserTenant(
                    keycloak_user_id=hub_user_id,
                    tenant_id=tenant.id,
                    company_id=invite_result.company_id,
                    role=invite_result.role,
                    is_active=True,
                    first_name=register_data.first_name,
                    last_name=register_data.last_name,
                )
                self.db.add(ut)
                self.db.flush()
            except Exception as exc:
                logger.warning("Could not create UserTenant (may already exist): %s", exc)
                self.db.rollback()

            # Asignar UserCompanyRole para que el usuario tenga permisos resueltos
            try:
                from api.v1.modules.core.permissions.models import CompanyRole, UserCompanyRole
                company_role_obj = (
                    self.db.query(CompanyRole)
                    .filter(
                        CompanyRole.code == invite_result.role,
                        CompanyRole.company_id == invite_result.company_id,
                        CompanyRole.is_active == True,
                    )
                    .first()
                )
                if company_role_obj:
                    existing_ucr = (
                        self.db.query(UserCompanyRole)
                        .filter(
                            UserCompanyRole.user_id == hub_user_id,
                            UserCompanyRole.company_role_id == company_role_obj.id,
                            UserCompanyRole.company_id == invite_result.company_id,
                        )
                        .first()
                    )
                    if not existing_ucr:
                        ucr = UserCompanyRole(
                            user_id=hub_user_id,
                            company_role_id=company_role_obj.id,
                            company_id=invite_result.company_id,
                            tenant_id=tenant.id,
                            is_active=True,
                        )
                        self.db.add(ucr)
                self.db.commit()
            except Exception as exc:
                logger.warning("Could not create UserCompanyRole for invited user: %s", exc)
                self.db.rollback()

        # 5. Consumir invite token
        invite_service.consume_by_id(invite_result.invite_id)

        return {
            "user_id": hub_user_id or "",
            "username": register_data.username,
            "email": str(register_data.email),
            "message": "Usuario registrado exitosamente",
        }

    async def exchange_code(self, exchange_data: Any) -> TokenResponseDTO:
        """
        Intercambia código por tokens a través del Hub
        """
        try:
            async with httpx.AsyncClient(timeout=10.0) as client:
                response = await client.post(
                    f"{settings.HUB_URL}api/v1/auth/exchange-code",
                    json=exchange_data.model_dump()
                )
            if response.status_code == 200:
                data = response.json()
                # Lazy-link: crear UserTenant si hay invite pendiente
                try:
                    await self._link_pending_invite("", access_token=data.get("access_token", ""))
                except Exception as exc:
                    logger.warning("exchange_code lazy-link failed (non-blocking): %s", exc)
                # Backfill: crear UserCompanyRole faltantes para usuarios ya registrados
                try:
                    ec_claims = self._decode_kc_user_from_token(data.get("access_token", ""))
                    self._backfill_company_roles(ec_claims.get("sub") or data.get("sub"))
                except Exception as exc:
                    logger.warning("backfill_company_roles failed on exchange_code (non-blocking): %s", exc)
                return TokenResponseDTO(**data)
            raise HTTPException(status_code=response.status_code, detail="Code exchange failed")
        except Exception as e:
            logger.error(f"Exchange code error: {str(e)}")
            raise HTTPException(status_code=500, detail="Exchange code error")

    async def switch_tenant(self, **kwargs) -> TokenResponseDTO:
        """
        Cambia de tenant a través del Hub
        """
        try:
            async with httpx.AsyncClient(timeout=10.0) as client:
                response = await client.post(
                    f"{settings.HUB_URL}api/v1/auth/switch-tenant",
                    json=kwargs
                )
            if response.status_code == 200:
                return TokenResponseDTO(**response.json())
            raise HTTPException(status_code=response.status_code, detail="Switch tenant failed")
        except Exception as e:
            logger.error(f"Switch tenant error: {str(e)}")
            raise HTTPException(status_code=500, detail="Switch tenant error")

    async def sso_exchange(self, relay_token: str) -> TokenResponseDTO:
        """
        Canjea un relay token de un solo uso por KC tokens.
        Llama al Hub backend (server-to-server), sin Bearer requerido en el Hub.
        """
        try:
            async with httpx.AsyncClient(timeout=10.0) as client:
                response = await client.post(
                    f"{settings.HUB_URL}api/v1/auth/sso-exchange",
                    json={"relay_token": relay_token},
                )
            if response.status_code == 200:
                data = response.json()
                # Lazy-link: crear UserTenant si hay invite pendiente (usuario registrado vía workspace)
                try:
                    await self._link_pending_invite("", access_token=data.get("access_token", ""))
                except Exception as exc:
                    logger.warning("sso_exchange lazy-link failed (non-blocking): %s", exc)
                # Backfill: crear UserCompanyRole faltantes para usuarios ya registrados
                try:
                    sso_claims = self._decode_kc_user_from_token(data.get("access_token", ""))
                    self._backfill_company_roles(sso_claims.get("sub") or data.get("sub"))
                except Exception as exc:
                    logger.warning("backfill_company_roles failed on sso_exchange (non-blocking): %s", exc)
                return TokenResponseDTO(
                    access_token=data["access_token"],
                    refresh_token=data["refresh_token"],
                    token_type=data.get("token_type", "bearer"),
                    expires_in=data.get("expires_in", 3600),
                    tenant_id=data.get("tenant_id"),
                    tenant_slug=data.get("tenant_slug"),
                )
            raise HTTPException(
                status_code=response.status_code,
                detail=response.json().get("detail", "SSO exchange failed"),
            )
        except HTTPException:
            raise
        except Exception as e:
            logger.error(f"SSO exchange error: {str(e)}")
            raise HTTPException(status_code=500, detail="SSO exchange error")

    async def _link_pending_invite(self, username_or_email: str, access_token: str = None) -> None:
        """
        Lazy-link: después de un login exitoso comprueba si existe un invite_token
        pendiente para el email del usuario. Si lo hay, crea la fila UserTenant
        y consume el token.

        Si se provee access_token, extrae hub_user_id y email directamente del JWT
        sin necesidad de un lookup extra al Hub.
        """
        from datetime import datetime, timezone
        from api.v1.modules.core.invites.models import InviteToken
        from api.v1.modules.core.tenants.models import Tenant
        from api.v1.modules.core.user_tenant.models import UserTenant

        hub_user_id = None
        user_email = username_or_email

        # Si tenemos el access_token, extraer info del JWT directamente (sin red)
        if access_token:
            try:
                claims = self._decode_kc_user_from_token(access_token)
                hub_user_id = claims.get("sub")
                user_email = claims.get("email") or username_or_email
            except Exception as exc:
                logger.debug("_link_pending_invite: JWT decode failed: %s", exc)

        # Sin access_token: buscar usuario en el Hub vía service account
        if not hub_user_id:
            if not user_email:
                return  # Sin email ni hub_user_id no podemos buscar el invite
            try:
                async with httpx.AsyncClient(timeout=10.0) as client:
                    login_resp = await client.post(
                        f"{settings.HUB_URL}api/v1/auth/login",
                        json={"username": settings.HUB_ADMIN_EMAIL, "password": settings.HUB_ADMIN_PASSWORD},
                    )
                    if login_resp.status_code != 200:
                        return
                    svc_token = login_resp.json().get("access_token", "")
                    search_resp = await client.get(
                        f"{settings.HUB_URL}api/v1/hub/admins",
                        params={"email": username_or_email},
                        headers={"Authorization": f"Bearer {svc_token}"},
                    )
                    if search_resp.status_code == 200:
                        items = search_resp.json()
                        items = items if isinstance(items, list) else items.get("items", [])
                        matches = [
                            u for u in items
                            if u.get("email", "").lower() == username_or_email.lower()
                            or u.get("username", "").lower() == username_or_email.lower()
                        ]
                        if matches:
                            hub_user_id = matches[0].get("id")
                            user_email = matches[0].get("email", username_or_email)
                    if not hub_user_id:
                        return
            except Exception as exc:
                logger.debug("_link_pending_invite: hub lookup failed: %s", exc)
                return

        now = datetime.now(timezone.utc)
        pending = (
            self.db.query(InviteToken)
            .filter(
                InviteToken.email == user_email,
                InviteToken.used_at.is_(None),
                InviteToken.expires_at > now,
            )
            .first()
        )
        if not pending:
            return

        tenant = (
            self.db.query(Tenant)
            .filter(Tenant.slug == pending.tenant_slug)
            .first()
        )
        if not tenant:
            logger.warning("_link_pending_invite: tenant %s not found", pending.tenant_slug)
            return

        # Evitar duplicados
        existing = (
            self.db.query(UserTenant)
            .filter(
                UserTenant.keycloak_user_id == hub_user_id,
                UserTenant.tenant_id == tenant.id,
            )
            .first()
        )
        if existing:
            # Vincular existe, solo consumir el token
            pending.used_at = now
            self.db.commit()
            return

        try:
            ut = UserTenant(
                keycloak_user_id=hub_user_id,
                tenant_id=tenant.id,
                company_id=pending.company_id,
                role=pending.role,
                is_active=True,
            )
            self.db.add(ut)
            self.db.flush()
            logger.info(
                "Lazy-link: UserTenant created for user=%s tenant=%s role=%s",
                hub_user_id,
                tenant.slug,
                pending.role,
            )
        except Exception as exc:
            logger.warning("_link_pending_invite: could not create UserTenant: %s", exc)
            self.db.rollback()

        # Asignar UserCompanyRole para que el usuario tenga permisos resueltos
        if pending.company_id:
            try:
                from api.v1.modules.core.permissions.models import CompanyRole, UserCompanyRole
                company_role_obj = (
                    self.db.query(CompanyRole)
                    .filter(
                        CompanyRole.code == pending.role,
                        CompanyRole.company_id == pending.company_id,
                        CompanyRole.is_active == True,
                    )
                    .first()
                )
                if company_role_obj:
                    existing_ucr = (
                        self.db.query(UserCompanyRole)
                        .filter(
                            UserCompanyRole.user_id == hub_user_id,
                            UserCompanyRole.company_role_id == company_role_obj.id,
                            UserCompanyRole.company_id == pending.company_id,
                        )
                        .first()
                    )
                    if not existing_ucr:
                        ucr = UserCompanyRole(
                            user_id=hub_user_id,
                            company_role_id=company_role_obj.id,
                            company_id=pending.company_id,
                            tenant_id=tenant.id,
                            is_active=True,
                        )
                        self.db.add(ucr)
            except Exception as exc:
                logger.warning("_link_pending_invite: could not create UserCompanyRole: %s", exc)

        pending.used_at = now
        self.db.commit()

    def _backfill_company_roles(self, hub_user_id: str) -> None:
        """
        Self-healing: para usuarios ya registrados vía invitación que tienen UserTenant
        pero no UserCompanyRole (creados antes del fix del flujo de invitación).
        Por cada UserTenant activo con role y company_id busca el CompanyRole y crea
        el UserCompanyRole si no existe. Non-blocking.
        """
        if not hub_user_id:
            return
        try:
            from api.v1.modules.core.user_tenant.models import UserTenant
            from api.v1.modules.core.permissions.models import CompanyRole, UserCompanyRole

            user_tenants = (
                self.db.query(UserTenant)
                .filter(
                    UserTenant.keycloak_user_id == hub_user_id,
                    UserTenant.is_active == True,
                    UserTenant.company_id.isnot(None),
                    UserTenant.role.isnot(None),
                )
                .all()
            )

            changed = False
            for ut in user_tenants:
                company_role_obj = (
                    self.db.query(CompanyRole)
                    .filter(
                        CompanyRole.code == ut.role,
                        CompanyRole.company_id == ut.company_id,
                        CompanyRole.is_active == True,
                    )
                    .first()
                )
                if not company_role_obj:
                    continue

                existing = (
                    self.db.query(UserCompanyRole)
                    .filter(
                        UserCompanyRole.user_id == hub_user_id,
                        UserCompanyRole.company_role_id == company_role_obj.id,
                        UserCompanyRole.company_id == ut.company_id,
                    )
                    .first()
                )
                if not existing:
                    self.db.add(UserCompanyRole(
                        user_id=hub_user_id,
                        company_role_id=company_role_obj.id,
                        company_id=ut.company_id,
                        tenant_id=ut.tenant_id,
                        is_active=True,
                    ))
                    changed = True
                    logger.info(
                        "backfill: UserCompanyRole created for user=%s company=%s role=%s",
                        hub_user_id, ut.company_id, ut.role,
                    )

            if changed:
                self.db.commit()
        except Exception as exc:
            logger.warning("_backfill_company_roles failed (non-blocking): %s", exc)
            self.db.rollback()

