"""Клиент API Объектива (api.objctv.ru) — аналитика первички/вторички.

Architecture:
    1. GetToken: GET /Users/User/GetToken?apiKey=<KEY> → {"token": "..."}
       Bearer-токен короткоживущий (~30 мин), кешируется в Redis.
    2. GetReport: GET /v2/Report/GetReport?<params> с Authorization: Bearer <token>
       Возвращает JSON-отчёт по комбинации (Page, ReportSection, ReportType,
       ReportName, GroupName, [ComplexName], [StartDate], [EndDate],
       [UseDdu], [UseDkp]).

Структура отчётов (из их docs):
    - Page=Отчеты + ReportSection=Объединенные данные + ReportType={Сводные|Поквартирные}
      + ReportName={Корпуса|Лоты} = 4 канонические комбинации.
    - StartDate/EndDate в формате YYYY.MM.DD (точка-разделитель!).
    - UseDdu (default=true) / UseDkp (default=false) — тип сделок в свод-отчётах.

Защита от лимитов:
    - rate-limit: 500мс между запросами (наблюдаемая безопасная зона B2B-API)
    - 401 → re-authenticate token (TTL истёк или revoked)
    - 429 / 5xx → exponential backoff
    - Brotli по дефолту в Accept-Encoding (их рекомендация).
"""

from __future__ import annotations

import json
import logging
import time
from collections.abc import Iterator
from contextlib import contextmanager
from datetime import date, datetime
from typing import Any

import httpx
import redis

from app.core.config import settings

logger = logging.getLogger(__name__)

_TOKEN_REDIS_KEY = "objective:bearer_token"
_DEFAULT_TIMEOUT_S = 30
_DEFAULT_RATE_MS = 500
_DEFAULT_RETRIES = 3

# Process-локальный кеш токена — fallback на случай, когда Redis недоступен
# (локальный запуск скриптов вне docker, сетевой сбой). Tuple = (token, expires_at).
_TOKEN_MEMORY_CACHE: dict[str, tuple[str, float]] = {}


class ObjectiveAuthError(RuntimeError):
    """Не удалось получить/обновить Bearer-токен (401 на /GetToken)."""


class ObjectiveAPIError(RuntimeError):
    """Не-401 ошибка Объектив-API (4xx бизнес-логики или 5xx сервера)."""


class ObjectiveClient:
    """HTTP-клиент к api.objctv.ru с кешированием токена в Redis.

    Usage:
        client = ObjectiveClient()
        report = client.get_report(
            report_section="Объединенные данные",
            report_type="Сводные",
            report_name="Корпуса",
            group_name="Екатеринбург",
            start_date=date(2025, 5, 1),
            end_date=date(2025, 6, 1),
            use_ddu=True,
            use_dkp=True,
        )
    """

    def __init__(
        self,
        api_key: str | None = None,
        base_url: str | None = None,
        timeout_s: int = _DEFAULT_TIMEOUT_S,
        rate_ms: int = _DEFAULT_RATE_MS,
        retries: int = _DEFAULT_RETRIES,
    ) -> None:
        self.api_key = api_key or settings.objective_api_key
        if not self.api_key:
            raise ObjectiveAuthError("OBJECTIVE_API_KEY не задан (settings.objective_api_key пуст)")
        self.base_url = (base_url or settings.objective_base_url).rstrip("/")
        self.timeout_s = timeout_s
        self.rate_ms = rate_ms
        self.retries = retries
        # Brotli + JSON. httpx по умолчанию умеет br/gzip.
        self._client = httpx.Client(
            timeout=timeout_s,
            headers={
                "Accept": "application/json",
                "Accept-Encoding": "br, gzip",
                "User-Agent": "gendesign/1.0 (+https://gendsgn.ru)",
            },
        )
        self._redis = redis.Redis.from_url(settings.redis_url, decode_responses=True)
        self._last_request_ts: float = 0.0

    # ── токен ───────────────────────────────────────────────────────────────

    def _get_cached_token(self) -> str | None:
        try:
            val = self._redis.get(_TOKEN_REDIS_KEY)
            if val:
                return val
        except redis.RedisError as e:
            # Логируем один раз на инстанс — не спамить, локальный запуск это норма.
            if not getattr(self, "_redis_warned", False):
                logger.warning("Redis недоступен, fallback на in-memory кеш токена: %s", e)
                self._redis_warned = True
        # Fallback: in-memory.
        cached = _TOKEN_MEMORY_CACHE.get(_TOKEN_REDIS_KEY)
        if cached:
            token, expires_at = cached
            if time.time() < expires_at:
                return token
            _TOKEN_MEMORY_CACHE.pop(_TOKEN_REDIS_KEY, None)
        return None

    def _cache_token(self, token: str) -> None:
        ttl = settings.objective_token_ttl_seconds
        try:
            self._redis.set(_TOKEN_REDIS_KEY, token, ex=ttl)
        except redis.RedisError:
            pass  # уже залогировали в _get_cached_token
        # Всегда дублируем в память — на случай если Redis потеряется между set/get.
        _TOKEN_MEMORY_CACHE[_TOKEN_REDIS_KEY] = (token, time.time() + ttl)

    def _fetch_new_token(self) -> str:
        """GET /Users/User/GetToken?apiKey=<KEY>. Возвращает Bearer-токен."""
        url = f"{self.base_url}/Users/User/GetToken"
        params = {"apiKey": self.api_key}
        logger.info("Objective: запрос нового Bearer-токена")
        try:
            r = self._client.get(url, params=params)
        except httpx.HTTPError as e:
            raise ObjectiveAuthError(f"Сетевая ошибка GetToken: {e}") from e
        if r.status_code == 401:
            raise ObjectiveAuthError("apiKey не принят (401 на GetToken)")
        if r.status_code != 200:
            raise ObjectiveAuthError(f"GetToken HTTP {r.status_code}: {r.text[:200]}")
        data = r.json()
        # Ответ: {"token": "..."} либо {"Token": "..."} либо строка — нормализуем.
        if isinstance(data, str):
            token = data
        elif isinstance(data, dict):
            token = data.get("token") or data.get("Token") or data.get("accessToken")
        else:
            token = None
        if not token:
            raise ObjectiveAuthError(f"GetToken вернул неожиданный формат: {str(data)[:200]}")
        self._cache_token(token)
        return token

    def _get_token(self, force_refresh: bool = False) -> str:
        if not force_refresh:
            cached = self._get_cached_token()
            if cached:
                return cached
        return self._fetch_new_token()

    # ── HTTP общий ──────────────────────────────────────────────────────────

    def _wait_rate_limit(self) -> None:
        elapsed_ms = (time.time() - self._last_request_ts) * 1000.0
        if elapsed_ms < self.rate_ms:
            time.sleep((self.rate_ms - elapsed_ms) / 1000.0)
        self._last_request_ts = time.time()

    def _request_authed(
        self,
        path: str,
        params: dict[str, Any],
        *,
        attempt: int = 0,
        force_refresh_token: bool = False,
    ) -> Any:
        token = self._get_token(force_refresh=force_refresh_token)
        url = f"{self.base_url}{path}"
        self._wait_rate_limit()
        try:
            r = self._client.get(
                url,
                params=params,
                headers={"Authorization": f"Bearer {token}"},
            )
        except httpx.HTTPError as e:
            if attempt < self.retries:
                wait = min(2**attempt, 30)
                logger.warning(
                    "Objective net-error %s (attempt %d/%d), retry in %ds",
                    e,
                    attempt + 1,
                    self.retries,
                    wait,
                )
                time.sleep(wait)
                return self._request_authed(path, params, attempt=attempt + 1)
            raise ObjectiveAPIError(f"Сетевая ошибка после {self.retries} попыток: {e}") from e

        if r.status_code == 401:
            # TTL истёк или revoked. Один раз пробуем re-auth.
            if not force_refresh_token:
                logger.info("Objective: 401, рефрешим токен и повторяем")
                return self._request_authed(path, params, attempt=attempt, force_refresh_token=True)
            raise ObjectiveAuthError("401 даже после refresh токена — apiKey невалиден")

        if r.status_code in (429, 502, 503, 504):
            if attempt < self.retries:
                # Backoff: 30, 60, 120, 240… (потолок 300с) — Objective любит держать
                # 429 пока не пройдёт rate-window (~30с на дешёвых тарифах).
                # Если есть Retry-After — слушаем его (но капаем потолком).
                retry_after_hdr = r.headers.get("Retry-After")
                if retry_after_hdr and retry_after_hdr.isdigit():
                    wait = min(int(retry_after_hdr), 300)
                else:
                    wait = min(30 * (2**attempt), 300)
                logger.warning(
                    "Objective HTTP %s (attempt %d/%d), retry in %ds",
                    r.status_code,
                    attempt + 1,
                    self.retries,
                    wait,
                )
                time.sleep(wait)
                return self._request_authed(path, params, attempt=attempt + 1)
            raise ObjectiveAPIError(f"HTTP {r.status_code} после ретраев: {r.text[:200]}")

        if r.status_code != 200:
            raise ObjectiveAPIError(f"HTTP {r.status_code}: {r.text[:500]}")

        try:
            return r.json()
        except json.JSONDecodeError as e:
            raise ObjectiveAPIError(
                f"Невалидный JSON: {e}; первые 200 символов: {r.text[:200]}"
            ) from e

    def _open_stream(
        self,
        path: str,
        params: dict[str, Any],
        *,
        attempt: int = 0,
        force_refresh_token: bool = False,
    ) -> httpx.Response:
        """Открывает streaming GET, возвращает httpx.Response (не читает тело).

        Включает retry/auth логику идентичную _request_authed().
        Caller обязан держать контекст через `with self._client.stream(...)` —
        вызов делается внутри stream_report() который гарантирует закрытие.
        """
        token = self._get_token(force_refresh=force_refresh_token)
        url = f"{self.base_url}{path}"
        self._wait_rate_limit()
        try:
            r = self._client.send(
                self._client.build_request(
                    "GET",
                    url,
                    params=params,
                    headers={"Authorization": f"Bearer {token}"},
                ),
                stream=True,
            )
        except httpx.HTTPError as e:
            if attempt < self.retries:
                wait = min(2**attempt, 30)
                logger.warning(
                    "Objective stream net-error %s (attempt %d/%d), retry in %ds",
                    e,
                    attempt + 1,
                    self.retries,
                    wait,
                )
                time.sleep(wait)
                return self._open_stream(path, params, attempt=attempt + 1)
            raise ObjectiveAPIError(
                f"Сетевая ошибка stream после {self.retries} попыток: {e}"
            ) from e

        if r.status_code == 401:
            r.close()
            if not force_refresh_token:
                logger.info("Objective stream: 401, рефрешим токен и повторяем")
                return self._open_stream(path, params, attempt=attempt, force_refresh_token=True)
            raise ObjectiveAuthError("401 даже после refresh токена — apiKey невалиден")

        if r.status_code in (429, 502, 503, 504):
            retry_after_hdr = r.headers.get("Retry-After")
            r.close()
            if attempt < self.retries:
                if retry_after_hdr and retry_after_hdr.isdigit():
                    wait = min(int(retry_after_hdr), 300)
                else:
                    wait = min(30 * (2**attempt), 300)
                logger.warning(
                    "Objective stream HTTP %s (attempt %d/%d), retry in %ds",
                    r.status_code,
                    attempt + 1,
                    self.retries,
                    wait,
                )
                time.sleep(wait)
                return self._open_stream(path, params, attempt=attempt + 1)
            raise ObjectiveAPIError(f"Stream HTTP {r.status_code} после ретраев")

        if r.status_code != 200:
            body_preview = r.read()[:200]
            r.close()
            raise ObjectiveAPIError(f"Stream HTTP {r.status_code}: {body_preview!r}")

        return r

    @contextmanager
    def stream_report(
        self,
        *,
        report_section: str = "Объединенные данные",
        report_type: str = "Поквартирные",
        report_name: str = "Лоты",
        group_name: str | None = None,
        complex_name: str | None = None,
        start_date: date | str | None = None,
        end_date: date | str | None = None,
        use_ddu: bool | None = True,
        use_dkp: bool | None = None,
        page: str = "Отчеты",
        v2: bool = True,
    ) -> Iterator[httpx.Response]:
        """Контекст-менеджер для streaming GetReport.

        Usage:
            with client.stream_report(report_type='Поквартирные', report_name='Лоты',
                                       group_name='Свердловская область') as resp:
                for chunk in resp.iter_bytes(65536):
                    ...

        Caller итерирует тело через resp.iter_bytes() или resp.iter_raw().
        Метод НЕ вызывает .json() — всё чтение на стороне caller'а.
        """
        path = "/v2/Report/GetReport" if v2 else "/Report/GetReport"
        params: dict[str, Any] = {
            "Page": page,
            "ReportSection": report_section,
            "ReportType": report_type,
            "ReportName": report_name,
            "GroupName": group_name or settings.objective_default_group,
        }
        if complex_name:
            params["ComplexName"] = complex_name
        if start_date is not None:
            params["StartDate"] = self._fmt_date(start_date)
        if end_date is not None:
            params["EndDate"] = self._fmt_date(end_date)
        if v2:
            if use_ddu is not None:
                params["UseDdu"] = "true" if use_ddu else "false"
            if use_dkp is not None:
                params["UseDkp"] = "true" if use_dkp else "false"

        logger.info(
            "Objective.stream_report: %s/%s/%s gr=%s cn=%s ddu=%s dkp=%s",
            report_section,
            report_type,
            report_name,
            params["GroupName"],
            complex_name,
            use_ddu,
            use_dkp,
        )
        resp = self._open_stream(path, params)
        try:
            yield resp
        finally:
            try:
                resp.close()
            except Exception:
                pass

    # ── публичные методы отчётов ────────────────────────────────────────────

    @staticmethod
    def _fmt_date(d: date | str | None) -> str | None:
        if d is None:
            return None
        if isinstance(d, str):
            return d  # ожидаем уже YYYY.MM.DD
        return d.strftime("%Y.%m.%d")

    def get_report(
        self,
        *,
        report_section: str = "Объединенные данные",
        report_type: str = "Сводные",
        report_name: str = "Корпуса",
        group_name: str | None = None,
        complex_name: str | None = None,
        start_date: date | str | None = None,
        end_date: date | str | None = None,
        use_ddu: bool | None = True,
        use_dkp: bool | None = None,
        page: str = "Отчеты",
        v2: bool = True,
    ) -> Any:
        """Универсальный вызов GetReport. Возвращает распарсенный JSON.

        Args:
            report_section: например "Объединенные данные"
            report_type: "Сводные" | "Поквартирные"
            report_name: "Корпуса" | "Лоты" | др.
            group_name: "Екатеринбург" (default из settings)
            complex_name: фильтр по конкретному ЖК (опционально)
            start_date / end_date: для Сводных — обязательны
            use_ddu / use_dkp: только для v2 (новый API)
            page: всегда "Отчеты"
            v2: True → /v2/Report/GetReport (с UseDdu/UseDkp), False → /Report/GetReport
        """
        path = "/v2/Report/GetReport" if v2 else "/Report/GetReport"
        params: dict[str, Any] = {
            "Page": page,
            "ReportSection": report_section,
            "ReportType": report_type,
            "ReportName": report_name,
            "GroupName": group_name or settings.objective_default_group,
        }
        if complex_name:
            params["ComplexName"] = complex_name
        if start_date is not None:
            params["StartDate"] = self._fmt_date(start_date)
        if end_date is not None:
            params["EndDate"] = self._fmt_date(end_date)
        if v2:
            if use_ddu is not None:
                params["UseDdu"] = "true" if use_ddu else "false"
            if use_dkp is not None:
                params["UseDkp"] = "true" if use_dkp else "false"

        logger.info(
            "Objective.get_report: %s/%s/%s gr=%s cn=%s [%s..%s] ddu=%s dkp=%s",
            report_section,
            report_type,
            report_name,
            params["GroupName"],
            complex_name,
            params.get("StartDate"),
            params.get("EndDate"),
            use_ddu,
            use_dkp,
        )
        return self._request_authed(path, params)

    # ── удобные обёртки под наши 4 канонические отчёта ──────────────────────

    def report_corpuses_summary(
        self,
        *,
        group_name: str | None = None,
        complex_name: str | None = None,
        start_date: date | None = None,
        end_date: date | None = None,
        use_ddu: bool = True,
        use_dkp: bool = True,
    ) -> Any:
        """Объединенные данные / Сводные / Корпуса — агрегаты per oks за период."""
        return self.get_report(
            report_section="Объединенные данные",
            report_type="Сводные",
            report_name="Корпуса",
            group_name=group_name,
            complex_name=complex_name,
            start_date=start_date,
            end_date=end_date,
            use_ddu=use_ddu,
            use_dkp=use_dkp,
        )

    def report_lots_per_flat(
        self,
        *,
        group_name: str | None = None,
        complex_name: str | None = None,
        use_ddu: bool = True,
        use_dkp: bool = True,
    ) -> Any:
        """Объединенные данные / Поквартирные / Лоты — каждая квартира с историей."""
        return self.get_report(
            report_section="Объединенные данные",
            report_type="Поквартирные",
            report_name="Лоты",
            group_name=group_name,
            complex_name=complex_name,
            use_ddu=use_ddu,
            use_dkp=use_dkp,
        )

    def report_lots_summary(
        self,
        *,
        group_name: str | None = None,
        complex_name: str | None = None,
        start_date: date | None = None,
        end_date: date | None = None,
        use_ddu: bool = True,
        use_dkp: bool = True,
    ) -> Any:
        """Объединенные данные / Сводные / Лоты — агрегаты per ЖК (mix-структура)."""
        return self.get_report(
            report_section="Объединенные данные",
            report_type="Сводные",
            report_name="Лоты",
            group_name=group_name,
            complex_name=complex_name,
            start_date=start_date,
            end_date=end_date,
            use_ddu=use_ddu,
            use_dkp=use_dkp,
        )

    def report_corpuses_per_flat(
        self,
        *,
        group_name: str | None = None,
        complex_name: str | None = None,
        use_ddu: bool = True,
        use_dkp: bool = True,
    ) -> Any:
        """Объединенные данные / Поквартирные / Корпуса — детально по корпусам."""
        return self.get_report(
            report_section="Объединенные данные",
            report_type="Поквартирные",
            report_name="Корпуса",
            group_name=group_name,
            complex_name=complex_name,
            use_ddu=use_ddu,
            use_dkp=use_dkp,
        )

    # ── lifecycle ───────────────────────────────────────────────────────────

    def close(self) -> None:
        try:
            self._client.close()
        except Exception:
            pass

    def __enter__(self) -> ObjectiveClient:
        return self

    def __exit__(self, *_exc: Any) -> None:
        self.close()


# ── удобный one-shot helper ─────────────────────────────────────────────────


def fetch_with_raw_log(
    fn_name: str,
    *,
    save_dir: str | None = None,
    **kwargs: Any,
) -> tuple[Any, str | None]:
    """Вызывает client.<fn_name>(**kwargs), сохраняет raw JSON в файл если задан save_dir.
    Возвращает (data, file_path or None). Удобно для smoke-сценариев и backfill."""
    from pathlib import Path

    client = ObjectiveClient()
    try:
        method = getattr(client, fn_name)
        data = method(**kwargs)
    finally:
        client.close()

    file_path: str | None = None
    if save_dir:
        Path(save_dir).mkdir(parents=True, exist_ok=True)
        ts = datetime.utcnow().strftime("%Y%m%d_%H%M%S")
        slug = (
            "_".join(str(v) for k, v in kwargs.items() if k in ("group_name", "complex_name") and v)
            or "all"
        )
        slug = slug.replace(" ", "_").replace("/", "_")
        file_path = f"{save_dir}/objective_{fn_name}_{slug}_{ts}.json"
        Path(file_path).write_text(
            json.dumps(data, ensure_ascii=False, indent=2),
            encoding="utf-8",
        )
    return data, file_path
