import base64
import json
import uuid
import warnings
from datetime import datetime, timedelta, timezone
from types import MappingProxyType
from typing import Annotated, Optional, cast
from urllib.parse import quote, urlencode
from uuid import UUID as parse_uuid

from fastapi import (
    APIRouter,
    BackgroundTasks,
    Header,
    HTTPException,
    Request,
    Response,
    status,
)
from fastapi.responses import JSONResponse, RedirectResponse
from pydantic import BaseModel, SecretStr
from server.auth.constants import (
    KEYCLOAK_CLIENT_ID,
    KEYCLOAK_REALM_NAME,
    KEYCLOAK_SERVER_URL_EXT,
    RECAPTCHA_SITE_KEY,
    ROLE_CHECK_ENABLED,
)
from server.auth.cookie_chunking import (
    delete_chunked_cookie,
    read_chunked_cookie,
    set_chunked_cookie,
)
from server.auth.gitlab_sync import schedule_gitlab_repo_sync
from server.auth.recaptcha_service import recaptcha_service
from server.auth.saas_user_auth import SaasUserAuth
from server.auth.token_manager import TokenManager
from server.auth.user.user_authorizer import (
    UserAuthorizer,
    depends_user_authorizer,
)
from server.constants import (
    DEPLOYMENT_MODE,
    IS_FEATURE_ENV,
)
from server.services.org_invitation_service import (
    EmailMismatchError,
    InvitationExpiredError,
    InvitationInvalidError,
    OrgInvitationService,
    UserAlreadyMemberError,
)
from server.utils.conversation_utils import get_session_api_key, get_user_id
from server.utils.rate_limit_utils import check_rate_limit_by_user_id
from server.utils.url_utils import get_cookie_domain, get_cookie_samesite, get_web_url
from sqlalchemy import select
from storage.database import a_session_maker
from storage.user import User
from storage.user_store import UserStore

from openhands.analytics import get_analytics_service, resolve_analytics_context
from openhands.app_server.integrations.provider import (
    PROVIDER_TOKEN_TYPE,
    ProviderHandler,
    ProviderToken,
)
from openhands.app_server.integrations.service_types import ProviderType, TokenResponse
from openhands.app_server.user_auth import get_access_token
from openhands.app_server.user_auth.user_auth import get_user_auth
from openhands.app_server.utils.logger import openhands_logger as logger

with warnings.catch_warnings():
    warnings.simplefilter('ignore')

api_router = APIRouter(prefix='/api')
oauth_router = APIRouter(prefix='/oauth')

token_manager = TokenManager()


def create_provider_tokens_object(
    providers_set: list[ProviderType],
) -> PROVIDER_TOKEN_TYPE:
    """Create provider tokens object for the given providers."""
    provider_information: dict[ProviderType, ProviderToken] = {}

    for provider in providers_set:
        provider_information[provider] = ProviderToken(token=None, user_id=None)

    return MappingProxyType(provider_information)


def set_response_cookie(
    request: Request,
    response: Response,
    keycloak_access_token: str,
    keycloak_refresh_token: str,
    secure: bool = True,
    accepted_tos: bool = False,
):
    # Create a signed JWT token
    cookie_data = {
        'access_token': keycloak_access_token,
        'refresh_token': keycloak_refresh_token,
        'accepted_tos': accepted_tos,
    }
    from storage.encrypt_utils import get_jwt_service

    signed_token = get_jwt_service().create_jws_token(
        cookie_data, expires_in=timedelta(weeks=1)
    )

    # Set secure cookie with signed token. The value can exceed the
    # browser's 4096-byte single-cookie cap for users with large Keycloak
    # claim sets, so write it through the chunked-cookie helper, which
    # splits oversized values across sibling cookies and stays
    # byte-identical for values that fit in one cookie.
    set_chunked_cookie(
        response,
        'keycloak_auth',
        signed_token,
        domain=get_cookie_domain(),
        secure=secure,
        httponly=True,
        samesite=get_cookie_samesite(),
    )


def _extract_oauth_state(state: str | None) -> tuple[str, str | None, str | None]:
    """Extract redirect URL, reCAPTCHA token, and invitation token from OAuth state.

    Returns:
        Tuple of (redirect_url, recaptcha_token, invitation_token).
        Tokens may be None.
    """
    if not state:
        return '', None, None

    try:
        # Try to decode as JSON (new format with reCAPTCHA and/or invitation)
        state_data = json.loads(base64.urlsafe_b64decode(state.encode()).decode())
        return (
            state_data.get('redirect_url', ''),
            state_data.get('recaptcha_token'),
            state_data.get('invitation_token'),
        )
    except Exception:
        # Old format - state is just the redirect URL
        return state, None, None


async def _get_user_orgs_with_data(user_id: str, org_member_ids: list) -> list:
    """Load Org objects for a user's org memberships.

    Uses OrgStore.get_orgs_by_ids() to batch-load all Org objects in a single
    query, avoiding N+1.

    Args:
        user_id: The user's ID string
        org_member_ids: List of org_id UUIDs from user.org_members

    Returns:
        List of Org objects the user belongs to
    """
    from storage.org_store import OrgStore

    if not org_member_ids:
        return []

    try:
        return await OrgStore.get_orgs_by_ids(org_member_ids)
    except Exception:
        logger.exception(
            'auth:_get_user_orgs_with_data:failed',
            extra={'user_id': user_id, 'org_ids': [str(oid) for oid in org_member_ids]},
        )
        return []


async def _track_login_analytics_background(
    user_id: str,
    email: str | None,
    idp: str,
    current_org_id: parse_uuid | None,
    org_member_ids: list,
    consented: bool,
) -> None:
    """Track login analytics in background to avoid blocking auth response."""
    try:
        from storage.org_member_store import OrgMemberStore
        from storage.org_store import OrgStore

        analytics = get_analytics_service()
        if not analytics:
            return

        org_id_str = str(current_org_id) if current_org_id else None

        # Load current org
        current_org = (
            await OrgStore.get_org_by_id(current_org_id) if current_org_id else None
        )

        # Load org data (orgs list with member_count)
        user_orgs = await _get_user_orgs_with_data(user_id, org_member_ids)

        orgs_data = []
        for org in user_orgs:
            try:
                member_count = await OrgMemberStore.get_org_members_count(org_id=org.id)
            except Exception:
                logger.exception(
                    'auth:identify_user:member_count_failed',
                    extra={'user_id': user_id, 'org_id': str(org.id)},
                )
                member_count = None
            orgs_data.append(
                {'id': str(org.id), 'name': org.name, 'member_count': member_count}
            )

        from openhands.analytics.analytics_context import AnalyticsContext

        ctx = AnalyticsContext(
            user_id=user_id,
            consented=consented,
            org_id=org_id_str,
            user=None,
        )

        analytics.identify_user(
            ctx=ctx,
            email=email,
            org_name=current_org.name if current_org else None,
            idp=idp,
            orgs=orgs_data,
        )

        analytics.track_user_logged_in(
            ctx=ctx,
            idp=idp,
        )
    except Exception:
        logger.exception(
            'auth:_track_login_analytics_background:failed',
            extra={'user_id': user_id},
        )


@oauth_router.get('/keycloak/callback')
async def keycloak_callback(
    request: Request,
    background_tasks: BackgroundTasks,
    code: Optional[str] = None,
    state: Optional[str] = None,
    error: Optional[str] = None,
    error_description: Optional[str] = None,
    user_authorizer: UserAuthorizer = depends_user_authorizer(),
):
    # Extract redirect URL, reCAPTCHA token, and invitation token from state
    redirect_url, recaptcha_token, invitation_token = _extract_oauth_state(state)

    if redirect_url is None:
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail='Missing state in request params',
        )

    if not code:
        # check if this is a forward from the account linking page
        if (
            error == 'temporarily_unavailable'
            and error_description == 'authentication_expired'
        ):
            return RedirectResponse(redirect_url, status_code=302)
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail='Missing code in request params',
        )

    web_url = get_web_url(request)
    redirect_uri = web_url + request.url.path

    (
        keycloak_access_token,
        keycloak_refresh_token,
    ) = await token_manager.get_keycloak_tokens(code, redirect_uri)
    if not keycloak_access_token or not keycloak_refresh_token:
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail='Problem retrieving Keycloak tokens',
        )

    user_info = await token_manager.get_user_info(keycloak_access_token)
    logger.debug(f'user_info: {user_info}')
    if ROLE_CHECK_ENABLED and user_info.roles is None:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED, detail='Missing required role'
        )

    authorization = await user_authorizer.authorize_user(user_info)
    if not authorization.success:
        # For duplicate_email errors, clean up the newly created Keycloak user
        # (only if they're not already in our UserStore, i.e., they're a new user)
        if authorization.error_detail == 'duplicate_email':
            try:
                existing_user = await UserStore.get_user_by_id(user_info.sub)
                if not existing_user:
                    # New user created during OAuth should be deleted from Keycloak
                    await token_manager.delete_keycloak_user(user_info.sub)
                    logger.info(
                        f'Deleted orphaned Keycloak user {user_info.sub} '
                        'after duplicate_email rejection'
                    )
            except Exception as e:
                # Log but don't fail - user should still get 401 response
                logger.warning(
                    f'Failed to clean up orphaned Keycloak user {user_info.sub}: {e}'
                )
        # Return unauthorized
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail=authorization.error_detail,
        )

    email = user_info.email
    user_id = user_info.sub
    user_info_dict = user_info.model_dump(exclude_none=True)
    user = await UserStore.get_user_by_id(user_id)
    if not user:
        user = await UserStore.create_user(user_id, user_info_dict)
    else:
        # Existing user — gradually backfill contact_name if it still has a username-style value
        await UserStore.backfill_contact_name(user_id, user_info_dict)
        await UserStore.backfill_user_email(user_id, user_info_dict)

    if not user:
        logger.error(f'Failed to authenticate user {user_info.email}')
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail=f'Failed to authenticate user {user_info.email}',
        )

    logger.info(f'Logging in user {str(user.id)} in org {user.current_org_id}')

    # reCAPTCHA verification with Account Defender
    if RECAPTCHA_SITE_KEY:
        if not recaptcha_token:
            logger.warning(
                'recaptcha_token_missing',
                extra={
                    'user_id': user_id,
                    'email': email,
                },
            )
            error_url = f'{web_url}/login?recaptcha_blocked=true'
            return RedirectResponse(error_url, status_code=302)

        user_ip = request.client.host if request.client else 'unknown'
        user_agent = request.headers.get('User-Agent', '')

        # Handle X-Forwarded-For for proxied requests
        forwarded_for = request.headers.get('X-Forwarded-For')
        if forwarded_for:
            user_ip = forwarded_for.split(',')[0].strip()

        try:
            result = recaptcha_service.create_assessment(
                token=recaptcha_token,
                action='LOGIN',
                user_ip=user_ip,
                user_agent=user_agent,
                email=email,
                user_id=user_id,
            )

            if not result.allowed:
                logger.warning(
                    'recaptcha_blocked_at_callback',
                    extra={
                        'user_ip': user_ip,
                        'score': result.score,
                        'user_id': user_id,
                    },
                )
                # Redirect to home with error parameter
                error_url = f'{web_url}/login?recaptcha_blocked=true'
                return RedirectResponse(error_url, status_code=302)

        except Exception as e:
            logger.exception(f'reCAPTCHA verification error at callback: {e}')
            # Fail open - continue with login if reCAPTCHA service unavailable

    # Check email verification status
    email_verified = user_info.email_verified or False
    if not email_verified:
        # Send verification email with rate limiting to prevent abuse
        # Users who repeatedly login without verifying would otherwise trigger
        # unlimited verification emails
        # Import locally to avoid circular import with email.py
        from server.routes.email import verify_email

        # Rate limit verification emails during auth flow (60 seconds per user)
        # This is separate from the manual resend rate limit which uses 30 seconds
        rate_limited = False
        try:
            await check_rate_limit_by_user_id(
                request=request,
                key_prefix='auth_verify_email',
                user_id=user_id,
                user_rate_limit_seconds=60,
                ip_rate_limit_seconds=120,
            )
            await verify_email(request=request, user_id=user_id, is_auth_flow=True)
        except HTTPException as e:
            if e.status_code == status.HTTP_429_TOO_MANY_REQUESTS:
                # Rate limited - still redirect to verification page but don't send email
                rate_limited = True
                logger.info(
                    f'Rate limited verification email for user {user_id} during auth flow'
                )
            else:
                raise

        verification_redirect_url = (
            f'{web_url}/login?email_verification_required=true&user_id={user_id}'
        )
        if rate_limited:
            verification_redirect_url = f'{verification_redirect_url}&rate_limited=true'

        # Preserve invitation token so it can be included in OAuth state after verification
        if invitation_token:
            verification_redirect_url = (
                f'{verification_redirect_url}&invitation_token={invitation_token}'
            )
        response = RedirectResponse(verification_redirect_url, status_code=302)
        return response

    # default to github IDP for now.
    # TODO: remove default once Keycloak is updated universally with the new attribute.
    idp: str = user_info.identity_provider or ProviderType.GITHUB.value
    logger.info(f'Full IDP is {idp}')
    idp_type = 'oidc'
    if ':' in idp:
        idp, idp_type = idp.rsplit(':', 1)
        idp_type = idp_type.lower()

    # Only fetch/store IdP tokens for OAuth-based IdPs (not SAML)
    # SAML IdPs don't have OAuth tokens to retrieve from Keycloak's broker endpoint
    if idp_type != 'saml':
        await token_manager.store_idp_tokens(
            ProviderType(idp), user_id, keycloak_access_token
        )

    valid_offline_token = (
        await token_manager.validate_offline_token(user_id=user_info.sub)
        if idp_type != 'saml'
        else True
    )

    logger.debug(
        f'keycloakAccessToken: {keycloak_access_token}, keycloakUserId: {user_id}'
    )

    # Server-side identity — defer to background to avoid blocking auth response
    consented = user.user_consents_to_analytics is True
    org_member_ids = [om.org_id for om in user.org_members] if user.org_members else []

    background_tasks.add_task(
        _track_login_analytics_background,
        user_id=user_id,
        email=email,
        idp=idp,
        current_org_id=user.current_org_id,
        org_member_ids=org_member_ids,
        consented=consented,
    )

    logger.info(
        'user_logged_in',
        extra={
            'idp': idp,
            'idp_type': idp_type,
            'user_id': user_id,
            'is_feature_env': IS_FEATURE_ENV,
        },
    )

    if not valid_offline_token:
        param_str = urlencode(
            {
                'client_id': KEYCLOAK_CLIENT_ID,
                'response_type': 'code',
                'kc_idp_hint': idp,
                'redirect_uri': f'{web_url}/oauth/keycloak/offline/callback',
                'scope': 'openid email profile offline_access',
                'state': state,
            }
        )
        redirect_url = (
            f'{KEYCLOAK_SERVER_URL_EXT}/realms/{KEYCLOAK_REALM_NAME}/protocol/openid-connect/auth'
            f'?{param_str}'
        )

    has_accepted_tos = user.accepted_tos is not None

    # Process invitation token if present (after email verification but before TOS)
    if invitation_token:
        try:
            logger.info(
                'Processing invitation token during auth callback',
                extra={
                    'user_id': user_id,
                    'invitation_token_prefix': invitation_token[:10] + '...',
                },
            )

            await OrgInvitationService.accept_invitation(
                invitation_token, parse_uuid(user_id)
            )
            logger.info(
                'Invitation accepted during auth callback',
                extra={'user_id': user_id},
            )

        except InvitationExpiredError:
            logger.warning(
                'Invitation expired during auth callback',
                extra={'user_id': user_id},
            )
            # Add query param to redirect URL
            if '?' in redirect_url:
                redirect_url = f'{redirect_url}&invitation_expired=true'
            else:
                redirect_url = f'{redirect_url}?invitation_expired=true'

        except InvitationInvalidError as e:
            logger.warning(
                'Invalid invitation during auth callback',
                extra={'user_id': user_id, 'error': str(e)},
            )
            if '?' in redirect_url:
                redirect_url = f'{redirect_url}&invitation_invalid=true'
            else:
                redirect_url = f'{redirect_url}?invitation_invalid=true'

        except UserAlreadyMemberError:
            logger.info(
                'User already member during invitation acceptance',
                extra={'user_id': user_id},
            )
            if '?' in redirect_url:
                redirect_url = f'{redirect_url}&already_member=true'
            else:
                redirect_url = f'{redirect_url}?already_member=true'

        except EmailMismatchError as e:
            logger.warning(
                'Email mismatch during auth callback invitation acceptance',
                extra={'user_id': user_id, 'error': str(e)},
            )
            if '?' in redirect_url:
                redirect_url = f'{redirect_url}&email_mismatch=true'
            else:
                redirect_url = f'{redirect_url}?email_mismatch=true'

        except Exception as e:
            logger.exception(
                'Unexpected error processing invitation during auth callback',
                extra={'user_id': user_id, 'error': str(e)},
            )
            # Don't fail the login if invitation processing fails
            if '?' in redirect_url:
                redirect_url = f'{redirect_url}&invitation_error=true'
            else:
                redirect_url = f'{redirect_url}?invitation_error=true'

    # If the user hasn't accepted the TOS, redirect to the TOS page
    if not has_accepted_tos:
        encoded_redirect_url = quote(redirect_url, safe='')
        tos_redirect_url = f'{web_url}/accept-tos?redirect_url={encoded_redirect_url}'
        if invitation_token:
            tos_redirect_url = f'{tos_redirect_url}&invitation_success=true'
        response = RedirectResponse(tos_redirect_url, status_code=302)
    else:
        # User has accepted TOS - check if they need onboarding
        # Only redirect to onboarding if user has a valid offline token,
        # otherwise they need to complete the Keycloak offline token flow first
        if valid_offline_token and await _should_redirect_to_onboarding(user_id, user):
            redirect_url = f'{web_url}/onboarding'
            logger.info(
                'Redirecting returning user to onboarding',
                extra={'user_id': user_id, 'deployment_mode': DEPLOYMENT_MODE},
            )
        if invitation_token:
            if '?' in redirect_url:
                redirect_url = f'{redirect_url}&invitation_success=true'
            else:
                redirect_url = f'{redirect_url}?invitation_success=true'
        response = RedirectResponse(redirect_url, status_code=302)

    set_response_cookie(
        request=request,
        response=response,
        keycloak_access_token=keycloak_access_token,
        keycloak_refresh_token=keycloak_refresh_token,
        secure=True if web_url.startswith('https') else False,
        accepted_tos=has_accepted_tos,
    )

    # Sync GitLab repos & set up webhooks
    # Use Keycloak access token (first-time users lack offline token at this stage)
    # Normally, offline token is used to fetch GitLab token via user_id
    schedule_gitlab_repo_sync(user_id, SecretStr(keycloak_access_token))
    return response


@oauth_router.get('/keycloak/offline/callback')
async def keycloak_offline_callback(code: str, state: str, request: Request):
    if not code:
        return JSONResponse(
            status_code=status.HTTP_400_BAD_REQUEST,
            content={'error': 'Missing code in request params'},
        )

    web_url = get_web_url(request)
    redirect_uri = web_url + request.url.path
    logger.debug(f'code: {code}, redirect_uri: {redirect_uri}')

    (
        keycloak_access_token,
        keycloak_refresh_token,
    ) = await token_manager.get_keycloak_tokens(code, redirect_uri)
    if not keycloak_access_token or not keycloak_refresh_token:
        return JSONResponse(
            status_code=status.HTTP_400_BAD_REQUEST,
            content={'error': 'Problem retrieving Keycloak tokens'},
        )

    user_info = await token_manager.get_user_info(keycloak_access_token)
    logger.debug(f'user_info: {user_info}')
    # sub is a required field in KeycloakUserInfo, validation happens in get_user_info

    await token_manager.store_offline_token(
        user_id=user_info.sub, offline_token=keycloak_refresh_token
    )

    user = await UserStore.get_user_by_id(user_info.sub)
    redirect_url, _, _ = _extract_oauth_state(state)
    default_url = redirect_url if redirect_url else web_url
    final_url = await _get_post_auth_redirect(user_info.sub, default_url, web_url, user)

    # Intentionally do NOT write tokens into the `keycloak_auth` cookie:
    # the cookie tracks the regular (online) session and is the token
    # passed to Keycloak's /logout endpoint. Putting the offline token
    # in the cookie causes logout to terminate the offline session.
    return RedirectResponse(final_url, status_code=302)


@oauth_router.get('/github/callback')
async def github_dummy_callback(request: Request):
    """Callback for GitHub that just forwards the user to the app base URL."""
    web_url = get_web_url(request)
    return RedirectResponse(web_url, status_code=302)


@api_router.post('/authenticate')
async def authenticate(request: Request):
    try:
        await get_access_token(request)
        return JSONResponse(
            status_code=status.HTTP_200_OK, content={'message': 'User authenticated'}
        )
    except Exception:
        # For any error during authentication, clear the auth cookie and return 401
        response = JSONResponse(
            status_code=status.HTTP_401_UNAUTHORIZED,
            content={'error': 'User is not authenticated'},
        )

        # Delete the auth cookie (and any sibling chunks) if it exists
        keycloak_auth_cookie = read_chunked_cookie(request, 'keycloak_auth')
        if keycloak_auth_cookie:
            delete_chunked_cookie(
                response,
                'keycloak_auth',
                domain=get_cookie_domain(),
                samesite=get_cookie_samesite(),
            )

        return response


async def _should_redirect_to_onboarding(user_id: str, user: User) -> bool:
    """Check if user should be redirected to onboarding after TOS acceptance.
    Backend always redirects applicable users to /onboarding.
    Returns True if:
    - User has onboarding_completed explicitly set to False (new users)
    - Either:
      - Deployment mode is 'cloud' (all users)
      - Deployment mode is 'self_hosted' AND user is the super admin
        (first owner in their current org to accept TOS)

    Returns False if:
    - User has onboarding_completed=True (already completed)
    - User has onboarding_completed=None (existing users before this feature)
    """
    # Already completed onboarding
    if user.onboarding_completed is True:
        return False

    # Existing user before this feature (NULL in database)
    if user.onboarding_completed is None:
        return False

    # Cloud SaaS: all users go to onboarding
    if DEPLOYMENT_MODE == 'cloud':
        return True

    # Self-hosted SaaS: only the super admin (first owner to accept TOS in the org)
    if DEPLOYMENT_MODE == 'self_hosted':
        first_owner = await UserStore.get_first_owner_in_org(user.current_org_id)
        if first_owner and str(first_owner.id) == user_id:
            return True

    return False


async def _get_post_auth_redirect(
    user_id: str, default_url: str, web_url: str, user: User | None = None
) -> str:
    """Determine where to redirect user after authentication completes.

    Called after offline token is stored to determine final redirect destination.
    Checks for pending user flows (e.g., onboarding) before falling back to default.

    Args:
        user_id: The user's ID.
        default_url: The default URL to redirect to if no special flow is needed.
        web_url: The base web URL for constructing absolute paths.
        user: Optional user object to avoid refetching.

    Returns:
        The URL to redirect the user to.
    """
    if not user:
        user = await UserStore.get_user_by_id(user_id)
    if user and await _should_redirect_to_onboarding(user_id, user):
        logger.info(
            'Redirecting user to onboarding',
            extra={'user_id': user_id, 'deployment_mode': DEPLOYMENT_MODE},
        )
        return f'{web_url}/onboarding'
    return default_url


@api_router.post('/accept_tos')
async def accept_tos(request: Request):
    user_auth = cast(SaasUserAuth, await get_user_auth(request))
    access_token = await user_auth.get_access_token()
    refresh_token = user_auth.refresh_token
    user_id = await user_auth.get_user_id()

    if not access_token or not refresh_token or not user_id:
        logger.warning(
            f'accept_tos: One or more is None: access_token {access_token}, refresh_token {refresh_token}, user_id {user_id}'
        )
        return JSONResponse(
            status_code=status.HTTP_401_UNAUTHORIZED,
            content={'error': 'User is not authenticated'},
        )

    # Get redirect URL from request body
    body = await request.json()
    web_url = get_web_url(request)
    redirect_url = body.get('redirect_url', str(web_url))

    # Update user settings with TOS acceptance
    accepted_tos: datetime = datetime.now(timezone.utc).replace(tzinfo=None)
    async with a_session_maker() as session:
        result = await session.execute(
            select(User).where(User.id == uuid.UUID(user_id))
        )
        user = result.scalar_one_or_none()
        if not user:
            await session.rollback()
            logger.error('User for {user_id} not found.')
            return JSONResponse(
                status_code=status.HTTP_401_UNAUTHORIZED,
                content={'error': 'User does not exist'},
            )
        user.accepted_tos = accepted_tos
        # SaaS users consent to analytics via Terms of Service acceptance
        user.user_consents_to_analytics = True
        await session.commit()

        logger.info(f'User {user_id} accepted TOS')

        # Analytics: user signed up event (fires on first TOS acceptance)
        try:
            analytics = get_analytics_service()
            if analytics:
                from openhands.analytics.analytics_context import AnalyticsContext

                org_id_str = str(user.current_org_id) if user.current_org_id else None
                email = user.email

                ctx = AnalyticsContext(
                    user_id=user_id,
                    consented=True,
                    org_id=org_id_str,
                    user=user,
                )
                analytics.track_user_signed_up(
                    ctx=ctx,
                    email_domain=email.split('@')[1]
                    if email and '@' in email
                    else None,
                )
                analytics.set_person_properties(
                    ctx=ctx,
                    properties={'signed_up_at': datetime.now(timezone.utc).isoformat()},
                )
        except Exception:
            logger.exception('analytics:user_signed_up:failed')

    # Determine final redirect - but don't override if it's the offline token flow
    # (the offline callback will handle post-auth redirect after storing the token)
    is_offline_flow = 'offline' in redirect_url
    if not is_offline_flow:
        redirect_url = await _get_post_auth_redirect(user_id, redirect_url, web_url)

    response = JSONResponse(
        status_code=status.HTTP_200_OK, content={'redirect_url': redirect_url}
    )

    set_response_cookie(
        request=request,
        response=response,
        keycloak_access_token=access_token.get_secret_value(),
        keycloak_refresh_token=refresh_token.get_secret_value(),
        secure=True if web_url.startswith('https') else False,
        accepted_tos=True,
    )
    return response


@api_router.get('/onboarding_status')
async def onboarding_status(request: Request):
    """Return whether the current user must still complete onboarding.

    Kept as a dedicated endpoint instead of riding on ``GET /api/v1/settings``
    (the natural home for fields like ``email_verified``) because the settings
    response is heavyweight: ``SaasSettingsStore.load`` joins User, Org, and
    OrgMember rows and deep-merges the org-level and member-level
    ``agent_settings`` before returning. Onboarding gating runs on every
    protected-route navigation, so we need a lightweight read of a single
    boolean rather than paying for the full settings aggregation.
    """
    user_auth = cast(SaasUserAuth, await get_user_auth(request))
    user_id = await user_auth.get_user_id()

    if not user_id:
        return JSONResponse(
            status_code=status.HTTP_401_UNAUTHORIZED,
            content={'error': 'User is not authenticated'},
        )

    user = await UserStore.get_user_by_id(user_id)
    if not user:
        return JSONResponse(
            status_code=status.HTTP_404_NOT_FOUND,
            content={'error': 'User not found'},
        )

    should_complete = await _should_redirect_to_onboarding(user_id, user)
    return JSONResponse(
        status_code=status.HTTP_200_OK,
        content={'should_complete_onboarding': should_complete},
    )


class OnboardingSubmission(BaseModel):
    """Payload posted from the onboarding form.

    ``selections`` maps onboarding question_id -> selected option(s), e.g.
    ``{"role": "software_engineer", "org_size": "solo",
       "use_case": ["new_features", "fixing_bugs"]}``.

    The field is optional so the endpoint stays backwards-compatible with any
    client that previously called it with an empty body, but the current
    frontend always submits a populated mapping.
    """

    selections: dict[str, str | list[str]] = {}


@api_router.post('/complete_onboarding')
async def complete_onboarding(
    request: Request, body: OnboardingSubmission | None = None
):
    """Mark onboarding as completed for the current user and fire analytics.

    Persists ``user.onboarding_completed = True`` and emits the
    ``onboarding completed`` PostHog event (plus an org ``group_identify`` to
    stamp ``onboarding_completed_at``). Analytics failures are swallowed so
    they never block the user from leaving the onboarding flow.
    """
    user_auth = cast(SaasUserAuth, await get_user_auth(request))
    user_id = await user_auth.get_user_id()

    if not user_id:
        return JSONResponse(
            status_code=status.HTTP_401_UNAUTHORIZED,
            content={'error': 'User is not authenticated'},
        )

    user = await UserStore.mark_onboarding_completed(user_id)
    if not user:
        return JSONResponse(
            status_code=status.HTTP_404_NOT_FOUND,
            content={'error': 'User not found'},
        )

    logger.info(
        'User completed onboarding',
        extra={'user_id': user_id},
    )

    # Analytics: 'onboarding completed' event + org group_identify.
    # Best-effort: never let a tracking failure break the onboarding flow.
    selections = body.selections if body is not None else {}
    try:
        analytics = get_analytics_service()
        if analytics:
            ctx = await resolve_analytics_context(user_id)
            analytics.track_onboarding_completed(
                ctx=ctx,
                selections=selections,
            )
            if ctx.org_id:
                analytics.group_identify(
                    ctx=ctx,
                    group_type='org',
                    group_key=ctx.org_id,
                    properties={
                        'onboarding_completed_at': datetime.now(
                            timezone.utc
                        ).isoformat(),
                    },
                )
    except Exception:
        logger.exception('analytics:onboarding_completed:failed')

    return JSONResponse(
        status_code=status.HTTP_200_OK,
        content={'message': 'Onboarding completed'},
    )


@api_router.post('/logout')
async def logout(request: Request):
    # Always create the response object first to ensure we can return it even if errors occur
    response = JSONResponse(
        status_code=status.HTTP_200_OK,
        content={'message': 'User logged out'},
    )

    # Always delete the cookie (and any sibling chunks) regardless of what happens
    delete_chunked_cookie(
        response,
        'keycloak_auth',
        domain=get_cookie_domain(),
        samesite=get_cookie_samesite(),
    )

    # Try to properly logout from Keycloak, but don't fail if it doesn't work
    try:
        user_auth = cast(SaasUserAuth, await get_user_auth(request))
        if user_auth and user_auth.refresh_token:
            refresh_token = user_auth.refresh_token.get_secret_value()
            await token_manager.logout(refresh_token)
    except Exception as e:
        # Log any errors but don't fail the request
        logger.debug(f'Error during logout: {str(e)}')
        # We still want to clear the cookie and return success

    return response


@api_router.get('/refresh-tokens', response_model=TokenResponse)
async def refresh_tokens(
    request: Request,
    provider: ProviderType,
    sid: str,
    x_session_api_key: Annotated[str | None, Header(alias='X-Session-API-Key')],
) -> TokenResponse:
    """Return the latest token for a given provider."""
    user_id = get_user_id(sid)
    session_api_key = await get_session_api_key(sid)
    if session_api_key != x_session_api_key:
        raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail='Forbidden')

    logger.info(f'Refreshing token for conversation {sid}')
    provider_handler = ProviderHandler(
        create_provider_tokens_object([provider]), external_auth_id=user_id
    )
    service = provider_handler.get_service(provider)
    token = await service.get_latest_token()
    if not token:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"No token found for provider '{provider}'",
        )

    return TokenResponse(token=token.get_secret_value())
