Загрузка данных


"""
Авторизация pytest-прогона: Keycloak access token и x-user-id для HTTP/WS.

Кэш кредов на уровне dataset (group_state)
"""

from __future__ import annotations

import os
import time

import pytest
import requests

from clients.http_client import StandHttpClient
from clients.keycloak_clients import KeycloakAuthError, KeycloakClient
from clients.testops_client import logger
from clients.websocket_client import WebSocketClient
from constants.architecture_constants import EnvKeyConstants as EnvConst
from constants.architecture_constants import HTTPClientConstants as HttpConst
from constants.architecture_constants import WebSocketClientConstants as WSCliConst

# Один KeycloakClient на pytest-сессию; TTL токена проверяется внутри клиента.
_keycloak_client: KeycloakClient | None = None


def get_instance() -> str:
    """Имя стенда из переменной окружения STAND_NAME."""
    instance = os.environ.get(EnvConst.STAND_NAME)
    if not instance:
        pytest.exit(f"Переменная окружения {EnvConst.STAND_NAME} не задана в .env")
    return instance


def build_stand_host() -> str:
    """Хост api-gateway для HTTP и WSS запросов к стенду."""
    instance = get_instance()
    return (
        f"{WSCliConst.SERVICE_NAME}.{WSCliConst.COMPONENT}-{instance}.{WSCliConst.ROOT_DOMAIN}/"
        f"{WSCliConst.API_GATEWAY_PATH_SEGMENT}"
    )


def _get_keycloak_client() -> KeycloakClient:
    """
    Возвращает единственный KeycloakClient на время pytest-сессии.

    Вызовы get_access_token() внутри одного клиента ждут истечения токена KeycloakClient._is_token_expired.
    """
    global _keycloak_client
    if _keycloak_client is None:
        _keycloak_client = KeycloakClient(
            url=os.environ.get(EnvConst.KEYCLOAK_SZI_URL),
            client_id=os.environ.get(EnvConst.KEYCLOAK_CLIENT_ID),
            client_secret=os.environ.get(EnvConst.KEYCLOAK_SZI_CLIENT_SECRET),
            username=os.environ.get(EnvConst.KEYCLOAK_USERNAME),
            password=os.environ.get(EnvConst.KEYCLOAK_PASSWORD),
        )
    return _keycloak_client


def _invalidate_keycloak_token() -> None:
    """
    Сбрасывает закэшированный access token в KeycloakClient.

    Вызывается при force_refresh=True (смена dataset), чтобы запросить новый JWT,
    даже если предыдущий ещё в пределах TTL.
    """
    client = _get_keycloak_client()
    client._token = None
    client._token_data = None


def get_token(max_retries: int = 12, backoff: float = 5.0, force_refresh: bool = False) -> str:
    """
    Получает JWT access token из Keycloak с повторными попытками.

    max_retries: сколько всего попыток (включая первую)
    backoff: время в секундах между попытками
    force_refresh: принудительно запросить новый token в Keycloak
    """
    if force_refresh:
        _invalidate_keycloak_token()

    last_exc = None
    keycloak = _get_keycloak_client()
    for attempt in range(1, max_retries + 1):
        try:
            token = keycloak.get_access_token()
            if not token:
                raise KeycloakAuthError("Получен пустой access token")
            return token

        except KeycloakAuthError as e:
            last_exc = e
            logger.warning(f"[{attempt}/{max_retries}] KeycloakAuthError: {e}. Повтор через {backoff} сек.")
        except Exception as e:
            last_exc = e
            logger.warning(f"[{attempt}/{max_retries}] Неожиданная ошибка: {e}. Повтор через {backoff} сек.")

        if attempt < max_retries:
            time.sleep(backoff)

    # все попытки исчерпаны
    logger.error(f"[KEYCLOAK] [ERROR] Не удалось получить токен после {max_retries} попыток: {last_exc}")
    pytest.fail(f"[KEYCLOAK][ERROR] Не удалось получить токен после {max_retries} попыток: {last_exc}")


def clear_suite_auth(group_state: dict) -> None:
    """
    Сбрасывает кэш auth в group_state перед инициализацией нового dataset.
    Очищает stand_host, auth_token, x_user_id и метку auth_suite.
    """
    group_state["stand_host"] = None
    group_state["auth_token"] = None
    group_state["x_user_id"] = None
    group_state["auth_suite"] = None


def _fetch_x_user_id(http_client: StandHttpClient, max_retries: int = 5, backoff: float = 5.0) -> str:
    """
    Запрашивает x-user-id через POST /apigateway/Ping с повторными попытками.
    Значение нужно для параметра xUserId= в URL WebSocket-подключения.
    Вызывается после старта api-gateway.
    """
    http_client.suppress_recv_logging = True
    last_exc = None
    for attempt in range(1, max_retries + 1):
        try:
            response = http_client.post_request(HttpConst.PING_URL_PATH, {})
            x_user_id = response.headers.get(HttpConst.X_USER_ID_KEY)
            if not x_user_id:
                raise ValueError("/Ping не вернул x-user-id")
            logger.info("[AUTH] [OK] Получен x-user-id")
            return x_user_id

        except (requests.RequestException, ValueError) as e:
            last_exc = e
            logger.warning(f"[{attempt}/{max_retries}] /Ping: {e}. Повтор через {backoff} сек.")
        except Exception as e:
            last_exc = e
            logger.warning(f"[{attempt}/{max_retries}] Неожиданная ошибка /Ping: {e}. Повтор через {backoff} сек.")

        if attempt < max_retries:
            time.sleep(backoff)

    # все попытки исчерпаны
    logger.error(f"[AUTH] [ERROR] Не удалось получить x-user-id после {max_retries} попыток: {last_exc}")
    pytest.fail(f"[AUTH][ERROR] Не удалось получить x-user-id после {max_retries} попыток: {last_exc}")


def ensure_suite_auth(group_state: dict, suite_name: str) -> None:
    """
    Один раз на dataset: access token (Keycloak) + x-user-id (/Ping).
    Выполняется после старта api-gateway (Docker).
    """
    if (
        group_state.get("auth_suite") == suite_name
        and group_state.get("auth_token")
        and group_state.get("x_user_id")
        and group_state.get("stand_host")
    ):
        return

    stand_host = build_stand_host()
    auth_token = get_token(force_refresh=True)
    http_client = StandHttpClient(stand_host, auth_token)
    x_user_id = _fetch_x_user_id(http_client)

    group_state["stand_host"] = stand_host
    group_state["auth_token"] = auth_token
    group_state["x_user_id"] = x_user_id
    group_state["auth_suite"] = suite_name

    logger.info("[AUTH] Инициализация auth для dataset '%s'", suite_name)


def ensure_auth_for_fixture(group_state: dict) -> None:
    """
    Подстраховка для фикстур http_client / ws_client.

    Основная инициализация auth выполняется в pytest_runtest_setup при смене dataset.
    Если тест по какой-то причине стартует раньше (нет current_suite), здесь
    выполняется инициализация по auth_suite или fallback '__default__'.
    """
    suite_name = group_state.get("current_suite") or group_state.get("auth_suite") or "__default__"
    ensure_suite_auth(group_state, suite_name)


def _require_auth(group_state: dict, *, require_x_user_id: bool = False) -> None:
    if not group_state.get("auth_token") or not group_state.get("stand_host"):
        pytest.exit("[AUTH] auth не инициализирован - ожидается ensure_suite_auth()")
    if require_x_user_id and not group_state.get("x_user_id"):
        pytest.exit("[AUTH] x_user_id не инициализирован - ожидается ensure_suite_auth()")


def init_http_stand_client(group_state: dict) -> StandHttpClient:
    """Создает StandHttpClient. Креды из group_state (кэш на dataset)."""
    _require_auth(group_state)
    return StandHttpClient(group_state["stand_host"], group_state["auth_token"])


def init_ws_stand_client(group_state: dict) -> WebSocketClient:
    """Создает WebSocketClient. Креды из group_state (кэш на dataset)."""
    _require_auth(group_state, require_x_user_id=True)
    return WebSocketClient(
        group_state["stand_host"],
        group_state["auth_token"],
        group_state["x_user_id"],
    )