"""Клиент для поиска и скачивания постановлений с pravo.gov66.ru (#1062).

Сайт pravo.gov66.ru — реестр правовых актов Правительства Свердловской области.
HTML server-rendered (нет JS-рендеринга), httpx достаточно.

TLS: сайт использует российский УЦ → verify=False (аналогично ekburg_permits #242).

Politeness (#2445 D4): пауза ``_RATE_S`` перед каждым запросом (search / карточка
документа / PDF) — тот же идиом, что nspd_client/objective/gisogd66 (300-500мс). Этот
VPS уже словил WAF-бан от DOM.РФ 24.05.2026 (#2443) за непейсованные последовательные
запросы; pravo.gov66.ru — сайт той же формы (гос-портал region, строгий WAF). Ретрай с
backoff на 429/5xx — идиом gisogd66._get_json.

Публичный API:
    search_documents(query, law_type, since) → list[dict]
    get_document_pdf_url(doc_id) → str | None
    fetch_pdf(url) → bytes
"""

from __future__ import annotations

import logging
import re
import time
from typing import Any

import httpx
from bs4 import BeautifulSoup

logger = logging.getLogger(__name__)

_BASE_URL = "https://www.pravo.gov66.ru"
_SEARCH_URL = f"{_BASE_URL}/search/"

# Тайм-аут щедрый: сервер иногда медленный.
_TIMEOUT = httpx.Timeout(60.0, connect=15.0)

# Politeness pacing (#2445 D4): пауза ПЕРЕД каждым запросом к pravo.gov66.ru — тот же
# идиом (300-500мс), что nspd_client/objective/gisogd66. Не непейсованный WAF-риск,
# аналогичный DOM.РФ-бану #2443.
_RATE_S = 0.4
# Ретраи на 429/5xx: экспоненциальный backoff, суммарно до 1 + _MAX_RETRIES попыток.
_MAX_RETRIES = 2
_RETRY_BACKOFF_S = 2.0

# Числовые коды типов документов (select#id_law_type на сайте).
# Текстовое значение «Постановление» — неверный параметр, сервер не матчит → 0 хитов.
_LAW_TYPE_POSTANOVLENIE = "1"  # Постановление Правительства Свердловской области

# Паттерны для извлечения ссылок.
# Числовой doc_id в пути: /35496/ (НЕ /documents/35496/ — у сайта такой структуры нет).
_RE_DOC_PATH = re.compile(r"^/(\d+)/$")
# PDF в /media/pravo/ (не ecp-вариант: ecp — подписанная копия, не всегда парсится).
_RE_PDF_PATH = re.compile(r"/media/pravo/(?!ecp/)[^\s\"']+\.pdf", re.IGNORECASE)
# ecp-вариант как fallback.
_RE_PDF_ECP_PATH = re.compile(r"/media/pravo/ecp/[^\s\"']+\.pdf", re.IGNORECASE)


def _get_with_backoff(
    client: httpx.Client,
    url: str,
    *,
    params: dict[str, str] | None = None,
) -> httpx.Response:
    """GET с politeness-паузой перед запросом + backoff-ретраем на 429/5xx.

    Пауза ``_RATE_S`` — ПЕРЕД каждой попыткой (в т.ч. первой): пейсинг
    последовательных запросов к pravo.gov66.ru, не только ретраев (#2445 D4).
    429/5xx ретраятся до ``_MAX_RETRIES`` раз с экспоненциальным backoff;
    прочие статусы (включая 4xx кроме 429) — сразу ``raise_for_status()``.
    """
    last_exc: Exception | None = None
    for attempt in range(_MAX_RETRIES + 1):
        time.sleep(_RATE_S)
        resp = client.get(url, params=params)
        if resp.status_code == 429 or resp.status_code >= 500:
            last_exc = httpx.HTTPStatusError(
                f"{resp.status_code} on {url}", request=resp.request, response=resp
            )
            if attempt >= _MAX_RETRIES:
                raise last_exc
            logger.warning(
                "pravo_gov66_client: %s (попытка %d) на %s — backoff",
                resp.status_code,
                attempt + 1,
                url,
            )
            time.sleep(_RETRY_BACKOFF_S * (attempt + 1))
            continue
        resp.raise_for_status()
        return resp
    assert last_exc is not None
    raise last_exc


def search_documents(
    query: str,
    law_type: str = _LAW_TYPE_POSTANOVLENIE,
    since: str | None = None,
) -> list[dict[str, Any]]:
    """Поиск документов на pravo.gov66.ru.

    Args:
        query: поисковый запрос (2-3 слова; 1 или 4+ слов → 0 результатов на сервере).
        law_type: числовой код типа документа (см. select#id_law_type). По умолчанию
            ``_LAW_TYPE_POSTANOVLENIE="1"`` (Постановление). НЕ передавать текст вроде
            «Постановление» — сервер игнорирует нечисловые коды → 0 хитов.
        since: дата начала периода в формате «DD.MM.YYYY» (поле signed_start_date).

    Returns:
        Список словарей {'doc_id': str, 'title': str, 'url': str}.
        Пустой список при любой сетевой ошибке.
    """
    params: dict[str, str] = {"q": query, "law_type": law_type}
    if since:
        params["signed_start_date"] = since

    try:
        with httpx.Client(timeout=_TIMEOUT, verify=False, follow_redirects=True) as client:
            resp = _get_with_backoff(client, _SEARCH_URL, params=params)
            html = resp.text
    except Exception as exc:
        logger.error("pravo_gov66_client.search_documents: ошибка сети q=%r: %s", query, exc)
        return []

    return _parse_search_html(html)


def _parse_search_html(html: str) -> list[dict[str, Any]]:
    """Извлекает doc_id / title / url из HTML страницы поиска."""
    soup = BeautifulSoup(html, "html.parser")
    results: list[dict[str, Any]] = []
    seen_ids: set[str] = set()

    for a_tag in soup.find_all("a", href=True):
        href: str = a_tag["href"]
        m = _RE_DOC_PATH.match(href)
        if not m:
            continue
        doc_id = m.group(1)
        if doc_id in seen_ids:
            continue
        seen_ids.add(doc_id)
        title = a_tag.get_text(separator=" ", strip=True)
        results.append(
            {
                "doc_id": doc_id,
                "title": title,
                "url": f"{_BASE_URL}{href}",
            }
        )

    logger.debug("_parse_search_html: найдено %d документов", len(results))
    return results


def get_document_pdf_url(doc_id: str) -> str | None:
    """Получает прямую ссылку на PDF со страницы документа.

    Предпочитает /media/pravo/ (не ecp); если не найдена — берёт ecp-вариант.

    Args:
        doc_id: числовой идентификатор документа (из пути /{doc_id}/).

    Returns:
        Абсолютный URL PDF или None если не найдена / сетевая ошибка.
    """
    doc_url = f"{_BASE_URL}/{doc_id}/"
    try:
        with httpx.Client(timeout=_TIMEOUT, verify=False, follow_redirects=True) as client:
            resp = _get_with_backoff(client, doc_url)
            html = resp.text
    except Exception as exc:
        logger.error(
            "pravo_gov66_client.get_document_pdf_url: ошибка загрузки doc_id=%s: %s",
            doc_id,
            exc,
        )
        return None

    return _extract_pdf_url(html)


def _extract_pdf_url(html: str) -> str | None:
    """Извлекает URL PDF из HTML страницы документа."""
    # Приоритет: /media/pravo/ (не ecp).
    m = _RE_PDF_PATH.search(html)
    if m:
        path = m.group(0)
        return path if path.startswith("http") else f"{_BASE_URL}{path}"

    # Fallback: ecp-вариант.
    m2 = _RE_PDF_ECP_PATH.search(html)
    if m2:
        path2 = m2.group(0)
        return path2 if path2.startswith("http") else f"{_BASE_URL}{path2}"

    return None


def fetch_pdf(url: str) -> bytes:
    """Загружает PDF по URL.

    Пейсинг + backoff на 429/5xx через ``_get_with_backoff`` (#2445 D4) — caller
    (reservation_ingest) НЕ должен добавлять свой sleep перед этим вызовом, пауза
    уже внутри клиента.

    Args:
        url: абсолютный URL PDF-файла.

    Returns:
        Байты PDF.

    Raises:
        httpx.HTTPError: при сетевой ошибке (caller должен обработать gracefully).
    """
    with httpx.Client(timeout=_TIMEOUT, verify=False, follow_redirects=True) as client:
        resp = _get_with_backoff(client, url)
        return resp.content


__all__ = [
    "_LAW_TYPE_POSTANOVLENIE",
    "fetch_pdf",
    "get_document_pdf_url",
    "search_documents",
]
