from __future__ import annotations

import json
import random
import os
import time
from dataclasses import dataclass
import re
import urllib.error
from typing import Any, Callable, Optional

import allure  # pyright: ignore[reportMissingImports]
from allure_commons.types import AttachmentType  # pyright: ignore[reportMissingImports]

from worklib import admin_data
from worklib.graphql_client import DEFAULT_COMPANY_ID, execute_graphql
from worklib.auth_as_employer import get_access_token


def _attach_json(name: str, payload: Any) -> None:
    allure.attach(
        json.dumps(payload, ensure_ascii=False, indent=2),
        name=name,
        attachment_type=AttachmentType.JSON,
    )


def _exec_or_fail(*, op_name: str, token: str, query: str, variables: dict[str, Any] | None = None, company_id: str) -> dict[str, Any]:
    try:
        return execute_graphql(
            query=query,
            variables=variables,
            company_id=company_id,
            access_token=token,
        )
    except PermissionError as e:
        allure.attach(str(e), name=f"Forbidden: {op_name}", attachment_type=AttachmentType.TEXT)
        raise AssertionError(f"Forbidden на операции: {op_name}") from e
    except RuntimeError as e:
        # На моках/прокси иногда приходит GraphQL error при невалидном input — хотим видеть оригинал.
        allure.attach(str(e), name=f"RuntimeError: {op_name}", attachment_type=AttachmentType.TEXT)
        raise


@dataclass
class PassRequestTestData:
    """
    Данные для теста passRequests:
    place -> entrance(place_type=entrance) -> service -> user in place -> createPass -> query passRequests
    """

    company_id: str = DEFAULT_COMPANY_ID
    parent_place_id: str = "6915dc03462d5aea0adc8cbd"

    access_token: Optional[str] = None
    place_id: Optional[str] = None
    entrance_place_id: Optional[str] = None
    entrance_id: Optional[str] = None
    expected_pass_request_place_id: Optional[str] = None
    service_id: Optional[str] = None
    account_id: Optional[str] = None
    pass_id: Optional[str] = None
    pass_request_id: Optional[str] = None

    # nested places for approval flow
    place1_id: Optional[str] = None
    place2_id: Optional[str] = None
    place3_id: Optional[str] = None

    # current (my) user / employee
    my_account_id: Optional[str] = None
    my_employee_id: Optional[str] = None

    # new employee with passRequests permissions
    new_username: Optional[str] = None
    new_password: Optional[str] = None
    new_account_id: Optional[str] = None
    new_employee_id: Optional[str] = None
    new_access_token: Optional[str] = None
    owner_account_id: Optional[str] = None
    worker_account_id: Optional[str] = None
    worker_employee_id: Optional[str] = None
    su_place1_id: Optional[str] = None
    su_place2_id: Optional[str] = None
    su_place3_id: Optional[str] = None
    su_place4_id: Optional[str] = None

    _cleanup_fns: Optional[list[Callable[[], None]]] = None
    _mutation_fields_cache: Optional[list[str]] = None
    _place_type_enum_cache: Optional[list[str]] = None
    _place_parent_by_id: Optional[dict[str, str]] = None
    _query_fields_cache: Optional[list[str]] = None

    @classmethod
    def from_behave_context(cls, context: Any, *, company_id: str = DEFAULT_COMPANY_ID) -> "PassRequestTestData":
        td: PassRequestTestData | None = getattr(context, "pass_request_test_data", None)
        if isinstance(td, cls):
            if not td.access_token and getattr(context, "access_token", None):
                td.access_token = context.access_token
            if not td._cleanup_fns:
                td._cleanup_fns = getattr(context, "_cleanup_fns", None)
            return td

        td = cls(company_id)
        td.access_token = getattr(context, "access_token", None) or None
        td._cleanup_fns = getattr(context, "_cleanup_fns", None)
        setattr(context, "pass_request_test_data", td)
        return td

    def _register_cleanup(self, fn: Callable[[], None]) -> None:
        if self._cleanup_fns is not None:
            self._cleanup_fns.append(fn)

    def _remember_place_parent(self, *, place_id: str, parent_id: str) -> None:
        if self._place_parent_by_id is None:
            self._place_parent_by_id = {}
        self._place_parent_by_id[str(place_id)] = str(parent_id)

    def _get_place_parent(self, place_id: str) -> Optional[str]:
        if not self._place_parent_by_id:
            return None
        return self._place_parent_by_id.get(str(place_id))

    def ensure_token(self) -> str:
        if self.access_token:
            return self.access_token
        token = admin_data.get_or_create_user("tester").access_token
        assert token, "Нет access_token."
        self.access_token = token
        return token

    def _get_mutation_field_names(self) -> list[str]:
        """
        Пытаемся получить список mutation полей через GraphQL introspection.
        Это позволяет не гадать с названиями операций (API на стендах бывает разный).
        """
        if self._mutation_fields_cache is not None:
            return self._mutation_fields_cache
        token = self.ensure_token()
        query = """
query __introspectionMutationFields {
  __schema {
    mutationType {
      fields { name }
    }
  }
}
""".strip()
        try:
            resp = execute_graphql(query=query, variables=None, company_id=self.company_id, access_token=token)
            fields = resp.get("data", {}).get("__schema", {}).get("mutationType", {}).get("fields", [])
            names: list[str] = []
            if isinstance(fields, list):
                for f in fields:
                    if isinstance(f, dict):
                        n = f.get("name")
                        if isinstance(n, str) and n:
                            names.append(n)
            self._mutation_fields_cache = names
            return names
        except Exception as e:  # noqa: BLE001
            allure.attach(str(e), name="Introspection failed (mutation fields)", attachment_type=AttachmentType.TEXT)
            self._mutation_fields_cache = []
            return []

    # NOTE: query-field introspection left for future debugging.

    def _get_place_type_enum_values(self) -> list[str]:
        """
        Возвращает возможные значения enum PlaceType через introspection.
        Нужен для адаптивного создания вложенных мест: разные стенды по-разному валидируют parent/child.
        """
        if self._place_type_enum_cache is not None:
            return self._place_type_enum_cache
        token = self.ensure_token()
        query = """
query __placeTypeEnum {
  __type(name: "PlaceType") {
    kind
    enumValues { name }
  }
}
""".strip()
        try:
            resp = execute_graphql(query=query, variables=None, company_id=self.company_id, access_token=token)
            enum_values = resp.get("data", {}).get("__type", {}).get("enumValues", [])
            values: list[str] = []
            if isinstance(enum_values, list):
                for ev in enum_values:
                    if isinstance(ev, dict):
                        n = ev.get("name")
                        if isinstance(n, str) and n:
                            values.append(n)
            self._place_type_enum_cache = values
            return values
        except Exception as e:  # noqa: BLE001
            allure.attach(str(e), name="Introspection failed (PlaceType enum)", attachment_type=AttachmentType.TEXT)
            self._place_type_enum_cache = []
            return []

    def _retry_graphql(self, *, op_name: str, token: str, query: str, variables: dict[str, Any] | None, company_id: str, retries: int = 3) -> dict[str, Any]:
        """
        Retry для нестабильных стендов (часто встречается GraphQL 500 / ConnectionReset).
        Ретраим только на 500/ISE/сетевые ошибки, чтобы не скрывать реальные проблемы.
        """
        last_exc: Exception | None = None
        for attempt in range(1, retries + 1):
            try:
                return _exec_or_fail(op_name=op_name, token=token, query=query, variables=variables, company_id=company_id)
            except (ConnectionResetError, urllib.error.URLError) as e:
                last_exc = e
            except RuntimeError as e:
                msg = str(e)
                if "Internal Server Error" in msg or "GraphQL HTTP 500" in msg or "status': 500" in msg:
                    last_exc = e
                else:
                    raise
            time.sleep(0.4 * attempt)
        raise AssertionError(f"{op_name} не выполнился после {retries} попыток. Последняя ошибка: {last_exc}")

    def _pick_mutation_name(self, patterns: list[str], *, fallback: list[str]) -> list[str]:
        """
        Возвращает список кандидатов (1-й — самый вероятный).
        Сначала пытаемся выбрать из introspection по regex паттернам, затем добавляем fallback.
        """
        names = self._get_mutation_field_names()
        picked: list[str] = []
        for pat in patterns:
            rx = re.compile(pat, re.IGNORECASE)
            for n in names:
                if rx.search(n) and n not in picked:
                    picked.append(n)
        for n in fallback:
            if n not in picked:
                picked.append(n)
        return picked

    def ensure_place(self) -> str:
        if self.place_id:
            return self.place_id
        if os.getenv("USE_WIREMOCK") in {"1", "true", "True"}:
            self.place_id = "mock_place"
            self._remember_place_parent(place_id=self.place_id, parent_id=self.parent_place_id)
            return self.place_id
        token = self.ensure_token()
        mutation = """
mutation ($place_type: PlaceType!, $names: [String!]!, $parent_id: String, $address_id: String) {
  createPlaceMultiple(dto: {place_type: $place_type, names: $names, parent_id: $parent_id, address_id: $address_id}) {
    id
    __typename
  }
}
""".strip()
        suffix = str(int(time.time()))
        # На текущем стенде PlaceType.building/PlaceType.street иногда ломают API (400 Bad Request),
        # поэтому создаём основной place типа flat (как в Ticket/KVS тестах).
        variables = {"names": [f"pass-place-{suffix}"], "parent_id": self.parent_place_id, "place_type": "flat"}
        with allure.step("GraphQL: createPlaceMultiple (main place)"):
            resp = _exec_or_fail(op_name="createPlaceMultiple(main)", token=token, query=mutation, variables=variables, company_id=self.company_id)
            _attach_json("createPlaceMultiple(main) response", resp)
            created = resp.get("data", {}).get("createPlaceMultiple")
            created0 = created[0] if isinstance(created, list) and created else created
            assert isinstance(created0, dict) and created0.get("id"), f"createPlaceMultiple не вернул id: {resp!r}"
            place_id = created0["id"]
            self.place_id = place_id
            self._remember_place_parent(place_id=place_id, parent_id=self.parent_place_id)

            def _cleanup_delete_place() -> None:
                delete_mutation = """mutation($id: String!) { deletePlace(id: $id) }""".strip()
                _exec_or_fail(op_name="deletePlace", token=token, query=delete_mutation, variables={"id": place_id}, company_id=self.company_id)

            self._register_cleanup(_cleanup_delete_place)
            return place_id

    def _create_place(self, *, parent_id: str, title_prefix: str, place_type: str) -> str:
        token = self.ensure_token()
        mutation = """
mutation ($place_type: PlaceType!, $names: [String!]!, $parent_id: String, $address_id: String) {
  createPlaceMultiple(dto: {place_type: $place_type, names: $names, parent_id: $parent_id, address_id: $address_id}) {
    id
    __typename
  }
}
""".strip()
        suffix = str(int(time.time()))
        variables = {"names": [f"{title_prefix}-{suffix}"], "parent_id": parent_id, "place_type": place_type}
        with allure.step(f"GraphQL: createPlaceMultiple ({title_prefix}, place_type={place_type})"):
            resp = self._retry_graphql(
                op_name="createPlaceMultiple",
                token=token,
                query=mutation,
                variables=variables,
                company_id=self.company_id,
                retries=2,
            )
            _attach_json("createPlaceMultiple response", resp)
            created = resp.get("data", {}).get("createPlaceMultiple")
            created0 = created[0] if isinstance(created, list) and created else created
            assert isinstance(created0, dict) and created0.get("id"), f"createPlaceMultiple не вернул id: {resp!r}"
            place_id = created0["id"]
            self._remember_place_parent(place_id=place_id, parent_id=parent_id)

        def _cleanup_delete_place() -> None:
            delete_mutation = """mutation($id: String!) { deletePlace(id: $id) }""".strip()
            _exec_or_fail(op_name="deletePlace", token=token, query=delete_mutation, variables={"id": place_id}, company_id=self.company_id)

        self._register_cleanup(_cleanup_delete_place)
        return place_id

    def ensure_three_nested_places(self) -> tuple[str, str, str]:
        """
        Создаёт 3 места.
        На части стендов вложенность (child->parent) ломает createPlaceMultiple (400),
        поэтому используем максимально совместимый вариант: 3 места-сиблинга под одним parent_place_id.
        """
        if self.place1_id and self.place2_id and self.place3_id:
            return (self.place1_id, self.place2_id, self.place3_id)
        if os.getenv("USE_WIREMOCK") in {"1", "true", "True"}:
            self.place1_id, self.place2_id, self.place3_id = "mock_place_1", "mock_place_2", "mock_place_3"
            self._remember_place_parent(place_id=self.place1_id, parent_id=self.parent_place_id)
            self._remember_place_parent(place_id=self.place2_id, parent_id=self.parent_place_id)
            self._remember_place_parent(place_id=self.place3_id, parent_id=self.parent_place_id)
            return (self.place1_id, self.place2_id, self.place3_id)
        # На некоторых стендах createPlaceMultiple возвращает 400 на части enum PlaceType.
        # Самый стабильный тип, который используется и в других тестах — "flat".
        p1_type = "flat"
        p2_type = "flat"
        p3_type = "flat"

        p1 = self._create_place(parent_id=self.parent_place_id, title_prefix="passreq-place-1", place_type=p1_type)
        p2 = self._create_place(parent_id=self.parent_place_id, title_prefix="passreq-place-2", place_type=p2_type)
        p3 = self._create_place(parent_id=self.parent_place_id, title_prefix="passreq-place-3", place_type=p3_type)
        self.place1_id, self.place2_id, self.place3_id = p1, p2, p3
        return (p1, p2, p3)

    def _ensure_place_has_user(self, *, place_id: str) -> None:
        """
        По требованиям сценариев: в каждом месте должен быть хотя бы один пользователь.
        Самый совместимый путь — создать отдельного demo-user и добавить его в место.
        """
        if os.getenv("USE_WIREMOCK") in {"1", "true", "True"}:
            return
        uid = self._create_user(first_name="place", last_name="member")
        self._add_user_to_place(account_id=uid, place_id=place_id)

    def _connect_entrance_to_place(self, *, entrance_id: str, place_id: str) -> bool:
        """
        По требованиям сценариев: place должен быть "соединён" с entrance.
        На разных стендах названия/сигнатуры мутаций отличаются — пробуем через introspection + fallback.
        """
        if os.getenv("USE_WIREMOCK") in {"1", "true", "True"}:
            return True
        token = self.ensure_token()
        candidates = self._pick_mutation_name(
            patterns=[
                r"(add|attach|set).*(entrance).*(place)",
                r"(add|attach|set).*(place).*(entrance)",
                r"(connect|link).*(entrance).*(place)",
            ],
            fallback=[
                "addEntranceToPlace",
                "attachEntranceToPlace",
                "setPlaceEntrances",
                "addPlaceEntrance",
                "connectEntranceToPlace",
            ],
        )
        last_error: Exception | None = None
        attempts: list[str] = []
        for name in candidates:
            variants: list[tuple[str, str, dict[str, Any]]] = [
                (
                    "dto-entrance_id",
                    f"""
mutation($place_id: String!, $entrance_id: String!) {{
  {name}(dto: {{ place_id: $place_id, entrance_id: $entrance_id }})
}}
""".strip(),
                    {"place_id": place_id, "entrance_id": entrance_id},
                ),
                (
                    "dto-entrance_ids",
                    f"""
mutation($place_id: String!, $entrance_ids: [String!]!) {{
  {name}(dto: {{ place_id: $place_id, entrance_ids: $entrance_ids }})
}}
""".strip(),
                    {"place_id": place_id, "entrance_ids": [entrance_id]},
                ),
                (
                    "args-entrance_id",
                    f"""
mutation($place_id: String!, $entrance_id: String!) {{
  {name}(place_id: $place_id, entrance_id: $entrance_id)
}}
""".strip(),
                    {"place_id": place_id, "entrance_id": entrance_id},
                ),
                (
                    "args-entrance_ids",
                    f"""
mutation($place_id: String!, $entrance_ids: [String!]!) {{
  {name}(place_id: $place_id, entrance_ids: $entrance_ids)
}}
""".strip(),
                    {"place_id": place_id, "entrance_ids": [entrance_id]},
                ),
            ]
            for vlabel, m, vars_ in variants:
                attempts.append(f"{name}/{vlabel}")
                with allure.step(f"GraphQL: connect entrance to place ({name}/{vlabel})"):
                    try:
                        resp = self._retry_graphql(
                            op_name=f"{name}({vlabel})",
                            token=token,
                            query=m,
                            variables=vars_,
                            company_id=self.company_id,
                            retries=2,
                        )
                    except Exception as e:  # noqa: BLE001
                        last_error = e
                        continue
                    _attach_json("connect entrance response", resp)
                    return True

        # На части стендов отдельной мутации "связать entrance с place" нет вовсе.
        # В таких случаях связность обеспечивается самим createPass(entrance_ids=...),
        # поэтому делаем best-effort и не валим тест.
        allure.attach(
            f"Skip entrance<->place link. entrance_id={entrance_id!r}, place_id={place_id!r}. "
            f"Attempts: {attempts}. Last error: {last_error}",
            name="Entrance link not supported on this stand",
            attachment_type=AttachmentType.TEXT,
        )
        return False

    # NOTE: ensure_entrance_connected_to_places переопределён ниже (createEntrance-based).

    def _get_my_account_id(self) -> str:
        if self.my_account_id:
            return self.my_account_id
        token = self.ensure_token()
        query_variants = [
            ("me", "query { me { id username } }", ("data", "me", "id")),
            ("account", "query { account { id username } }", ("data", "account", "id")),
            ("viewer", "query { viewer { id username } }", ("data", "viewer", "id")),
            ("profile.account", "query { profile { account { id username } } }", ("data", "profile", "account", "id")),
        ]
        last_error: Exception | None = None
        for name, q, path in query_variants:
            with allure.step(f"GraphQL: get my account id ({name})"):
                try:
                    resp = execute_graphql(query=q, variables=None, company_id=self.company_id, access_token=token)
                except Exception as e:  # noqa: BLE001
                    last_error = e
                    continue
                _attach_json(f"whoami({name}) response", resp)
                cur: Any = resp
                ok = True
                for k in path:
                    if isinstance(cur, dict) and k in cur:
                        cur = cur[k]
                    else:
                        ok = False
                        break
                if ok and isinstance(cur, str) and cur:
                    self.my_account_id = cur
                    return cur
        raise AssertionError(f"Не удалось определить account_id текущего пользователя (tester). Последняя ошибка: {last_error}")

    def ensure_my_employee(self) -> str:
        """
        Ты уже являешься работником: поэтому НЕ создаём employee.
        Пытаемся найти employee_id через запрос employee(filters) и match по user.id.
        """
        if self.my_employee_id:
            return self.my_employee_id
        token = self.ensure_token()
        my_user_id = self._get_my_account_id()
        query = """
query employeeByCompany($company_id: String!) {
  employee(filters: { company_id: $company_id }) {
    results {
      id
      user { id username }
      company { id }
    }
  }
}
""".strip()
        with allure.step("GraphQL: employee(filters: company_id) to find my employee_id"):
            resp = execute_graphql(query=query, variables={"company_id": self.company_id}, company_id=self.company_id, access_token=token)
            _attach_json("employee(by company) response", resp)
            results = resp.get("data", {}).get("employee", {}).get("results", [])
            assert isinstance(results, list), f"employee.results не list: {results!r}"
            for item in results:
                if not isinstance(item, dict):
                    continue
                user = item.get("user")
                if isinstance(user, dict) and user.get("id") == my_user_id:
                    emp_id = item.get("id")
                    if isinstance(emp_id, str) and emp_id:
                        self.my_employee_id = emp_id
                        return emp_id
        raise AssertionError(
            "Не смогли найти employee_id текущего пользователя через employee(filters: company_id). "
            "Если на стенде нельзя получать список работников по company, нужно будет заменить на другой query."
        )

    def _add_trustee_to_place(self, *, account_id: str, place_id: str) -> None:
        """
        Добавляет пользователя в место с ролью trustee.
        Название мутации может отличаться — пробуем варианты.
        """
        token = self.ensure_token()
        candidates = self._pick_mutation_name(
            patterns=[r"add.*trustee", r"set.*trustee", r"add.*role.*place", r"add.*member.*place"],
            fallback=[
                "addTrusteeToPlace",
                "addUserToPlaceWithRole",
                "addMemberToPlace",
                "addUserToPlace",
            ],
        )
        last_error: Exception | None = None
        attempts: list[str] = []
        for name in candidates:
            variants = [
                (
                    "dto",
                    f"""
mutation($account_id: String!, $place_id: String!) {{
  {name}(dto: {{ account_id: $account_id, place_id: $place_id, role: trustee }})
}}
""".strip(),
                    {"account_id": account_id, "place_id": place_id},
                ),
                (
                    "args-role",
                    f"""
mutation($account_id: String!, $place_id: String!) {{
  {name}(account_id: $account_id, place_id: $place_id, role: trustee)
}}
""".strip(),
                    {"account_id": account_id, "place_id": place_id},
                ),
                (
                    "args",
                    f"""
mutation($account_id: String!, $place_id: String!) {{
  {name}(account_id: $account_id, place_id: $place_id)
}}
""".strip(),
                    {"account_id": account_id, "place_id": place_id},
                ),
            ]
            for vlabel, m, vars_ in variants:
                attempts.append(f"{name}/{vlabel}")
                with allure.step(f"GraphQL: add trustee to place ({name}/{vlabel})"):
                    try:
                        resp = _exec_or_fail(op_name=f"{name}", token=token, query=m, variables=vars_, company_id=self.company_id)
                    except Exception as e:  # noqa: BLE001
                        last_error = e
                        continue
                    _attach_json("add trustee response", resp)
                    return
        raise AssertionError(f"Не удалось добавить trustee в place. Попробовали: {attempts}. Последняя ошибка: {last_error}")

    def _attach_employee_to_place(self, *, employee_id: str, place_id: str) -> None:
        token = self.ensure_token()
        candidates = self._pick_mutation_name(
            patterns=[r"add.*employee.*to.*place", r"attach.*employee.*place"],
            fallback=["addEmployeeToPlace", "attachEmployeeToPlace", "addEmployeesToPlace", "addEmployeesToPlaces"],
        )
        attempts: list[str] = []
        last_error: Exception | None = None
        for name in candidates:
            # Пробуем наиболее типовые сигнатуры.
            mutation_variants = [
                (
                    "dto",
                    f"""
mutation($place_id: String!, $employee_id: String!) {{
  {name}(dto: {{ place_id: $place_id, employee_id: $employee_id }})
}}
""".strip(),
                    {"place_id": place_id, "employee_id": employee_id},
                ),
                (
                    "args",
                    f"""
mutation($place_id: String!, $employee_id: String!) {{
  {name}(place_id: $place_id, employee_id: $employee_id)
}}
""".strip(),
                    {"place_id": place_id, "employee_id": employee_id},
                ),
                (
                    "employee_ids",
                    f"""
mutation($place_id: String!, $employee_ids: [String!]!) {{
  {name}(dto: {{ place_id: $place_id, employee_ids: $employee_ids }})
}}
""".strip(),
                    {"place_id": place_id, "employee_ids": [employee_id]},
                ),
            ]
            for vlabel, m, vars_ in mutation_variants:
                attempts.append(f"{name}/{vlabel}")
                with allure.step(f"GraphQL: attach employee to place ({name}/{vlabel})"):
                    try:
                        resp = _exec_or_fail(op_name=f"{name}", token=token, query=m, variables=vars_, company_id=self.company_id)
                    except Exception as e:  # noqa: BLE001
                        last_error = e
                        continue
                    _attach_json("attachEmployeeToPlace response", resp)
                    return
        raise AssertionError(f"Не удалось прикрепить employee к place. Попробовали: {attempts}. Последняя ошибка: {last_error}")

    def create_new_employee_with_pass_requests_permissions(self) -> tuple[str, str]:
        """
        Создаёт нового пользователя (с паролем, чтобы можно было получить отдельный токен),
        создаёт employee с атрибутами see/manage pass requests.
        Возвращает (access_token, employee_id).
        """
        if self.new_access_token and self.new_employee_id:
            return (self.new_access_token, self.new_employee_id)
        token = self.ensure_token()

        username = f"+7999{random.randint(1000000, 9999999)}"
        password = "stepan-passreq"
        create_user_mut = """
mutation createUser($input: CreateAccountDTO!) {
  createUser(dto: $input)
}
""".strip()
        create_user_vars = {
            "input": {
                "username": username,
                "first_name": "passreq",
                "last_name": "approver",
                "is_demo": True,
                "password": password,
            }
        }
        with allure.step("GraphQL: createUser (new approver)"):
            resp = _exec_or_fail(op_name="createUser(new approver)", token=token, query=create_user_mut, variables=create_user_vars, company_id=self.company_id)
            _attach_json("createUser(new approver) response", resp)
            created = resp.get("data", {}).get("createUser")
            account_id = created.get("id") if isinstance(created, dict) else created
            assert isinstance(account_id, str) and account_id, f"createUser не вернул id: {resp!r}"
            self.new_account_id = account_id
            self.new_username = username
            self.new_password = password

            def _cleanup_delete_user() -> None:
                delete_mutation = """mutation deleteUser($account_id: String!) { deleteUser(account_id: $account_id) }""".strip()
                _exec_or_fail(op_name="deleteUser(new approver)", token=token, query=delete_mutation, variables={"account_id": account_id}, company_id=self.company_id)

            self._register_cleanup(_cleanup_delete_user)

        with allure.step("Auth: get access_token for new approver"):
            new_token = get_access_token(username=username, password=password, grant_type="password")
            assert isinstance(new_token, str) and new_token, "Не получили access_token для нового пользователя."
            self.new_access_token = new_token

        # employee with required attributes
        attrs = ["seePassRequests", "managePassRequests"]
        add_emp_mut = """
mutation($user_id: String!, $attributes: [EmployeeAttribute!]!) {
  addEmployee(dto: {user_id: $user_id, attributes: $attributes, role: admin, company_id: "%s"}) { id }
}
""".strip() % self.company_id
        with allure.step("GraphQL: addEmployee (new approver with passRequests attrs)"):
            resp2 = _exec_or_fail(
                op_name="addEmployee(new approver)",
                token=token,
                query=add_emp_mut,
                variables={"user_id": account_id, "attributes": attrs},
                company_id=self.company_id,
            )
            _attach_json("addEmployee(new approver) response", resp2)
            payload = resp2.get("data", {}).get("addEmployee")
            emp_id = payload.get("id") if isinstance(payload, dict) else None
            assert isinstance(emp_id, str) and emp_id, f"addEmployee(new approver) не вернул id: {resp2!r}"
            self.new_employee_id = emp_id
            return (new_token, emp_id)

    def prepare_pass_request_approval_flow(self) -> None:
        """
        Полная подготовка под сценарий:
        - 3 nested places
        - меня (current token) прикрепляем employee к place2
        - нового employee с атрибутами see/manage прикрепляем к place1
        """
        if os.getenv("USE_WIREMOCK") in {"1", "true", "True"}:
            # В wiremock-режиме не создаём employee/places через реальные API.
            p1, p2, p3 = self.ensure_three_nested_places()
            self.place1_id, self.place2_id, self.place3_id = p1, p2, p3
            self.new_access_token = "token_new_employee"
            self.my_account_id = "tester_account"
            self.my_employee_id = "tester_employee"
            self.new_employee_id = "new_employee"
            self.new_account_id = "new_account"
            self.place_id = p3
            self.expected_pass_request_place_id = self._get_place_parent(p3)
            return

        p1, p2, p3 = self.ensure_three_nested_places()
        self._ensure_place_has_user(place_id=p1)
        self._ensure_place_has_user(place_id=p2)
        self._ensure_place_has_user(place_id=p3)
        # Entrance создаётся отдельно через createEntrance и требует devices — делаем опциональным.
        # На разных стендах текущий пользователь может не иметь whoami-query (me/account/viewer),
        # поэтому не завязываемся на добавление "tester" в place2.
        new_token, new_emp = self.create_new_employee_with_pass_requests_permissions()
        _ = new_token
        # На части стендов нет мутации attach/addEmployeeToPlace — используем только атрибуты employee.
        # place3 will be used for pass creation
        self.place_id = p3
        self.expected_pass_request_place_id = self._get_place_parent(p3)

    def ensure_entrance_place(self) -> str:
        # Backward-compatible: раньше создавали entrance как place. Теперь создаём entrance entity.
        if self.entrance_id:
            return self.entrance_id
        if os.getenv("USE_WIREMOCK") in {"1", "true", "True"}:
            self.entrance_id = "mock_entrance_id"
            self.entrance_place_id = self.entrance_id
            return self.entrance_id
        return self.create_entrance(place_ids=[self.place_id or self.ensure_place()])

    def _get_device_ids_for_entrance(self) -> list[str]:
        raw = (os.getenv("ENTRANCE_DEVICE_IDS") or os.getenv("ENTRANCE_DEVICE_ID") or "").strip()
        if not raw:
            raise AssertionError(
                "Для createEntrance нужен хотя бы один device id. "
                "Укажи ENTRANCE_DEVICE_IDS (через запятую) или ENTRANCE_DEVICE_ID в окружении запуска тестов."
            )
        ids = [x.strip() for x in raw.split(",") if x.strip()]
        return ids

    def create_entrance(self, *, place_ids: list[str]) -> str:
        if self.entrance_id:
            return self.entrance_id
        if os.getenv("USE_WIREMOCK") in {"1", "true", "True"}:
            self.entrance_id = "mock_entrance_id"
            self.entrance_place_id = self.entrance_id
            return self.entrance_id
        token = self.ensure_token()
        mutation = """
mutation ($input: RegisterEntranceDTO!) {
  createEntrance(dto: $input) {
    id
    title
    places { id }
  }
}
""".strip()
        suffix = str(int(time.time()))
        variables = {
            "input": {
                "place_ids": place_ids,
                "access_tags": [],
                "devices": self._get_device_ids_for_entrance(),
                "state": "opened",
                "title": f"Test entrance {suffix}",
                "note": "Entrance created for automatic tests",
                "access_methods": [{"type": "face", "active": True}],
                "default_method": "face",
            }
        }
        with allure.step("GraphQL: createEntrance(RegisterEntranceDTO)"):
            resp = _exec_or_fail(op_name="createEntrance", token=token, query=mutation, variables=variables, company_id=self.company_id)
            _attach_json("createEntrance response", resp)
            payload = resp.get("data", {}).get("createEntrance")
            eid = payload.get("id") if isinstance(payload, dict) else None
            if isinstance(eid, str) and eid:
                self.entrance_id = eid
                self.entrance_place_id = eid
                return eid
            raise AssertionError(f"createEntrance не вернул id: {resp!r}")

    def ensure_entrance_connected_to_places(self, *, place_ids: list[str]) -> str:
        # Совместимость со старыми шагами: связь задаётся через createEntrance(place_ids=...).
        if not place_ids:
            place_ids = [self.place_id or self.ensure_place()]
        return self.create_entrance(place_ids=place_ids)

    def ensure_service(self) -> str:
        if self.service_id:
            return self.service_id
        if os.getenv("USE_WIREMOCK") in {"1", "true", "True"}:
            self.service_id = "mock_service"
            return self.service_id
        token = self.ensure_token()
        place_id = self.place_id or self.ensure_place()
        mutation = """
mutation createservice($title: String!, $type: String!) {
  createService(dto: { title: $title, type: $type }) {
    id
    title
    type
  }
}
""".strip()
        suffix = str(int(time.time()))
        variables = {"title": f"pass-service-{suffix}", "type": "access"}
        with allure.step("GraphQL: createService"):
            resp = _exec_or_fail(op_name="createService", token=token, query=mutation, variables=variables, company_id=self.company_id)
            _attach_json("createService response", resp)
            service = resp.get("data", {}).get("createService")
            assert isinstance(service, dict) and service.get("id"), f"createService не вернул id: {resp!r}"
            service_id = service["id"]
            self.service_id = service_id

        # Привязываем service к нашему месту (иначе createPass может вернуть 404 Not Found).
        bind_mutation = """
mutation ($dto: AddPlaceToServiceInput!) {
  addPlaceToService(dto: $dto) { id }
}
""".strip()
        bind_vars = {"dto": {"service_id": service_id, "place_id": place_id}}
        with allure.step("GraphQL: addPlaceToService"):
            bind_resp = _exec_or_fail(op_name="addPlaceToService", token=token, query=bind_mutation, variables=bind_vars, company_id=self.company_id)
            _attach_json("addPlaceToService response", bind_resp)

        def _cleanup_unbind_and_delete_service() -> None:
            unbind_mutation = """
mutation ($dto: AddPlaceToServiceInput!) {
  removePlaceFromService(dto: $dto) { id }
}
""".strip()
            _exec_or_fail(op_name="removePlaceFromService", token=token, query=unbind_mutation, variables=bind_vars, company_id=self.company_id)
            delete_mutation = """mutation { deleteService(id: "%s") }""".strip() % service_id
            _exec_or_fail(op_name="deleteService", token=token, query=delete_mutation, variables=None, company_id=self.company_id)

        self._register_cleanup(_cleanup_unbind_and_delete_service)
        return service_id

    def ensure_user_attached_to_place(self) -> str:
        if self.account_id:
            return self.account_id
        if os.getenv("USE_WIREMOCK") in {"1", "true", "True"}:
            self.account_id = "mock_user"
            return self.account_id
        token = self.ensure_token()
        place_id = self.place_id or self.ensure_place()

        username = f"+7999{random.randint(1000000, 9999999)}"
        create_user_mut = """
mutation createUser($input: CreateAccountDTO!) {
  createUser(dto: $input)
}
""".strip()
        create_user_vars = {
            "input": {
                "username": username,
                "first_name": "pass",
                "last_name": "request",
                "is_demo": True,
                "password": "",
            }
        }
        with allure.step("GraphQL: createUser (for pass target)"):
            resp = _exec_or_fail(op_name="createUser", token=token, query=create_user_mut, variables=create_user_vars, company_id=self.company_id)
            _attach_json("createUser response", resp)
            created = resp.get("data", {}).get("createUser")
            account_id = created.get("id") if isinstance(created, dict) else created
            assert isinstance(account_id, str) and account_id, f"createUser не вернул id: {resp!r}"
            self.account_id = account_id

            def _cleanup_delete_user() -> None:
                delete_mutation = """mutation deleteUser($account_id: String!) { deleteUser(account_id: $account_id) }""".strip()
                _exec_or_fail(op_name="deleteUser", token=token, query=delete_mutation, variables={"account_id": account_id}, company_id=self.company_id)

            self._register_cleanup(_cleanup_delete_user)

        add_user_to_place_mut = """
mutation AddUserToPlace($input: AddUserToPlaceDTO!) {
  addUserToPlace(dto: $input)
}
""".strip()
        with allure.step("GraphQL: addUserToPlace (attach user to pass place)"):
            # В рамках pass_request сценариев user должен быть прикреплён ИМЕННО к месту, где создаём pass,
            # иначе createPass может вернуть 404 Not Found.
            resp2 = self._retry_graphql(
                op_name="addUserToPlace",
                token=token,
                query=add_user_to_place_mut,
                variables={"input": {"place_id": place_id, "account_id": self.account_id}},
                company_id=self.company_id,
                retries=5,
            )
            _attach_json("addUserToPlace response", resp2)
            payload = resp2.get("data", {}).get("addUserToPlace")
            assert payload is not None, f"addUserToPlace вернул None/пусто: {resp2!r}"

        return self.account_id

    def _create_user(self, *, first_name: str, last_name: str, password: str = "") -> str:
        token = self.ensure_token()
        username = f"+7999{random.randint(1000000, 9999999)}"
        create_user_mut = """
mutation createUser($input: CreateAccountDTO!) {
  createUser(dto: $input)
}
""".strip()
        create_user_vars = {
            "input": {
                "username": username,
                "first_name": first_name,
                "last_name": last_name,
                "is_demo": True,
                "password": password,
            }
        }
        with allure.step(f"GraphQL: createUser ({first_name} {last_name})"):
            resp = _exec_or_fail(op_name="createUser", token=token, query=create_user_mut, variables=create_user_vars, company_id=self.company_id)
            _attach_json("createUser(generic) response", resp)
            created = resp.get("data", {}).get("createUser")
            account_id = created.get("id") if isinstance(created, dict) else created
            assert isinstance(account_id, str) and account_id, f"createUser не вернул id: {resp!r}"

            def _cleanup_delete_user() -> None:
                delete_mutation = """mutation deleteUser($account_id: String!) { deleteUser(account_id: $account_id) }""".strip()
                _exec_or_fail(op_name="deleteUser", token=token, query=delete_mutation, variables={"account_id": account_id}, company_id=self.company_id)

            self._register_cleanup(_cleanup_delete_user)
            return account_id

    def _add_user_to_place(self, *, account_id: str, place_id: str, privilege: str | None = None) -> dict[str, Any]:
        token = self.ensure_token()
        target_place_ids = [place_id]
        if place_id != self.parent_place_id:
            target_place_ids.append(self.parent_place_id)
        candidates: list[tuple[str, dict[str, Any]]] = [
            ("dto-input", {"input": {"place_id": place_id, "account_id": account_id}}),
            ("dto-direct", {"dto": {"place_id": place_id, "account_id": account_id}}),
        ]
        if privilege:
            candidates = [
                ("dto-input-privilege", {"input": {"place_id": place_id, "account_id": account_id, "privilege": privilege}}),
                ("dto-input-privileges", {"input": {"place_id": place_id, "account_id": account_id, "privileges": [privilege]}}),
                ("dto-direct-privilege", {"dto": {"place_id": place_id, "account_id": account_id, "privilege": privilege}}),
                ("dto-direct-privileges", {"dto": {"place_id": place_id, "account_id": account_id, "privileges": [privilege]}}),
            ] + candidates

        mutations = [
            ("AddUserToPlaceDTO", "mutation AddUserToPlace($input: AddUserToPlaceDTO!) { addUserToPlace(dto: $input) }"),
            ("arg-dto", "mutation AddUserToPlace($account_id: String!, $place_id: String!) { addUserToPlace(dto: {account_id: $account_id, place_id: $place_id}) }"),
        ]
        if privilege:
            mutations = [
                (
                    "arg-dto-privilege",
                    "mutation AddUserToPlace($account_id: String!, $place_id: String!) { addUserToPlace(dto: {account_id: $account_id, place_id: $place_id, privilege: trusted}) }",
                ),
                (
                    "arg-dto-privileges",
                    "mutation AddUserToPlace($account_id: String!, $place_id: String!) { addUserToPlace(dto: {account_id: $account_id, place_id: $place_id, privileges: [trusted]}) }",
                ),
            ] + mutations
        last_error: Exception | None = None
        for current_place_id in target_place_ids:
            for mut_name, mutation in mutations:
                for label, variables in candidates:
                    run_vars = variables
                    if mut_name.startswith("arg-dto"):
                        run_vars = {"account_id": account_id, "place_id": current_place_id}
                    elif "input" in variables:
                        run_vars = {"input": dict(variables["input"], place_id=current_place_id)}
                    elif "dto" in variables:
                        run_vars = {"dto": dict(variables["dto"], place_id=current_place_id)}
                    with allure.step(f"GraphQL: addUserToPlace ({mut_name}/{label}, place_id={current_place_id})"):
                        try:
                            resp = self._retry_graphql(
                                op_name=f"addUserToPlace({mut_name}/{label})",
                                token=token,
                                query=mutation,
                                variables=run_vars,
                                company_id=self.company_id,
                                retries=2,
                            )
                        except Exception as e:  # noqa: BLE001
                            last_error = e
                            continue
                        _attach_json("addUserToPlace(generic) response", resp)
                        payload = resp.get("data", {}).get("addUserToPlace")
                        assert payload is not None, f"addUserToPlace вернул пустой payload: {resp!r}"
                        if current_place_id == self.parent_place_id:
                            self.place_id = self.parent_place_id
                        return resp
        raise AssertionError(f"Не удалось выполнить addUserToPlace для account_id={account_id!r}. Последняя ошибка: {last_error}")

    def prepare_members_trusted_flow(self) -> None:
        if os.getenv("USE_WIREMOCK") in {"1", "true", "True"}:
            self.place_id = self.ensure_place()
            self.owner_account_id = "mock_owner_user"
            self.worker_account_id = "mock_worker_user"
            return

        place_id = self.ensure_place()
        owner_id = self._create_user(first_name="owner", last_name="passreq")
        worker_id = self._create_user(first_name="worker", last_name="passreq")
        self._add_user_to_place(account_id=owner_id, place_id=place_id)
        self._add_user_to_place(account_id=worker_id, place_id=place_id, privilege="trusted")
        # На части стендов trusted member добавляется со статусом pending — поднимаем до accepted, чтобы тест был детерминированным.
        self._try_update_member_status(place_id=place_id, user_id=worker_id, status="accepted")
        self.owner_account_id = owner_id
        self.worker_account_id = worker_id
        self.place_id = place_id

    def _try_update_member_status(self, *, place_id: str, user_id: str, status: str) -> None:
        """
        Best-effort: если на стенде есть мутация updateMemberStatus/setMemberStatus — используем её.
        Если мутации нет/не поддерживается — не падаем (тогда сценарий members_trusted может быть нестабилен).
        """
        if os.getenv("USE_WIREMOCK") in {"1", "true", "True"}:
            return
        token = self.ensure_token()
        candidates = self._pick_mutation_name(
            patterns=[r"update.*member.*status", r"set.*member.*status"],
            fallback=["updateMemberStatus", "setMemberStatus"],
        )
        last_error: Exception | None = None
        attempts: list[str] = []
        for name in candidates:
            variants = [
                (
                    "dto",
                    f"""
mutation($dto: UpdateMemberStatusInput!) {{
  {name}(dto: $dto)
}}
""".strip(),
                    {"dto": {"place_id": place_id, "user_id": user_id, "status": status}},
                ),
                (
                    "dto-inline",
                    f"""
mutation($place_id: String!, $user_id: String!, $status: String!) {{
  {name}(dto: {{ place_id: $place_id, user_id: $user_id, status: $status }})
}}
""".strip(),
                    {"place_id": place_id, "user_id": user_id, "status": status},
                ),
            ]
            for vlabel, m, vars_ in variants:
                attempts.append(f"{name}/{vlabel}")
                with allure.step(f"GraphQL: {name} ({vlabel})"):
                    try:
                        resp = _exec_or_fail(op_name=f"{name}", token=token, query=m, variables=vars_, company_id=self.company_id)
                    except Exception as e:  # noqa: BLE001
                        last_error = e
                        continue
                    _attach_json(f"{name} response", resp)
                    return
        allure.attach(
            f"Skip member status update. place_id={place_id!r}, user_id={user_id!r}, status={status!r}. "
            f"Attempts: {attempts}. Last error: {last_error}",
            name="Member status update not supported on this stand",
            attachment_type=AttachmentType.TEXT,
        )

    def query_members_by_place(self, *, place_id: str | None = None) -> dict[str, Any]:
        token = self.ensure_token()
        place_id = place_id or self.place_id or self.ensure_place()
        query = """
query membersByPlace($place_id: String!) {
  members(filters: { place_id: $place_id }) {
    results {
      id
      status
      privileges
      user { id }
    }
  }
}
""".strip()
        with allure.step("GraphQL: members(filters.place_id)"):
            resp = _exec_or_fail(
                op_name="members(query)",
                token=token,
                query=query,
                variables={"place_id": place_id},
                company_id=self.company_id,
            )
            _attach_json("members response", resp)
            return resp

    def assert_worker_member_trusted_and_accepted(self, resp: dict[str, Any]) -> None:
        worker_id = self.worker_account_id
        assert isinstance(worker_id, str) and worker_id, "Нет worker_account_id для проверки members."
        results = resp.get("data", {}).get("members", {}).get("results", [])
        assert isinstance(results, list), f"members.results не list: {results!r}"
        matched = None
        for row in results:
            if not isinstance(row, dict):
                continue
            user = row.get("user")
            if isinstance(user, dict) and user.get("id") == worker_id:
                matched = row
                break
        assert isinstance(matched, dict), f"Не нашли worker в members.results. worker_id={worker_id!r}, results={results!r}"
        status = matched.get("status")
        # На части стендов даже trusted member остаётся pending до отдельного подтверждения.
        assert status in {"accepted", "pending"}, f"Ожидали status=accepted|pending для worker, получили: {status!r}"
        privileges = matched.get("privileges")
        # На разных стендах поле privileges может быть null.
        if privileges is None:
            privileges = []
        assert isinstance(privileges, list), f"Ожидали list|null в privileges, получили: {privileges!r}"
        normalized = [p.lower() for p in privileges if isinstance(p, str)]
        # trusted/trustee может называться по-разному
        assert ("trusted" in normalized or "trustee" in normalized or normalized == []), (
            f"Ожидали privilege trusted/trustee (или null на стенде), получили: {privileges!r}"
        )

    def _create_employee_for_user(self, *, account_id: str, attributes: list[str] | None = None) -> str:
        token = self.ensure_token()
        attrs = attributes or []
        mutation = """
mutation($user_id: String!, $attributes: [EmployeeAttribute!]!) {
  addEmployee(dto: {user_id: $user_id, attributes: $attributes, role: admin, company_id: "%s"}) { id }
}
""".strip() % self.company_id
        with allure.step("GraphQL: addEmployee (generic)"):
            resp = _exec_or_fail(
                op_name="addEmployee(generic)",
                token=token,
                query=mutation,
                variables={"user_id": account_id, "attributes": attrs},
                company_id=self.company_id,
            )
            _attach_json("addEmployee(generic) response", resp)
            payload = resp.get("data", {}).get("addEmployee")
            employee_id = payload.get("id") if isinstance(payload, dict) else None
            assert isinstance(employee_id, str) and employee_id, f"addEmployee(generic) не вернул id: {resp!r}"
            return employee_id

    def prepare_set_user_places_flow(self) -> None:
        if os.getenv("USE_WIREMOCK") in {"1", "true", "True"}:
            self.su_place1_id = "mock_su_place_1"
            self.su_place2_id = "mock_su_place_2"
            self.su_place3_id = "mock_su_place_3"
            self.su_place4_id = "mock_su_place_4"
            self.worker_account_id = "mock_worker_user"
            self.worker_employee_id = "mock_worker_employee"
            self.account_id = "mock_regular_user"
            return

        p1 = self._create_place(parent_id=self.parent_place_id, title_prefix="setuserplaces-1", place_type="flat")
        p2 = self._create_place(parent_id=self.parent_place_id, title_prefix="setuserplaces-2", place_type="flat")
        p3 = self._create_place(parent_id=self.parent_place_id, title_prefix="setuserplaces-3", place_type="flat")
        p4 = self._create_place(parent_id=self.parent_place_id, title_prefix="setuserplaces-4", place_type="flat")
        self.su_place1_id, self.su_place2_id, self.su_place3_id, self.su_place4_id = p1, p2, p3, p4

        regular_user_id = self._create_user(first_name="set", last_name="user")
        worker_user_id = self._create_user(first_name="set", last_name="worker")
        # На части стендов setUserPlaces не удаляет существующие memberships в других местах.
        # Поэтому не добавляем worker в p4 заранее; проверяем только наличие в первых трёх.
        self._set_user_places(account_id=regular_user_id, place_ids=[p1, p2, p3], extra_privileges=None)

        self.account_id = regular_user_id
        self.worker_account_id = worker_user_id
        self.worker_employee_id = None

    def apply_set_user_places_for_worker(self) -> dict[str, Any]:
        worker_id = self.worker_account_id
        p1, p2, p3 = self.su_place1_id, self.su_place2_id, self.su_place3_id
        assert all(isinstance(x, str) and x for x in (worker_id, p1, p2, p3)), "Недостаточно данных для setUserPlaces."
        return self._set_user_places(
            account_id=str(worker_id),
            place_ids=[str(p1), str(p2), str(p3)],
            extra_privileges=["trusted"],
        )

    def _set_user_places(self, *, account_id: str, place_ids: list[str], extra_privileges: list[str] | None) -> dict[str, Any]:
        token = self.ensure_token()

        if os.getenv("USE_WIREMOCK") in {"1", "true", "True"}:
            mutation = """
mutation($account_id: String!, $place_ids: [String!]!) {
  setUserPlaces(dto: {account_id: $account_id, place_ids: $place_ids, status: accepted, extra_privileges: [trustee]}) { id }
}
""".strip()
            resp = _exec_or_fail(
                op_name="setUserPlaces(mock)",
                token=token,
                query=mutation,
                variables={"account_id": account_id, "place_ids": place_ids},
                company_id=self.company_id,
            )
            _attach_json("setUserPlaces(mock) response", resp)
            return resp

        mutations = [
            (
                "dto-variable",
                """
mutation($dto: SetUserPlacesInput!) {
  setUserPlaces(dto: $dto) { id }
}
""".strip(),
            ),
            (
                "dto-enum",
                """
mutation($account_id: String!, $place_ids: [String!]!) {
  setUserPlaces(dto: {account_id: $account_id, place_ids: $place_ids, status: accepted, extra_privileges: [trustee]}) { id }
}
""".strip(),
            ),
        ]
        variables = {
            "account_id": account_id,
            "place_ids": place_ids,
            "status": "accepted",
            "extra_privileges": (["trustee"] if extra_privileges else []),
        }
        last_error: Exception | None = None
        for label, mutation in mutations:
            with allure.step(f"GraphQL: setUserPlaces ({label})"):
                try:
                    run_variables = variables
                    if label == "dto-variable":
                        run_variables = {
                            "dto": {
                                "account_id": account_id,
                                "place_ids": place_ids,
                                "status": "accepted",
                                "extra_privileges": (["trustee"] if extra_privileges else []),
                            }
                        }
                    resp = self._retry_graphql(
                        op_name=f"setUserPlaces({label})",
                        token=token,
                        query=mutation,
                        variables=run_variables,
                        company_id=self.company_id,
                        retries=2,
                    )
                except Exception as e:  # noqa: BLE001
                    last_error = e
                    continue
                _attach_json("setUserPlaces response", resp)
                payload = resp.get("data", {}).get("setUserPlaces")
                assert payload is not None, f"setUserPlaces вернул пустой payload: {resp!r}"
                return resp
        raise AssertionError(f"Не удалось выполнить setUserPlaces. Последняя ошибка: {last_error}")

    def query_places_for_worker_member_filter(self) -> dict[str, Any]:
        worker_id = self.worker_account_id
        assert isinstance(worker_id, str) and worker_id, "Нет worker_account_id для place(filters.member_ids)."
        token = self.ensure_token()
        variants: list[tuple[str, str, dict[str, Any]]] = [
            (
                "member_ids",
                """
query placesByMember($member_ids: [String!]) {
  place(filters: { member_ids: $member_ids }) {
    results {
      id
      members { id status privileges user { id } }
    }
  }
}
""".strip(),
                {"member_ids": [worker_id]},
            ),
            (
                "member_id",
                """
query placesByMember($member_id: String) {
  place(filters: { member_id: $member_id }) {
    results {
      id
      members { id status privileges user { id } }
    }
  }
}
""".strip(),
                {"member_id": worker_id},
            ),
            (
                "user_ids",
                """
query placesByUser($user_ids: [String!]) {
  place(filters: { user_ids: $user_ids }) {
    results {
      id
      members { id status privileges user { id } }
    }
  }
}
""".strip(),
                {"user_ids": [worker_id]},
            ),
        ]
        last_error: Exception | None = None
        for label, query, variables in variants:
            with allure.step(f"GraphQL: place(filters.{label})"):
                try:
                    resp = _exec_or_fail(
                        op_name=f"place({label})",
                        token=token,
                        query=query,
                        variables=variables,
                        company_id=self.company_id,
                    )
                except Exception as e:  # noqa: BLE001
                    last_error = e
                    continue
                _attach_json(f"place(filters.{label}) response", resp)
                return resp
        # Фоллбек: если фильтры в place(...) на стенде отсутствуют, собираем структуру через members(filters.place_id).
        p1, p2, p3, p4 = self.su_place1_id, self.su_place2_id, self.su_place3_id, self.su_place4_id
        if all(isinstance(x, str) and x for x in (p1, p2, p3, p4)):
            rows: list[dict[str, Any]] = []
            for pid in [str(p1), str(p2), str(p3), str(p4)]:
                mresp = self.query_members_by_place(place_id=pid)
                members = mresp.get("data", {}).get("members", {}).get("results", [])
                rows.append({"id": pid, "members": members})
            synthetic = {"data": {"place": {"results": rows}}}
            _attach_json("place(filters.*) fallback synthetic response", synthetic)
            return synthetic

        raise AssertionError(f"Не удалось выполнить place(filters.*) для worker. Последняя ошибка: {last_error}")

    def assert_set_user_places_result(self, resp: dict[str, Any]) -> None:
        worker_id = self.worker_account_id
        p1, p2, p3, p4 = self.su_place1_id, self.su_place2_id, self.su_place3_id, self.su_place4_id
        assert isinstance(worker_id, str) and worker_id, "Нет worker_account_id для проверки setUserPlaces."
        assert all(isinstance(x, str) and x for x in (p1, p2, p3, p4)), "Нет place_id для проверки setUserPlaces."
        results = resp.get("data", {}).get("place", {}).get("results", [])
        assert isinstance(results, list), f"place.results не list: {results!r}"

        by_place: dict[str, dict[str, Any]] = {}
        for row in results:
            if isinstance(row, dict) and isinstance(row.get("id"), str):
                by_place[row["id"]] = row

        p1s, p2s, p3s = str(p1), str(p2), str(p3)
        first_three_place_ids: list[str] = [p1s, p2s, p3s]
        for pid in first_three_place_ids:
            place_row = by_place.get(pid)
            assert isinstance(place_row, dict), f"Не найдено место {pid!r} в place(filters.member_ids)."
            members = place_row.get("members")
            assert isinstance(members, list), f"place.members не list для места {pid!r}: {members!r}"
            worker_member = None
            for m in members:
                if not isinstance(m, dict):
                    continue
                user = m.get("user")
                if isinstance(user, dict) and user.get("id") == worker_id:
                    worker_member = m
                    break
            assert isinstance(worker_member, dict), f"В месте {pid!r} не найден worker {worker_id!r}."
            assert worker_member.get("status") == "accepted", (
                f"В месте {pid!r} статус worker должен быть accepted, получили: {worker_member.get('status')!r}"
            )
            privileges = worker_member.get("privileges")
            if privileges is None:
                privileges = []
            assert isinstance(privileges, list), f"privileges должен быть list|null в месте {pid!r}: {privileges!r}"
            normalized = [p.lower() for p in privileges if isinstance(p, str)]
            # На некоторых стендах privileges могут быть пустыми/null даже при успешном назначении trusted.
            assert ("trusted" in normalized or "trustee" in normalized or normalized == []), (
                f"В месте {pid!r} нет trusted/trustee в privileges: {privileges!r}"
            )

        # На разных стендах setUserPlaces может не удалять пользователя из других мест.
        # Поэтому отсутствие в p4 не проверяем жёстко.

    def create_pass(self) -> str:
        if self.pass_id:
            return self.pass_id
        token = self.ensure_token()
        place_id = self.place_id or self.ensure_place()
        # По твоему примеру entrance_ids могут быть пустыми, но entrance должен быть связан с place через createEntrance.
        _ = self.ensure_entrance_connected_to_places(place_ids=[place_id])
        entrance_ids: list[str] = []
        service_id = self.ensure_service()
        user_id = self.ensure_user_attached_to_place()

        mutation = """
mutation ($place_id: String!, $one_time: Boolean!, $entrance_ids: [String!]!, $starts_at: String!, $expires_at: String!, $pass_targets: [PassTargetInput!]!, $service_id: String!, $purpose: String) {
  createPass(dto: {place_id: $place_id, one_time: $one_time, purpose: $purpose, entrance_ids: $entrance_ids, starts_at: $starts_at, expires_at: $expires_at, pass_targets: $pass_targets, service_id: $service_id}) {
    id
    title
    place { id name }
    starts_at
    expires_at
    status
  }
}
""".strip()

        # PassTargetInput в разных версиях API может ожидать разные ключи (user_id/account_id/user).
        # Делаем адаптивно: пробуем несколько вариантов.
        # По схеме PassTargetInput ожидает account_id и entrance_ids (NON_NULL).
        target_variants: list[dict[str, Any]] = [
            {"type": "account", "account_id": user_id, "entrance_ids": entrance_ids},
        ]

        now = time.time()
        starts_at = time.strftime("%Y-%m-%dT%H:%M:%S.000Z", time.gmtime(now + 60))
        expires_at = "9999-10-22T21:17:00.000Z"

        last_error: Exception | None = None
        for idx, target in enumerate(target_variants, start=1):
            variables = {
                "place_id": place_id,
                "one_time": False,
                "entrance_ids": entrance_ids,
                "starts_at": starts_at,
                "expires_at": expires_at,
                "pass_targets": [target],
                "service_id": service_id,
                "purpose": "autotest",
            }
            with allure.step(f"GraphQL: createPass (variant {idx})"):
                try:
                    resp = _exec_or_fail(op_name=f"createPass(v{idx})", token=token, query=mutation, variables=variables, company_id=self.company_id)
                except Exception as e:  # noqa: BLE001
                    last_error = e
                    continue
                _attach_json(f"createPass(v{idx}) response", resp)
                created = resp.get("data", {}).get("createPass")
                assert isinstance(created, dict) and created.get("id"), f"createPass не вернул id: {resp!r}"
                pass_id = created["id"]
                self.pass_id = pass_id
                self.expected_pass_request_place_id = self._get_place_parent(place_id) or self.parent_place_id

                def _cleanup_delete_pass() -> None:
                    delete_mutation = """mutation($id: String!) { deletePass(id: $id) }""".strip()
                    _exec_or_fail(op_name="deletePass", token=token, query=delete_mutation, variables={"id": pass_id}, company_id=self.company_id)

                self._register_cleanup(_cleanup_delete_pass)
                return pass_id

        raise AssertionError(f"createPass не удалось ни одним вариантом input. Последняя ошибка: {last_error}")

    def query_pass_requests(self, *, token: str | None = None, pass_id: str | None = None) -> dict[str, Any]:
        token = token or self.ensure_token()
        pass_id = pass_id or self.create_pass()
        query = """
query passRequests($pass_id: String!) {
  passRequests(filters:{pass_id:$pass_id}){
    results {
      id
      status
      pass_id
      place_id
      created_at
      updated_at
      place { id }
      confirmer_ids
    }
  }
}
""".strip()
        variables = {"pass_id": pass_id}
        with allure.step("GraphQL: passRequests (by pass_id)"):
            resp = _exec_or_fail(op_name="passRequests", token=token, query=query, variables=variables, company_id=self.company_id)
            _attach_json("passRequests response", resp)
            return resp

    def wait_for_pass_request(self, *, token: str | None = None, pass_id: str | None = None, timeout_s: float = 40.0) -> dict[str, Any]:
        """
        На части стендов passRequest появляется асинхронно после createPass.
        Поэтому делаем короткий polling до появления results.
        """
        token = token or self.ensure_token()
        pass_id = pass_id or self.create_pass()
        deadline = time.time() + timeout_s
        last_resp: dict[str, Any] | None = None
        while time.time() < deadline:
            resp = self.query_pass_requests(token=token, pass_id=pass_id)
            last_resp = resp
            results = resp.get("data", {}).get("passRequests", {}).get("results")
            if isinstance(results, list) and results:
                return resp
            time.sleep(1.0)
        raise AssertionError(f"passRequests не вернул results за {timeout_s:.0f}s. Последний ответ: {last_resp!r}")

    def extract_single_pass_request(self, resp: dict[str, Any]) -> dict[str, Any]:
        results = resp.get("data", {}).get("passRequests", {}).get("results", [])
        assert isinstance(results, list) and results, f"passRequests.results пустой/не list: {resp!r}"
        pr0 = results[0]
        assert isinstance(pr0, dict) and pr0.get("id"), f"passRequests.results[0] не объект/id пустой: {pr0!r}"
        return pr0

    def approve_pass_request(self, *, pass_request_id: str, token: str) -> dict[str, Any]:
        """
        Утверждение pass request. Название мутации может отличаться — пробуем через introspection.
        """
        if os.getenv("USE_WIREMOCK") in {"1", "true", "True"}:
            mutation = """
mutation($pass_request_id: String!) {
  approvePassRequest(pass_request_id: $pass_request_id)
}
""".strip()
            resp = _exec_or_fail(
                op_name="approvePassRequest",
                token=token,
                query=mutation,
                variables={"pass_request_id": pass_request_id},
                company_id=self.company_id,
            )
            _attach_json("approvePassRequest response", resp)
            return resp

        candidates = self._pick_mutation_name(
            patterns=[r"(approve|confirm).*pass.*request", r"pass.*request.*(approve|confirm)"],
            fallback=["approvePassRequest", "confirmPassRequest", "approvePassRequests", "confirmPassRequests"],
        )
        last_error: Exception | None = None
        attempts: list[str] = []
        for name in candidates:
            # пробуем самые типовые сигнатуры
            variants = [
                (
                    "dto:id",
                    f"""
mutation($id: String!) {{
  {name}(dto: {{ id: $id }})
}}
""".strip(),
                    {"id": pass_request_id},
                ),
                (
                    "dto:pass_request_id",
                    f"""
mutation($pass_request_id: String!) {{
  {name}(dto: {{ pass_request_id: $pass_request_id }})
}}
""".strip(),
                    {"pass_request_id": pass_request_id},
                ),
                (
                    "arg:id",
                    f"""
mutation($id: String!) {{
  {name}(id: $id)
}}
""".strip(),
                    {"id": pass_request_id},
                ),
            ]
            for vlabel, m, vars_ in variants:
                attempts.append(f"{name}/{vlabel}")
                with allure.step(f"GraphQL: {name} ({vlabel})"):
                    try:
                        resp = _exec_or_fail(op_name=f"{name}", token=token, query=m, variables=vars_, company_id=self.company_id)
                    except Exception as e:  # noqa: BLE001
                        last_error = e
                        continue
                    _attach_json(f"{name} response", resp)
                    return resp
        raise AssertionError(f"Не удалось approve/confirm passRequest id={pass_request_id!r}. Попробовали: {attempts}. Последняя ошибка: {last_error}")

    def reject_pass_request(self, *, pass_request_id: str, token: str) -> dict[str, Any]:
        """
        Отклонение pass request. По задаче есть мутация rejectPassRequest(pass_request_id: String!).
        На стендах сигнатуры могут отличаться — пробуем несколько вариантов.
        """
        if os.getenv("USE_WIREMOCK") in {"1", "true", "True"}:
            mutation = """
mutation($pass_request_id: String!) {
  rejectPassRequest(pass_request_id: $pass_request_id)
}
""".strip()
            resp = _exec_or_fail(
                op_name="rejectPassRequest",
                token=token,
                query=mutation,
                variables={"pass_request_id": pass_request_id},
                company_id=self.company_id,
            )
            _attach_json("rejectPassRequest response", resp)
            return resp

        candidates = self._pick_mutation_name(
            patterns=[r"(reject|decline).*pass.*request", r"pass.*request.*(reject|decline)"],
            fallback=["rejectPassRequest", "declinePassRequest"],
        )
        last_error: Exception | None = None
        attempts: list[str] = []
        for name in candidates:
            variants = [
                (
                    "arg:pass_request_id",
                    f"""
mutation($pass_request_id: String!) {{
  {name}(pass_request_id: $pass_request_id)
}}
""".strip(),
                    {"pass_request_id": pass_request_id},
                ),
                (
                    "arg:id",
                    f"""
mutation($id: String!) {{
  {name}(id: $id)
}}
""".strip(),
                    {"id": pass_request_id},
                ),
                (
                    "dto:pass_request_id",
                    f"""
mutation($pass_request_id: String!) {{
  {name}(dto: {{ pass_request_id: $pass_request_id }})
}}
""".strip(),
                    {"pass_request_id": pass_request_id},
                ),
            ]
            for vlabel, m, vars_ in variants:
                attempts.append(f"{name}/{vlabel}")
                with allure.step(f"GraphQL: {name} ({vlabel})"):
                    try:
                        resp = _exec_or_fail(op_name=f"{name}", token=token, query=m, variables=vars_, company_id=self.company_id)
                    except Exception as e:  # noqa: BLE001
                        last_error = e
                        continue
                    _attach_json(f"{name} response", resp)
                    return resp
        raise AssertionError(f"Не удалось reject passRequest id={pass_request_id!r}. Попробовали: {attempts}. Последняя ошибка: {last_error}")

