"""Protocol-контракты (границы) между scraper_kit и продуктовым backend'ом.

Скрапперы (после переноса в scraper_kit, шаги C-G #2131) не должны напрямую
импортировать `app.*`. Вместо этого они принимают инжектируемые зависимости,
описанные здесь через `typing.Protocol` (structural typing): продуктовая сторона
предоставляет конкретные реализации (см. `app.services.scraper_adapters`),
которые структурно удовлетворяют этим Protocol'ам.

ВАЖНО: этот модуль НЕ импортирует `app.*` и не тянет тяжёлых зависимостей в
рантайме. Тип `Session` (SQLAlchemy) используется только в аннотациях под
`TYPE_CHECKING` — благодаря `from __future__ import annotations` аннотации не
вычисляются при импорте, поэтому пакет остаётся standalone-импортируемым.

Сигнатуры Protocol'ов зафиксированы по факту из backend-кода:
  - HouseMatcher     ← app.services.matching.houses.match_or_create_house
                       + app.services.matching.listings.upsert_listing_source
  - ScraperConfig    ← поля app.core.config.Settings, читаемые скрапперами
  - SessionFactory   ← app.core.db.SessionLocal (sessionmaker → Session)
  - EnrichmentJobs   ← app.services.house_imv_backfill / house_dedup_merge /
                       yandex_address_backfill
"""

from __future__ import annotations

from collections.abc import Callable
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable

if TYPE_CHECKING:
    from sqlalchemy.orm import Session


@dataclass(frozen=True)
class ProxyLease:
    """Арендованный из пула прокси (лёгкий транспорт между kit и backend).

    Структурно совпадает с `app.services.proxy_pool.ProxyLease` (id/url/kind/rotate_url),
    но живёт в контрактах, чтобы kit не импортировал `app.*`. Провайдеры читают только
    `url` (готов для curl_cffi `proxies=`); `id`/`rotate_url` нужны backend-адаптеру для
    release / mark_health / (будущей) ротации.
    """

    id: int
    url: str
    kind: str
    rotate_url: str | None = None


@runtime_checkable
class HouseMatcher(Protocol):
    """Кросс-источниковый матчинг домов/листингов (продуктовая логика matching-сервиса).

    Реализуется `app.services.scraper_adapters.RealMatcherAdapter`, который проксирует
    в `app.services.matching`. Скрапперы вызывают методы вместо прямого импорта.
    """

    def match_or_create_house(
        self,
        db: Session,
        ext_source: str,
        ext_id: str,
        address: str | None = ...,
        lat: float | None = ...,
        lon: float | None = ...,
        *,
        year_built: int | None = ...,
        building_cadastral_number: str | None = ...,
        cadastral_number: str | None = ...,
        source_url: str | None = ...,
    ) -> tuple[int | None, float, str]:
        """Найти или создать канонический дом.

        Returns:
            (house_id, confidence ∈ [0.0, 1.0], method), где method ∈ {
                'cadastr_exact', 'source_exact', 'fingerprint', 'geo_proximity', 'new',
                'no_house_number'
            }.
            house_id=None только при method='no_house_number' — matcher отказался
            создавать дом из безномерного адреса без кадастра (P1). Вызывающий обязан
            обработать None.
        """
        ...

    def upsert_listing_source(
        self,
        db: Session,
        *,
        listing_id: int,
        ext_source: str,
        ext_id: str,
        method: str,
        confidence: float,
        price_rub: float | None = ...,
        area_m2: float | None = ...,
        floor: int | None = ...,
        rooms_count: int | None = ...,
        source_url: str | None = ...,
        source_data: dict[str, Any] | None = ...,
    ) -> None:
        """Зарегистрировать/обновить строку listing_sources для (ext_source, ext_id)."""
        ...


@runtime_checkable
class ScraperConfig(Protocol):
    """Конфигурация, читаемая скрапперами из `app.core.config.settings`.

    Поля собраны grep'ом `settings.<name>` по `app/services/scrapers/`. Типы —
    как в `Settings` (proxy-свойства возвращают `str | None`). Реализуется
    `app.services.scraper_adapters.RealScraperConfig` (проксирует в singleton
    `settings`); pydantic-`Settings` также структурно удовлетворяет этому Protocol.
    """

    # Режим загрузки: "curl_cffi" | "browser".
    scraper_fetch_mode: str
    # HTTP-эндпоинт headless-браузера (tradein-browser).
    browser_http_endpoint: str
    # Backconnect-прокси общий (Avito/Cian fallback).
    scraper_proxy_url: str | None
    # Avito rotate-прокси + лимит ротаций на прогон.
    avito_proxy_rotate_url: str | None
    avito_proxy_max_rotations: int
    # Ограничивать Avito SERP только ЕКБ.
    avito_serp_ekb_only: bool
    # Yandex rotate-прокси.
    yandex_proxy_rotate_url: str | None
    # Cian-прокси (CIAN_PROXY_URL > scraper_proxy_url fallback).
    cian_proxy_url: str | None
    # Границы валидности cian-оценки, руб.
    cian_valuation_min_rub: float
    cian_valuation_max_rub: float
    # Секрет pgp_sym_encrypt для cian_session_cookies (read в session.py).
    cookie_encryption_key: str
    # Sentry/GlitchTip DSN (опционально).
    glitchtip_dsn: str | None
    # ── Оркестрация sweep-pipeline (#2135) ───────────────────────────────────
    # Поля, читаемые слоем оркестрации (scraper_kit.orchestration.pipeline).
    # Собраны grep'ом `settings.<name>` по app/services/scrape_pipeline.py.
    # Проксируются той же продуктовой реализацией (RealScraperConfig).
    #
    # Partial-ban intake: SERP уже собрал лоты, а detail/houses заблокировало —
    # run помечается 'done' (не 'banned'), intake сохранён (#1950).
    avito_serp_ok_not_banned: bool
    # changeip settle-пауза (секунды) после ротации IP.
    avito_proxy_rotate_settle_s: float
    # Ретраи changeip-GET: N попыток по proxy_rotate_attempt_timeout_s каждая (#1950).
    proxy_rotate_attempts: int
    proxy_rotate_attempt_timeout_s: float
    # Cian rotate-прокси + лимит ротаций.
    cian_proxy_rotate_url: str | None
    cian_proxy_max_rotations: int
    # Yandex лимит ротаций (rotate_url уже выше).
    yandex_proxy_max_rotations: int
    # Пропускать листинги, уже обновлённые сегодня по МСК (full-load дедуп).
    scraper_skip_seen_today: bool
    # Per-fetch watchdog-таймаут Cian full-load (секунды).
    cian_full_load_per_fetch_timeout_s: float
    # ── Proxy-pool (#2163) ────────────────────────────────────────────────────
    # Feature-флаг: брать прокси из пула (scrape_proxies) на curl_cffi-путях вместо
    # env-прокси (config.*_proxy_url). Ship-dark: дефолт False → curl-пути ходят как
    # сейчас. True + пустой пул → fallback на env (сбор не ломается). См. ProxyProvider.
    use_proxy_pool_curl: bool
    # Feature-флаг (#2164 P4): брать прокси из пула на browser-путях (BrowserFetcher →
    # POST /fetch{proxy}). Scraper-сторона делает acquire(source) и передаёт lease.url в
    # теле запроса; tradein-browser релончит camoufox с этим прокси (relaunch ТОЛЬКО при
    # реальной смене). Ship-dark: дефолт False → браузер берёт прокси из env
    # (BROWSER_PROXY_*), прод не меняется. True + пустой пул → fallback на env.
    use_proxy_pool_browser: bool


@runtime_checkable
class ProxyProvider(Protocol):
    """Источник прокси из пула для curl-путей (#2163), за флагом use_proxy_pool_curl.

    Инкапсулирует lease/health-механику `app.services.proxy_pool` (которая требует
    `Session`), пряча БД от kit'а. Реализуется
    `app.services.scraper_adapters.RealProxyProvider` (открывает короткую сессию на
    операцию). Провайдеры вызывают `acquire` → используют `lease.url` → на выходе
    `mark_health` + `release` (в finally — lease не должен течь).

    Контракт ship-dark/fallback: `acquire` возвращает None если свободных здоровых
    прокси нет — caller обязан откатиться на env-прокси, а НЕ падать.
    """

    def acquire(self, provider: str) -> ProxyLease | None:
        """Взять прокси под провайдера (avito/cian/yandex/generic/any).

        Returns ProxyLease или None если пул пуст/выключен → caller делает env-fallback.
        """
        ...

    def release(self, lease: ProxyLease) -> None:
        """Освободить lease (идемпотентно). Вызывать в finally — иначе прокси течёт."""
        ...

    def mark_health(
        self,
        lease: ProxyLease,
        ok: bool,
        *,
        exit_ip: str | None = ...,
        latency_ms: int | None = ...,
    ) -> None:
        """Записать исход использования прокси (ok=True успех, False бан/ошибка)."""
        ...


@runtime_checkable
class SessionFactory(Protocol):
    """Фабрика SQLAlchemy-сессии (аналог `app.core.db.SessionLocal`).

    Вызов без аргументов возвращает новую `Session`. Реализуется
    `app.services.scraper_adapters.RealSessionFactory`; сам `sessionmaker`
    (SessionLocal) также структурно удовлетворяет.
    """

    def __call__(self) -> Session:
        """Открыть новую сессию БД."""
        ...


@runtime_checkable
class EnrichmentJobs(Protocol):
    """Реестр enrichment-джоб, дёргаемых после сохранения скрапом.

    Callback'и проксируют в соответствующие `app.services.*`. Возвращаемые
    доменные результат-типы (`HouseIMVBackfillResult`, `YandexAddressBackfillResult`)
    остаются `Any`, чтобы не тянуть `app.*` в контракты. Реализуется
    `app.services.scraper_adapters.RealEnrichmentJobs`.
    """

    async def house_imv_backfill(
        self,
        db: Session,
        *,
        batch_size: int = ...,
        request_delay_sec: float = ...,
        only_status: str = ...,
        house_id: int | None = ...,
        heartbeat: Callable[[], None] | None = ...,
    ) -> Any:
        """Avito IMV-оценка для домов в scope. Проксирует backfill_house_imv."""
        ...

    def house_dedup_merge(
        self,
        db: Session,
        *,
        run_id: int,
        params: dict[str, Any],
    ) -> dict[str, int]:
        """Слить дубли домов. Проксирует run_house_dedup_merge. Returns счётчики."""
        ...

    async def yandex_address_backfill(
        self,
        db: Session,
        *,
        limit: int = ...,
        request_delay_sec: float = ...,
    ) -> Any:
        """Догрузить точные адреса Yandex. Проксирует backfill_yandex_addresses."""
        ...

    async def process_houses_imv_batch(
        self,
        db: Session,
        house_ids: set[int],
        *,
        request_delay_sec: float = ...,
        heartbeat: Callable[[], None] | None = ...,
    ) -> Any:
        """Avito IMV-оценка явного набора house_id (финальная IMV-фаза city-sweep).

        Проксирует process_houses_imv_batch. Возвращаемый результат имеет поля
        .checked/.saved/.errors (тип остаётся Any — не тянем app.* в контракты).
        """
        ...

    # ── Yandex sweep-хелперы (#2135 F2) ──────────────────────────────────────
    # Продуктовая логика, дёргаемая yandex city/full sweep-оркестраторами напрямую
    # (не через отдельные backfill-джобы). Инжектируются вместо прямых импортов
    # app.services.yandex_price_history / yandex_address_backfill.

    def record_yandex_price_history(self, db: Session, lots: list[Any]) -> int:
        """Записать price-history из gate price.previous/trend. Проксирует
        record_yandex_price_history. Returns число вставленных строк истории.

        `lots` — список kit-`ScrapedLot` (тип оставлен `Any`, чтобы не тянуть
        доменный тип в контракт). Читается duck-typing'ом (source_id/price/…).
        """
        ...

    def extract_address_from_title(self, html: str) -> str | None:
        """Извлечь полный адрес из <title> detail-страницы Yandex.

        Проксирует `_extract_address_from_title` (yandex_address_backfill).
        Returns адрес с номером дома или None если не распарсилось.
        """
        ...

    def address_has_house_number(self, address: str) -> bool:
        """True если адрес содержит номер дома (`,\\s*\\d+`).

        Проксирует regex `_RE_HAS_HOUSE_NUMBER` из yandex_address_backfill —
        gate для решения, обновлять ли address на обогащённый.
        """
        ...


__all__ = [
    "EnrichmentJobs",
    "HouseMatcher",
    "ProxyLease",
    "ProxyProvider",
    "ScraperConfig",
    "SessionFactory",
]
