"""NSPD bulk cadastre client — async httpx-based, rate-limited.

PR 2/5 of GH issue #168: Bulk Cadastre Ingest (ЕКБ 2408 кварталов).

Три новых метода:
  - search_by_quarter(quarter) → QuarterSnapshot  (phase 1 snapshot)
  - wms_feature_info(layer_id, bbox, click_xy) → list[NSPDBulkFeature]  (grid-walk)
  - list_objects_in_building(objdoc_id) → ObjectsListing  (Q3 deferred)

Ключевые отличия от nspd_client.py (sync urllib):
  - async httpx.AsyncClient (connection pooling, timeout objects)
  - asyncio.Semaphore(3) — не более 3 одновременных запросов
  - asyncio.sleep(0.05) jitter между запросами
  - 429 → exponential backoff (2 → 4 → 8 с, max 3 retries)
  - 403 WAF → raise немедленно (не retry, нужен manual investigate)

Geometry: NSPD возвращает geom в EPSG:3857. ST_Transform(geom, 4326)
делается на стороне Celery-таска (PR 3) при upsert в DB.

Rate limit: 3 req/s sustained через Semaphore(3) + 0.05s jitter.
При 429 — exponential backoff: 2s → 4s → 8s, max 3 retries.
При 403 (WAF) — raise NspdBulkWafError, не retry.
"""

from __future__ import annotations

import asyncio
import datetime as _dt
import logging
from typing import Any

import httpx

from app.schemas.nspd_bulk import NSPDBulkFeature, ObjectsListing, QuarterSnapshot

logger = logging.getLogger(__name__)

# ── Константы ──────────────────────────────────────────────────────────────────

NSPD_BASE = "https://nspd.gov.ru/api"
NSPD_SEARCH_URL = f"{NSPD_BASE}/geoportal/v2/search/geoportal"
NSPD_WMS_BASE = f"{NSPD_BASE}/aeggis/v4"
NSPD_TAB_GROUP_URL = f"{NSPD_BASE}/geoportal/v1/tab-group-data"

# Маппинг category_id → thematicSearchId для per-category override.
# Получен экспериментально (curl-probe, 2026-05-15):
#   thematicSearchId=1  → ZU+Здания+Сооружения+Реестр адресов (без ЗОУИТ)
#   thematicSearchId=5  → ЗОУИТ (36940, 469039, 469040, 469042)
#   thematicSearchId=15 → ЕНК  (39663)
#   thematicSearchId=2  → Квартальная статистика (36381) — внутренний, не для override
# Ключ None → базовый тип поиска (thematic=1, возвращает все категории включая ЗОУИТ).
_CAT_TO_THEMATIC_SEARCH_ID: dict[int, int] = {
    36940: 5,  # ЗОУИТ — охранная зона
    469039: 5,  # ЗОУИТ subtype
    469040: 5,  # ЗОУИТ subtype
    469042: 5,  # ЗОУИТ subtype
    39663: 15,  # ЕНК
}

# Concurrency cap per HTTP fetch loop. Issue #260 (Sub-PR B re-review):
# module-level asyncio.Semaphore() bind'ится к event loop первого asyncio.run()
# вызова, на втором — RuntimeError: bound to a different event loop.
# search_by_quarter делает N sequential asyncio.run() per layer → semaphore надо
# создавать per-instance в __aenter__ (под текущим running loop).
_SEMAPHORE_LIMIT = 3

# Таймаут подключения и чтения
DEFAULT_TIMEOUT = httpx.Timeout(30.0, connect=10.0)

# Headers — exact match to legacy nspd_lite.py (proven to bypass NSPD WAF
# on VPS IP since April 2026). All-lowercase keys + cache-control + Chrome 144
# UA. Initial HAR-extracted headers (Pascal-Case, Chrome 148, no cache-control)
# were blocked 50/50 в pilot run v2 (job_id=2, all 403 WAF).
DEFAULT_HEADERS: dict[str, str] = {
    "accept": "*/*",
    "accept-language": "en-US,en;q=0.9,ru-RU;q=0.8,ru;q=0.7,es;q=0.6",
    "cache-control": "no-cache",
    "pragma": "no-cache",
    "referer": "https://nspd.gov.ru/map?thematic=PKK",
    "origin": "https://nspd.gov.ru",
    "user-agent": (
        "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
        "(KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36"
    ),
}

# Retry settings
_MAX_RETRIES = 3
_BACKOFF_BASE_S = 2.0  # 2 → 4 → 8 секунд


# ── Exceptions ─────────────────────────────────────────────────────────────────


class NspdBulkError(RuntimeError):
    """Неуспешный ответ от NSPD bulk endpoint."""


class NspdBulkWafError(NspdBulkError):
    """HTTP 403 WAF блокировка — не retry, нужно manual investigate."""


class NspdBulkRateLimitError(NspdBulkError):
    """HTTP 429 Rate limit — исчерпаны retries после backoff."""


class NspdBulkServerError(NspdBulkError):
    """HTTP 5xx / WMS ServiceException — server-side ошибка NSPD.

    Issue #252: NSPD WMS отдаёт 500 + тело с <ServiceException> на десятки cells
    per quarter (повторяющиеся в GlitchTip BACKEND-8/10/11/12/14/16/17/24/36/37).
    Это transient server-side noise, не наш баг и не клиентская ошибка (4xx).
    Выделяем в отдельный подкласс, чтобы:
      - caller (bulk_harvest_quarter_task) ретраил весь квартал — autoretry_for
        ловит подкласс NspdBulkError, dont_autoretry_for=(NspdBulkWafError,) его
        не исключает;
      - grid-walk считал per-layer fail-rate и поднимал layer_X_failed-флаг,
        если ВСЕ cells слоя упали с 500 (см. _grid_walk_category).
    """


# ── NSPDBulkClient ─────────────────────────────────────────────────────────────


class NSPDBulkClient:
    """Async NSPD client для bulk cadastre ingest.

    Использовать как async context manager:

        async with NSPDBulkClient() as client:
            snapshot = await client.search_by_quarter("66:41:0303161")

    Или с explicit lifecycle (для Celery tasks):

        client = NSPDBulkClient()
        await client.__aenter__()
        try:
            ...
        finally:
            await client.__aexit__(None, None, None)
    """

    def __init__(
        self,
        timeout: httpx.Timeout = DEFAULT_TIMEOUT,
        headers: dict[str, str] | None = None,
        max_retries: int = _MAX_RETRIES,
    ) -> None:
        self._timeout = timeout
        self._headers = headers or DEFAULT_HEADERS
        self._max_retries = max_retries
        self._client: httpx.AsyncClient | None = None
        # Issue #260: semaphore создаётся в __aenter__ под текущий event loop.
        # Module-level не работал — sequential asyncio.run() bind'ил его к
        # закрытому loop'у первого вызова.
        self._sem: asyncio.Semaphore | None = None

    async def __aenter__(self) -> NSPDBulkClient:
        # NSPD prod chain contains a self-signed/internal CA on Beget VPS →
        # default verify trips CERTIFICATE_VERIFY_FAILED. Existing legacy
        # client (app.services.scrapers.nspd_lite) uses unverified context
        # for the same reason — reuse pattern. Risk OK: public gov data,
        # no sensitive payload, X-Admin-Token не уходит к NSPD.
        self._client = httpx.AsyncClient(
            headers=self._headers,
            timeout=self._timeout,
            follow_redirects=True,
            verify=False,
        )
        # Создаём semaphore под running loop — safe для sequential asyncio.run().
        self._sem = asyncio.Semaphore(_SEMAPHORE_LIMIT)
        return self

    async def __aexit__(self, *_: Any) -> None:
        if self._client is not None:
            await self._client.aclose()
            self._client = None

    # ── Internal HTTP helper ───────────────────────────────────────────────────

    async def _get_json(self, url: str, params: dict[str, Any] | None = None) -> Any:
        """GET → JSON с rate-limit semaphore, jitter, retry при 429.

        Raises:
            NspdBulkWafError при 403.
            NspdBulkRateLimitError при 429 после исчерпания retries.
            NspdBulkServerError при 5xx или WMS <ServiceException> в теле.
            NspdBulkError при прочих 4xx.
        """
        if self._client is None or self._sem is None:
            raise RuntimeError("NSPDBulkClient не инициализирован — используй async with")

        attempt = 0
        while True:
            async with self._sem:
                # Небольшой jitter чтобы N одновременных запросов не начинались в 0мс
                await asyncio.sleep(0.05)
                try:
                    resp = await self._client.get(url, params=params)
                except httpx.TransportError as e:
                    raise NspdBulkError(f"Network error: {e}") from e

            # Обработка статусов — вне семафора чтобы не держать слот при sleep
            if resp.status_code == 403:
                body_preview = resp.text[:200]
                logger.warning("NSPD WAF 403 blocked: url=%s preview=%s", url, body_preview)
                raise NspdBulkWafError(f"HTTP 403 WAF block (manual investigate required): {url}")

            if resp.status_code == 429:
                attempt += 1
                if attempt > self._max_retries:
                    raise NspdBulkRateLimitError(
                        f"HTTP 429 rate-limit: exhausted {self._max_retries} retries for {url}"
                    )
                backoff = _BACKOFF_BASE_S * (2 ** (attempt - 1))
                logger.warning(
                    "NSPD 429 rate-limit: retry %d/%d after %.0fs backoff, url=%s",
                    attempt,
                    self._max_retries,
                    backoff,
                    url,
                )
                await asyncio.sleep(backoff)
                continue

            # Issue #252: 5xx — server-side ошибка NSPD (transient). Отдельный
            # подкласс, чтобы caller ретраил квартал и считал per-layer fail-rate.
            if resp.status_code >= 500:
                body_preview = resp.text[:300]
                raise NspdBulkServerError(
                    f"HTTP {resp.status_code} server error: {url} — {body_preview}"
                )

            if resp.status_code >= 400:
                body_preview = resp.text[:300]
                raise NspdBulkError(f"HTTP {resp.status_code}: {url} — {body_preview}")

            # Issue #252: NSPD WMS (GeoServer) иногда отдаёт HTTP 200 с XML-телом
            # <ServiceException> вместо запрошенного application/json (например при
            # внутренней ошибке рендера слоя). resp.json() в этом случае кинул бы
            # JSONDecodeError, маскируя server-side природу сбоя. Детектим маркер
            # в теле и поднимаем NspdBulkServerError — единый retry/skip-путь с 5xx.
            body = resp.text
            if "ServiceException" in body or "<ServiceExceptionReport" in body:
                raise NspdBulkServerError(
                    f"WMS ServiceException (HTTP {resp.status_code}): {url} — {body[:300]}"
                )

            return resp.json()

    # ── 1. search_by_quarter ──────────────────────────────────────────────────

    async def search_by_quarter(
        self,
        quarter: str,
        category_id: int | None = None,
    ) -> QuarterSnapshot:
        """Поиск объектов в квартале через NSPD search endpoint.

        Базовый вызов (category_id=None):
            GET .../search/geoportal?thematic=1&query={quarter}
            Возвращает до 20 объектов per категории для всех категорий включая ЗОУИТ.

        Per-category вызов (category_id задан):
            Выбирает thematicSearchId соответствующий группе категории:
            - 36940/469039/469040/469042 (ЗОУИТ) → thematicSearchId=5
            - 39663 (ЕНК)                        → thematicSearchId=15
            - прочие (ЗУ/Здания/Сооружения/ОНС)  → thematic=1 (общий, те же 20 per cat)
            Используется в Phase 1.5 bulk_harvest для дозабора overflow-категорий,
            которые не покрываются grid-walk из-за broken bbox.

        NSPD API особенности (curl-probe 2026-05-15):
            - API возвращает строго 20 объектов per категории, pagination недоступна.
            - Параметр categoryId игнорируется сервером (не фильтрует категории).
            - thematic=1 лучше thematicSearchId=1: включает ЗОУИТ в features и meta.

        Args:
            quarter: кадастровый номер квартала в формате `66:41:NNNNNN`.
            category_id: если задан — использовать thematicSearchId специфичный
                         для группы этой категории. None = базовый тип (thematic=1).

        Returns:
            QuarterSnapshot с features + meta_counts.
            При 404 или пустом ответе — snapshot с features=[].

        Raises:
            NspdBulkWafError при 403.
            NspdBulkError при сетевых / прочих ошибках.
        """
        fetched_at = _dt.datetime.now(_dt.UTC).isoformat()

        # Выбираем параметры запроса в зависимости от category_id
        if category_id is not None and category_id in _CAT_TO_THEMATIC_SEARCH_ID:
            # Специфичный thematicSearchId для ЗОУИТ / ЕНК групп
            params: dict[str, Any] = {
                "thematicSearchId": _CAT_TO_THEMATIC_SEARCH_ID[category_id],
                "query": quarter,
            }
        else:
            # Базовый thematic=1: все категории включая ЗОУИТ (лучше thematicSearchId=1)
            params = {"thematic": 1, "query": quarter}

        try:
            data = await self._get_json(NSPD_SEARCH_URL, params=params)
        except (NspdBulkWafError, NspdBulkRateLimitError, NspdBulkServerError):
            # 403 WAF / 429 / 5xx+ServiceException — НЕ «квартал не найден».
            # Пробрасываем как есть: caller (autoretry) ретраит квартал, WAF
            # останавливает harvest. Раньше подстрочная классификация по str(e)
            # с подмешанным body_preview могла ложно проглотить 5xx как 404.
            raise
        except NspdBulkError as e:
            # Остаётся базовый NspdBulkError = прочие 4xx (см. _get_json:234).
            # Текст: f"HTTP {code}: {url} — {body_preview}" → классифицируем по
            # ПРЕФИКСУ (код до URL), а не по вхождению в произвольное тело ответа.
            err_str = str(e)
            if err_str.startswith("HTTP 404:") or err_str.startswith("HTTP 400:"):
                logger.info(
                    "search_by_quarter: quarter %s not found (404/400), returning empty"
                    " (category_id=%s)",
                    quarter,
                    category_id,
                )
                return QuarterSnapshot(quarter=quarter, fetched_at=fetched_at)
            raise

        # Парсим response
        # Shape: {"data": {"type": "FeatureCollection", "features": [...], "meta": [...]}, ...}
        # или: {"data": {"features": [...], "meta": [...]}}
        payload = data if isinstance(data, dict) else {}
        data_block = payload.get("data") or {}
        if isinstance(data_block, str):
            # Иногда NSPD оборачивает data в строку (Bug_Nspd_Geo_Str_Object_No_Get_Fixed)
            logger.warning(
                "search_by_quarter: data block is string for quarter %s"
                " (category_id=%s), returning empty",
                quarter,
                category_id,
            )
            return QuarterSnapshot(quarter=quarter, fetched_at=fetched_at)

        raw_features: list[dict[str, Any]] = data_block.get("features") or []
        # meta лежит на TOP level: {"data": {features}, "meta": [...]}
        # fallback на data.meta для legacy shape (just in case)
        raw_meta: list[dict[str, Any]] = payload.get("meta") or data_block.get("meta") or []

        features = [NSPDBulkFeature.model_validate(f) for f in raw_features]

        # meta: [{categoryId: N, totalCount: M}, ...]
        meta_counts: dict[int, int] = {}
        for m in raw_meta:
            cat_id = m.get("categoryId")
            total = m.get("totalCount")
            if cat_id is None or total is None:
                continue
            # NSPD изредка отдаёт categoryId/totalCount нечисловой/float-строкой
            # ('20.0') или иным типом → голый int() кинул бы ValueError/TypeError,
            # который НЕ подкласс NspdBulkError и завалил бы всю Phase 1 квартала.
            # Зеркалит защитный try/except в schemas/nspd_bulk.py и
            # list_objects_in_building. Битую meta-запись просто пропускаем.
            try:
                meta_counts[int(cat_id)] = int(total)
            except (ValueError, TypeError):
                logger.warning(
                    "search_by_quarter: non-numeric meta entry quarter=%s"
                    " categoryId=%r totalCount=%r — skipping",
                    quarter,
                    cat_id,
                    total,
                )

        logger.info(
            "search_by_quarter: quarter=%s category_id=%s features=%d meta_cats=%d overflow=%d",
            quarter,
            category_id,
            len(features),
            len(meta_counts),
            len([c for c, cnt in meta_counts.items() if cnt > 20]),
        )

        return QuarterSnapshot(
            quarter=quarter,
            fetched_at=fetched_at,
            features=features,
            meta_counts=meta_counts,
        )

    # ── 2. wms_feature_info ───────────────────────────────────────────────────

    async def wms_feature_info(
        self,
        layer_id: int,
        bbox: tuple[float, float, float, float],
        click_xy: tuple[int, int],
        width: int = 512,
        height: int = 512,
    ) -> list[NSPDBulkFeature]:
        """WMS GetFeatureInfo — объекты layer'а в bbox на pixel-клике.

        GET /api/aeggis/v4/{layer_id}/wms?REQUEST=GetFeatureInfo&...

        Используется для grid-walk фазы: каждая ячейка bbox квартала
        кликается в центре (click_xy = (width//2, height//2)).

        Args:
            layer_id: NSPD layer ID (например 36368 для ЗУ ЕГРН).
            bbox: (x_min, y_min, x_max, y_max) в EPSG:3857 (метры).
            click_xy: (I, J) пиксель-координаты клика в [0..width-1, 0..height-1].
            width: ширина виртуального tile.
            height: высота виртуального tile.

        Returns:
            Список NSPDBulkFeature (может быть пуст).

        Raises:
            NspdBulkWafError при 403.
            NspdBulkError при прочих ошибках.
        """
        xmin, ymin, xmax, ymax = bbox
        click_i, click_j = click_xy

        url = f"{NSPD_WMS_BASE}/{layer_id}/wms"
        params: dict[str, Any] = {
            "REQUEST": "GetFeatureInfo",
            "SERVICE": "WMS",
            "VERSION": "1.3.0",
            "INFO_FORMAT": "application/json",
            "LAYERS": str(layer_id),
            "QUERY_LAYERS": str(layer_id),
            "CRS": "EPSG:3857",
            "BBOX": f"{xmin},{ymin},{xmax},{ymax}",
            "WIDTH": str(width),
            "HEIGHT": str(height),
            "I": str(click_i),
            "J": str(click_j),
            "STYLES": "",
            "TRANSPARENT": "true",
            "FORMAT": "image/png",
            "FEATURE_COUNT": "10",
        }

        data = await self._get_json(url, params=params)
        # NSPD/GeoServer изредка отдаёт валидный JSON, но не объект (list/str —
        # Bug_Nspd_Geo_Str_Object). Тогда `(data or {})` вернул бы сам truthy
        # data, а .get("features") кинул бы AttributeError (не NspdBulkError →
        # уронил бы ячейку grid-walk без сигнала). Унифицируем guard с
        # search_by_quarter: не-dict трактуем как пустой ответ.
        if not isinstance(data, dict):
            logger.warning(
                "wms_feature_info: non-dict JSON response layer=%d type=%s — returning empty",
                layer_id,
                type(data).__name__,
            )
            return []
        raw_features: list[dict[str, Any]] = data.get("features") or []
        return [NSPDBulkFeature.model_validate(f) for f in raw_features]

    # ── 3. get_features_in_bbox_grid ─────────────────────────────────────────

    async def get_features_in_bbox_grid(
        self,
        layer_id: int,
        bbox: tuple[float, float, float, float],
        *,
        grid_n: int = 7,
        tile_size: int = 512,
    ) -> list[dict]:
        """Grid-walk WMS GetFeatureInfo по layer_id в bbox.

        Разбивает bbox на grid_n × grid_n ячеек, кликает центр каждой.
        Дедуплицирует результаты по feature id.

        Args:
            layer_id: NSPD layer ID (например 875838 для ПЗЗ).
            bbox: (xmin, ymin, xmax, ymax) в EPSG:3857 (метры).
            grid_n: количество ячеек по каждой оси (7 → 49 запросов).
            tile_size: размер виртуального WMS тайла в пикселях.

        Returns:
            Список raw feature dict'ов с полями id, geometry, properties.
            Дедуплицированы по feature id.
        """
        xmin, ymin, xmax, ymax = bbox
        x_step = (xmax - xmin) / grid_n
        y_step = (ymax - ymin) / grid_n
        click_xy = (tile_size // 2, tile_size // 2)

        tasks = []
        for i in range(grid_n):
            for j in range(grid_n):
                cell_bbox = (
                    xmin + i * x_step,
                    ymin + j * y_step,
                    xmin + (i + 1) * x_step,
                    ymin + (j + 1) * y_step,
                )
                tasks.append(
                    self.wms_feature_info(
                        layer_id=layer_id,
                        bbox=cell_bbox,
                        click_xy=click_xy,
                        width=tile_size,
                        height=tile_size,
                    )
                )

        # Запускаем все ячейки конкурентно (semaphore ограничивает до 3 concurrent)
        cell_results = await asyncio.gather(*tasks, return_exceptions=True)

        seen_ids: set[str] = set()
        results: list[dict] = []
        # Issue #252-mirror: считаем server-side провалы и успешные ячейки, чтобы
        # отличить «слой реально пуст» (ok_cells>0, 0 features) от «слой/IP лёг»
        # (все ячейки 5xx/WAF). Раньше любой Exception молча падал на DEBUG и метод
        # отдавал [] → в БД писался ложный tz_count=0 без layer_failed-сигнала.
        server_errors = 0
        ok_cells = 0
        first_server_error: NspdBulkServerError | None = None

        for idx, cell_result in enumerate(cell_results):
            if isinstance(cell_result, NspdBulkWafError):
                # 403 WAF (бан IP) — по docstring должен ОСТАНОВИТЬ harvest, не
                # маскироваться пустым результатом. Пробрасываем немедленно.
                logger.warning(
                    "get_features_in_bbox_grid: layer=%d cell=%d WAF 403 — aborting grid-walk: %s",
                    layer_id,
                    idx,
                    cell_result,
                )
                raise cell_result
            if isinstance(cell_result, NspdBulkServerError):
                server_errors += 1
                if first_server_error is None:
                    first_server_error = cell_result
                logger.debug(
                    "get_features_in_bbox_grid: layer=%d cell=%d server error: %s",
                    layer_id,
                    idx,
                    cell_result,
                )
                continue
            if isinstance(cell_result, Exception):
                # Прочие (сетевые/parse) ошибки одной ячейки — не валим обход и НЕ
                # считаем server-side fail (иначе сеть ложно triggers layer_failed).
                logger.debug(
                    "get_features_in_bbox_grid: layer=%d cell=%d error: %s",
                    layer_id,
                    idx,
                    cell_result,
                )
                continue
            ok_cells += 1
            for feature in cell_result:
                fid = str(feature.id) if feature.id is not None else ""
                if fid and fid in seen_ids:
                    continue
                if fid:
                    seen_ids.add(fid)
                results.append(
                    {
                        "id": feature.id,
                        "geometry": feature.geometry,
                        "properties": feature.properties,
                    }
                )

        # Если БЫЛИ server-side провалы И ни одна ячейка не прошла — слой/NSPD лёг
        # целиком. Возврат [] здесь означал бы ложный tz_count=0 («зонирование
        # отсутствует»). Пробрасываем server-error, чтобы caller отличил сбой от
        # реально пустого слоя (мирроринг _grid_walk_category.layer_failed).
        if server_errors > 0 and ok_cells == 0 and first_server_error is not None:
            logger.warning(
                "get_features_in_bbox_grid: layer=%d grid=%dx%d ПОЛНОСТЬЮ сбойный "
                "(%d server errors, 0 ok cells) — raising вместо ложного пустого результата",
                layer_id,
                grid_n,
                grid_n,
                server_errors,
            )
            raise first_server_error

        logger.info(
            "get_features_in_bbox_grid: layer=%d grid=%dx%d unique_features=%d",
            layer_id,
            grid_n,
            grid_n,
            len(results),
        )
        return results

    async def get_territorial_zones_in_bbox(
        self,
        bbox: tuple[float, float, float, float],
        *,
        grid_n: int = 7,
    ) -> list[dict]:
        """Grid-walk WMS GetFeatureInfo для layer 875838 (ПЗЗ территориальные зоны).

        Returns: list of feature dicts с полями id, geometry, properties.
        Дедуплицирует по feature id.
        """
        return await self.get_features_in_bbox_grid(875838, bbox, grid_n=grid_n)

    # ── 4. list_objects_in_building ───────────────────────────────────────────
    # Q3 deferred — метод реализован, но не вызывается в bulk_harvest_quarter MVP.
    # Готов для per-building помещения/парковка фазы.

    async def list_objects_in_building(
        self,
        objdoc_id: int,
        registers_id: int = 36441,
    ) -> ObjectsListing:
        """Список помещений и машино-мест в здании.

        GET /api/geoportal/v1/tab-group-data?tabClass=objectsList
            &objdocId={objdoc_id}&registersId={registers_id}

        Response shape:
            {title: "Список объектов", object: [
                {title: "Помещения (количество)", value: ["264"]},
                {title: "Машино-места (количество)", value: ["4"]},
                {title: "Помещения (список)", value: [...cad_nums]},
                {title: "Машино-места (список)", value: [...cad_nums]},
            ]}

        NOTE: Q3 deferred — метод готов, но НЕ вызывается в PR 2/3 MVP.
        Использовать при реализации bulk_harvest_flats (отдельный task).

        Args:
            objdoc_id: NSPD internal object document ID (из NSPDOptions.objdoc_id).
            registers_id: NSPD registers ID (36441 — здания ЕГРН по умолчанию).

        Returns:
            ObjectsListing с counts и cad_nums списками.

        Raises:
            NspdBulkWafError при 403.
            NspdBulkError при прочих ошибках.
        """
        data = await self._get_json(
            NSPD_TAB_GROUP_URL,
            params={
                "tabClass": "objectsList",
                "objdocId": str(objdoc_id),
                "registersId": str(registers_id),
            },
        )

        flats_count = 0
        parking_count = 0
        flats_cad_nums: list[str] = []
        parking_cad_nums: list[str] = []

        obj_list: list[dict[str, Any]] = (data or {}).get("object") or []
        for entry in obj_list:
            title: str = (entry.get("title") or "").lower()
            values: list[Any] = entry.get("value") or []

            if "помещения" in title and "количество" in title:
                try:
                    flats_count = int(values[0]) if values else 0
                except (ValueError, TypeError):
                    flats_count = 0

            elif "машино-места" in title and "количество" in title:
                try:
                    parking_count = int(values[0]) if values else 0
                except (ValueError, TypeError):
                    parking_count = 0

            elif "помещения" in title and "список" in title:
                flats_cad_nums = [str(v) for v in values if v]

            elif "машино-места" in title and "список" in title:
                parking_cad_nums = [str(v) for v in values if v]

        return ObjectsListing(
            objdoc_id=objdoc_id,
            flats_count=flats_count,
            parking_count=parking_count,
            flats_cad_nums=flats_cad_nums,
            parking_cad_nums=parking_cad_nums,
        )


__all__ = [
    "NSPDBulkClient",
    "NspdBulkError",
    "NspdBulkRateLimitError",
    "NspdBulkServerError",
    "NspdBulkWafError",
]
