"""CBR (Банк России) macro indicators scraper — key rate + inflation (ИПЦ YoY).

Fills two ``macro_indicator`` series (both region='rf', source='cbr'), детерминированно,
без LLM:

  • ``key_rate``      — ключевая ставка ЦБ, ДНЕВНОЙ ряд, через SOAP DailyInfoWebServ.
  • ``inflation_yoy`` — инфляция «% г/г» (year-over-year, на базе ИПЦ Росстата),
                        МЕСЯЧНЫЙ ряд, через CBR UniDbQuery DownloadExcel (#946).

#946 / ИПЦ-вход: путь fedstat ИПЦ заблокирован reCAPTCHA (dataGrid, см.
``rosstat_emiss``). ЦБ публикует инфляцию ОТКРЫТО (НЕ за WAF, работает с dev напрямую)
на ``https://www.cbr.ru/hd_base/infl/`` — таблица «Дата | Ключевая ставка |
Инфляция, % г/г | Цель по инфляции». Чистый машиночитаемый источник за этой
страницей — её Excel-экспорт UniDbQuery (HTML-таблица рендерит только последние ~6
месяцев и НЕ реагирует на GET-параметр диапазона, поэтому для бэкфилла берём Excel).

Endpoint / method (KEY RATE — verified live 2026-06-02):
    POST https://www.cbr.ru/DailyInfoWebServ/DailyInfo.asmx
    SOAP 1.2 action ``KeyRateXML(fromDate, ToDate)``.

    ⚠ Параметры называются ``fromDate`` и ``ToDate`` (вторая — с ЗАГЛАВНОЙ T,
    так в WSDL). Оба типа ``xsd:dateTime`` — нужен суффикс времени
    ``T00:00:00``; голый ``YYYY-MM-DD`` сервис молча игнорит и возвращает
    пустой ``KeyRateXMLResponse``. (Поэтому плоский GET-form
    ``/KeyRate?fromDate=..&toDate=..`` тоже не работает — у DailyInfoWebServ нет
    HTTP-GET binding для этого метода, только SOAP.)

Response shape (``KeyRateXML`` — без diffgram-обёртки, в отличие от ``KeyRate``):
    <KeyRateXMLResult>
      <KeyRate>
        <KR><DT>2023-08-15T00:00:00+03:00</DT><Rate>12.00</Rate></KR>
        ...
      </KeyRate>
    </KeyRateXMLResult>
Строки идут в обратном хронологическом порядке; ``parse_key_rate_xml``
сортировку не навязывает — отдаёт в порядке появления.

Endpoint / method (INFLATION — verified live 2026-06-07/08):
    GET https://www.cbr.ru/Queries/UniDbQuery/DownloadExcel/132934
        ?FromDate=MM/DD/YYYY&ToDate=MM/DD/YYYY&posted=False
    Возвращает .xlsx (openpyxl-читаемый): колонки
        «Дата (MM.YYYY) | Ключевая ставка, % годовых | Инфляция, % г/г | Цель …».
    Берём ТОЛЬКО колонку «Инфляция, % г/г» (year-over-year %). Полная история
    01.2013→текущий месяц; FromDate/ToDate точно ограничивают диапазон. Запятая —
    десятичный разделитель в HTML, но openpyxl отдаёт нативный float. Ранние строки
    (до 09.2013) несут пустую ставку, но инфляция есть всегда.
    NB: id=132934 — стабильный id ряда «Инфляция и ключевая ставка» (взят из
    ссылки DownloadExcel на самой странице /hd_base/infl/). Если ЦБ его сменит —
    fetch вернёт не-200 / пустой парс → task залогирует и пробросит (видно в GlitchTip).

Парсинг вынесен в чистые ``parse_key_rate_xml`` / ``parse_inflation_xlsx``
(offline-тестируемые на фикстурах); HTTP-обвязки ``fetch_key_rate`` /
``fetch_inflation`` тонкие (httpx, timeout, UA, retry).

Standalone smoke (для ручной проверки против живого cbr.ru):
    python -m app.services.scrapers.cbr_macro

Mirror conventions: httpx.Client + явный User-Agent (как objective.py /
ekburg_permits.py), logger вместо print, типизированные сигнатуры.
"""

from __future__ import annotations

import logging
from datetime import date, datetime, timedelta
from decimal import Decimal, InvalidOperation
from io import BytesIO
from typing import Any
from xml.etree import ElementTree as ET

import httpx
from openpyxl import load_workbook

logger = logging.getLogger(__name__)

# ── constants ────────────────────────────────────────────────────────────────

CBR_DAILYINFO_URL = "https://www.cbr.ru/DailyInfoWebServ/DailyInfo.asmx"
# SOAP method that returns a clean <KeyRate><KR>... structure (no ADO diffgram).
CBR_KEYRATE_METHOD = "KeyRateXML"

# Инфляция (ИПЦ YoY): Excel-экспорт UniDbQuery страницы /hd_base/infl/. id=132934 —
# стабильный id ряда «Инфляция и ключевая ставка» (см. ссылку DownloadExcel на странице).
CBR_INFLATION_EXCEL_URL = "https://www.cbr.ru/Queries/UniDbQuery/DownloadExcel/132934"
# Заголовок колонки инфляции в Excel-экспорте (подстрочный матч — устойчив к мелким
# правкам формулировки/единиц после «Инфляция»). НЕ путать с «Цель по инфляции».
CBR_INFLATION_COLUMN_MARKER = "инфляц"
CBR_INFLATION_TARGET_MARKER = "цель"

USER_AGENT = "GenDesign/1.0 (+https://gendsgn.ru) macro indicators scraper"
DEFAULT_TIMEOUT_S = 30.0
DEFAULT_RETRIES = 3

# Полный бэкфилл истории ключевой ставки. ON CONFLICT в таске делает повторные
# прогоны идемпотентными, так что диапазон можно держать широким.
DEFAULT_FROM_DATE = date(2019, 1, 1)

# Инфляция ЦБ публикуется с 01.2013 (ИПЦ YoY). Широкий старт безопасен — ON CONFLICT
# в таске делает прогон идемпотентным.
DEFAULT_INFLATION_FROM_DATE = date(2013, 1, 1)


class CBRScraperError(RuntimeError):
    """Сетевая / протокольная ошибка обращения к DailyInfoWebServ."""


# ── SOAP envelope ──────────────────────────────────────────────────────────────


def _soap_envelope(from_date: date, to_date: date) -> str:
    """Собрать SOAP 1.2 envelope для ``KeyRateXML(fromDate, ToDate)``.

    Даты сериализуются как ``YYYY-MM-DDT00:00:00`` — сервис принимает только
    ``xsd:dateTime`` (см. module docstring). ``ToDate`` — с заглавной T.
    """
    return (
        '<?xml version="1.0" encoding="utf-8"?>'
        '<soap12:Envelope xmlns:soap12="http://www.w3.org/2003/05/soap-envelope">'
        "<soap12:Body>"
        f'<{CBR_KEYRATE_METHOD} xmlns="http://web.cbr.ru/">'
        f"<fromDate>{from_date.isoformat()}T00:00:00</fromDate>"
        f"<ToDate>{to_date.isoformat()}T00:00:00</ToDate>"
        f"</{CBR_KEYRATE_METHOD}>"
        "</soap12:Body>"
        "</soap12:Envelope>"
    )


# ── pure parse (unit-testable offline) ──────────────────────────────────────────


def _parse_dt(raw: str) -> date | None:
    """Распарсить ``<DT>`` (``2023-08-15T00:00:00+03:00``) в ``date``.

    Берём только календарную дату; tz-суффикс игнорируем (ключевая ставка —
    дневной показатель, время всегда полночь МСК). Поддерживаем и формы без
    суффикса / с 'Z'.
    """
    raw = (raw or "").strip()
    if not raw:
        return None
    # Дата всегда в первых 10 символах ISO-8601 (YYYY-MM-DD...). Это устойчиво к
    # любому tz-суффиксу (+03:00 / Z / отсутствию) без зависимости от Python <3.11
    # fromisoformat ограничений.
    head = raw[:10]
    try:
        return datetime.strptime(head, "%Y-%m-%d").date()
    except ValueError:
        logger.warning("CBR KeyRate: не удалось распарсить DT=%r", raw)
        return None


def _parse_rate(raw: str) -> float | None:
    """Распарсить ``<Rate>`` в float. Принимаем и запятую как десятичный
    разделитель (на всякий случай — фактически CBR отдаёт точку)."""
    raw = (raw or "").strip().replace(",", ".")
    if not raw:
        return None
    try:
        # Через Decimal — чтобы не словить артефакты float-парсинга строки.
        return float(Decimal(raw))
    except (InvalidOperation, ValueError):
        logger.warning("CBR KeyRate: не удалось распарсить Rate=%r", raw)
        return None


def _localname(tag: str) -> str:
    """ElementTree-тег без namespace-префикса: ``{ns}KR`` → ``KR``."""
    return tag.rsplit("}", 1)[-1] if "}" in tag else tag


def parse_key_rate_xml(xml: str) -> list[tuple[date, float]]:
    """Pure-парсер SOAP-ответа CBR KeyRate → отсортированный список (date, rate).

    Находит все элементы ``<KR>`` где угодно в дереве (устойчиво и к чистому
    ``KeyRateXML``, и к diffgram-варианту ``KeyRate``, и к разным namespace'ам).
    Из каждого берёт дочерние ``<DT>`` и ``<Rate>``.

    Возвращает список ``(date, rate)``, отсортированный по возрастанию даты.
    Пустой / битый / без KR XML → ``[]`` (не бросает) — единственное исключение
    логируется как warning. Дубли по дате схлопываются (последний выигрывает).
    """
    if not xml or not xml.strip():
        return []
    try:
        root = ET.fromstring(xml)
    except ET.ParseError as e:
        logger.warning("CBR KeyRate: XML parse error: %s", e)
        return []

    by_date: dict[date, float] = {}
    for kr in root.iter():
        if _localname(kr.tag) != "KR":
            continue
        dt_val: date | None = None
        rate_val: float | None = None
        for child in kr:
            name = _localname(child.tag)
            if name == "DT":
                dt_val = _parse_dt(child.text or "")
            elif name == "Rate":
                rate_val = _parse_rate(child.text or "")
        if dt_val is not None and rate_val is not None:
            by_date[dt_val] = rate_val

    return sorted(by_date.items())


# ── HTTP fetch (thin) ───────────────────────────────────────────────────────────


def fetch_key_rate(
    from_date: date | None = None,
    to_date: date | None = None,
    *,
    timeout_s: float = DEFAULT_TIMEOUT_S,
    retries: int = DEFAULT_RETRIES,
) -> list[tuple[date, Decimal]]:
    """Загрузить историю ключевой ставки ЦБ за период [from_date, to_date].

    Args:
        from_date: начало периода. По умолчанию ``DEFAULT_FROM_DATE`` (2019-01-01)
            — полный бэкфилл истории.
        to_date: конец периода. По умолчанию сегодня (UTC-date достаточно;
            ставка дневная).
        timeout_s: httpx timeout на запрос.
        retries: число повторов на сетевых / 5xx ошибках (экспоненциальный backoff).

    Returns:
        Отсортированный по дате список ``(date, Decimal)``. Decimal — чтобы
        отдать в numeric-колонку без потери точности.

    Raises:
        CBRScraperError: при сетевой ошибке / не-200 / SOAP fault после всех
            повторов. Caller (Celery task) логирует и пробрасывает — surfaces
            в GlitchTip.
    """
    from_date = from_date or DEFAULT_FROM_DATE
    to_date = to_date or datetime.now().date()

    body = _soap_envelope(from_date, to_date)
    headers = {
        "Content-Type": "application/soap+xml; charset=utf-8",
        "User-Agent": USER_AGENT,
    }

    last_exc: Exception | None = None
    with httpx.Client(timeout=timeout_s, headers=headers) as client:
        for attempt in range(retries + 1):
            try:
                resp = client.post(CBR_DAILYINFO_URL, content=body.encode("utf-8"))
            except httpx.HTTPError as e:
                last_exc = e
                logger.warning(
                    "CBR KeyRate fetch network error (attempt %d/%d): %s",
                    attempt + 1,
                    retries + 1,
                    e,
                )
            else:
                if resp.status_code == 200:
                    rows = parse_key_rate_xml(resp.text)
                    logger.info(
                        "CBR KeyRate fetched %d rows for [%s..%s]",
                        len(rows),
                        from_date.isoformat(),
                        to_date.isoformat(),
                    )
                    # Decimal(str(float)) — нормализуем через строку, чтобы
                    # numeric получил аккуратное значение (12.0 → '12.0').
                    return [(d, Decimal(str(v))) for d, v in rows]
                # 5xx — повторяем; 4xx — нет смысла.
                last_exc = CBRScraperError(
                    f"CBR DailyInfoWebServ HTTP {resp.status_code}: {resp.text[:300]}"
                )
                if resp.status_code < 500:
                    break
                logger.warning(
                    "CBR KeyRate HTTP %d (attempt %d/%d)",
                    resp.status_code,
                    attempt + 1,
                    retries + 1,
                )
            # backoff перед следующей попыткой (не на последней итерации)
            if attempt < retries:
                import time

                time.sleep(min(2**attempt, 10))

    raise CBRScraperError(f"CBR KeyRate fetch failed after {retries + 1} attempts: {last_exc}")


# ── inflation (ИПЦ YoY, region='rf', monthly) — pure parse ──────────────────────


def _parse_month_cell(raw: Any) -> date | None:
    """Распарсить ячейку «Дата» Excel-экспорта инфляции в 1-е число месяца.

    Формат CBR — строка ``MM.YYYY`` (напр. '04.2026'). На всякий случай поддерживаем
    и datetime/date (если ЦБ когда-нибудь отдаст типизированную ячейку — берём её
    год/месяц, нормализуя к 1-му числу). Незнакомое → None (строка пропускается).
    """
    if isinstance(raw, datetime):
        return date(raw.year, raw.month, 1)
    if isinstance(raw, date):
        return date(raw.year, raw.month, 1)
    s = str(raw or "").strip()
    if not s:
        return None
    # Основной формат MM.YYYY. Терпим и MM/YYYY (на случай локали).
    s = s.replace("/", ".")
    parts = s.split(".")
    if len(parts) != 2:
        logger.warning("CBR inflation: не удалось распарсить дату=%r", raw)
        return None
    try:
        month = int(parts[0])
        year = int(parts[1])
    except ValueError:
        logger.warning("CBR inflation: не удалось распарсить дату=%r", raw)
        return None
    if not (1 <= month <= 12 and year >= 1900):
        logger.warning("CBR inflation: дата вне диапазона=%r", raw)
        return None
    return date(year, month, 1)


def _parse_inflation_value(raw: Any) -> float | None:
    """Распарсить ячейку «Инфляция, % г/г» в float. None/пусто → None (строка скип).

    openpyxl отдаёт нативный int/float; на случай строковой ячейки принимаем и
    запятую как десятичный разделитель.
    """
    if raw is None:
        return None
    if isinstance(raw, (int, float)):
        return float(raw)
    s = str(raw).strip().replace(",", ".")
    if not s:
        return None
    try:
        return float(Decimal(s))
    except (InvalidOperation, ValueError):
        logger.warning("CBR inflation: не удалось распарсить значение=%r", raw)
        return None


def _find_header_and_inflation_col(rows: list[tuple[Any, ...]]) -> tuple[int, int] | None:
    """Найти строку-заголовок и индекс колонки «Инфляция, % г/г» в Excel-экспорте.

    Сканируем первые строки: заголовок — та, где есть ячейка, СОДЕРЖАЩАЯ 'инфляц' и
    НЕ содержащая 'цель' (чтобы не схватить «Цель по инфляции»). Возвращает
    ``(header_row_idx, inflation_col_idx)`` или None, если колонка не найдена
    (формат изменился) — тогда парсер вернёт [] и task залогирует пустой результат.
    """
    for r_idx, row in enumerate(rows[:10]):  # заголовок всегда в самом начале
        for c_idx, cell in enumerate(row):
            text = str(cell or "").strip().lower()
            if CBR_INFLATION_COLUMN_MARKER in text and CBR_INFLATION_TARGET_MARKER not in text:
                return r_idx, c_idx
    return None


def parse_inflation_xlsx(content: bytes) -> list[tuple[date, float]]:
    """Pure-парсер Excel-экспорта инфляции CBR → отсортированный список (date, %).

    Берёт колонку «Инфляция, % г/г» (YoY %), дату «MM.YYYY» → 1-е число месяца.
    Строки без даты ИЛИ без значения инфляции пропускаются (ранние месяцы несут
    пустую ставку, но инфляция там есть; обратной ситуации в данных нет, но guard
    симметричен). Дубли по дате схлопываются (последний выигрывает).

    Graceful: пустой / битый xlsx / отсутствие колонки «Инфляция» → [] (НЕ бросает) —
    единственное исключение логируется как warning. Сортировка по возрастанию даты.

    Args:
        content: байты .xlsx (как вернул DownloadExcel).

    Returns:
        Отсортированный по дате список ``(date, float)`` (значение инфляции в %).
    """
    if not content:
        return []
    try:
        wb = load_workbook(BytesIO(content), read_only=True, data_only=True)
    except Exception as e:  # openpyxl бросает разные типы на битом файле
        logger.warning("CBR inflation: не удалось открыть xlsx: %s", e)
        return []

    try:
        sheet = wb.active
        if sheet is None:
            return []
        rows = list(sheet.iter_rows(values_only=True))
    finally:
        wb.close()

    if not rows:
        return []

    found = _find_header_and_inflation_col(rows)
    if found is None:
        logger.warning("CBR inflation: колонка «Инфляция, %% г/г» не найдена в xlsx")
        return []
    header_idx, infl_col = found

    by_date: dict[date, float] = {}
    for row in rows[header_idx + 1 :]:
        if not row:
            continue
        d = _parse_month_cell(row[0] if len(row) > 0 else None)
        if d is None:
            continue
        value = _parse_inflation_value(row[infl_col] if len(row) > infl_col else None)
        if value is None:
            continue
        by_date[d] = value

    return sorted(by_date.items())


# ── inflation (ИПЦ YoY) — HTTP fetch (thin) ─────────────────────────────────────


def fetch_inflation(
    from_date: date | None = None,
    to_date: date | None = None,
    *,
    timeout_s: float = DEFAULT_TIMEOUT_S,
    retries: int = DEFAULT_RETRIES,
) -> list[tuple[date, Decimal]]:
    """Загрузить месячный ряд инфляции «% г/г» (ИПЦ YoY) ЦБ за период.

    Источник — CBR UniDbQuery DownloadExcel (см. module docstring): открытый, НЕ за
    WAF, работает с dev напрямую (в отличие от fedstat ИПЦ за reCAPTCHA). Тянем .xlsx
    и парсим колонку «Инфляция, % г/г» через ``parse_inflation_xlsx``.

    Args:
        from_date: начало периода. По умолчанию ``DEFAULT_INFLATION_FROM_DATE``
            (2013-01-01) — полный бэкфилл.
        to_date: конец периода. По умолчанию сегодня.
        timeout_s: httpx timeout на запрос.
        retries: число повторов на сетевых / 5xx ошибках (экспоненциальный backoff).

    Returns:
        Отсортированный по дате список ``(date, Decimal)`` (значение в %). Decimal —
        чтобы отдать в numeric-колонку без потери точности.

    Raises:
        CBRScraperError: при сетевой ошибке / не-200 после всех повторов. Caller
            (Celery task) логирует и пробрасывает (surfaces в GlitchTip). Пустой
            (но валидный) Excel → [] без исключения.
    """
    from_date = from_date or DEFAULT_INFLATION_FROM_DATE
    to_date = to_date or datetime.now().date()

    # CBR UniDbQuery ждёт даты в формате MM/DD/YYYY (US), URL-кодирование делает httpx.
    params = {
        "FromDate": from_date.strftime("%m/%d/%Y"),
        "ToDate": to_date.strftime("%m/%d/%Y"),
        "posted": "False",
    }
    headers = {"User-Agent": USER_AGENT}

    last_exc: Exception | None = None
    with httpx.Client(timeout=timeout_s, headers=headers, follow_redirects=True) as client:
        for attempt in range(retries + 1):
            try:
                resp = client.get(CBR_INFLATION_EXCEL_URL, params=params)
            except httpx.HTTPError as e:
                last_exc = e
                logger.warning(
                    "CBR inflation fetch network error (attempt %d/%d): %s",
                    attempt + 1,
                    retries + 1,
                    e,
                )
            else:
                if resp.status_code == 200:
                    rows = parse_inflation_xlsx(resp.content)
                    logger.info(
                        "CBR inflation fetched %d rows for [%s..%s]",
                        len(rows),
                        from_date.isoformat(),
                        to_date.isoformat(),
                    )
                    return [(d, Decimal(str(v))) for d, v in rows]
                last_exc = CBRScraperError(
                    f"CBR inflation DownloadExcel HTTP {resp.status_code}: {resp.text[:300]}"
                )
                if resp.status_code < 500:
                    break
                logger.warning(
                    "CBR inflation HTTP %d (attempt %d/%d)",
                    resp.status_code,
                    attempt + 1,
                    retries + 1,
                )
            if attempt < retries:
                import time

                time.sleep(min(2**attempt, 10))

    raise CBRScraperError(f"CBR inflation fetch failed after {retries + 1} attempts: {last_exc}")


# ── national mortgage weighted rate (region='rf') ───────────────────────────────
# TODO(#945): национальная средневзвешенная ставка по ипотеке (region='rf').
# Намеренно НЕ реализовано в этом PR. У ЦБ ряд есть, но стабильного
# машиночитаемого endpoint без авторизации / Excel-парсинга не нашлось
# (формы 0409316 публикуются как .xlsx с плавающей структурой листов —
# рискованно для детерминированного scraper). key_rate — must-have, закрыт.
# Ипотека deferrable: можно добавить отдельным fetch_*-методом по образцу
# ekburg_permits.parse_xlsx, когда определимся со стабильным источником.


# ── standalone smoke ────────────────────────────────────────────────────────────


def _main() -> None:
    """``python -m app.services.scrapers.cbr_macro`` — печатает несколько свежих
    строк ключевой ставки для ручной верификации против живого cbr.ru."""
    logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
    # Узкий диапазон (последние ~120 дней) — быстрый smoke без полного бэкфилла.
    today = datetime.now().date()
    rows = fetch_key_rate(from_date=today - timedelta(days=120), to_date=today)
    logger.info("KeyRate rows fetched: %d", len(rows))
    # Печатаем последние 5 (самые свежие) — sorted asc, поэтому хвост.
    for d, v in rows[-5:]:
        logger.info("  key_rate %s -> %s%%", d.isoformat(), v)
    if not rows:
        logger.warning("No key_rate rows returned — проверь endpoint/период.")

    # Инфляция (ИПЦ YoY) — последние ~12 месяцев.
    infl = fetch_inflation(from_date=date(today.year - 1, today.month, 1), to_date=today)
    logger.info("Inflation rows fetched: %d", len(infl))
    for d, v in infl[-6:]:
        logger.info("  inflation_yoy %s -> %s%%", d.isoformat(), v)
    if not infl:
        logger.warning("No inflation rows returned — проверь endpoint/период.")


if __name__ == "__main__":
    _main()
