"""Росстат / ЕМИСС macro-indicator scraper → ``macro_indicator`` (#946, EPIC2).

Заполняет региональные (Свердловская обл., ОКТМО 65 / ЕКБ) макро-ряды Росстата в
унифицированной таблице ``macro_indicator`` (миграция ``data/sql/123_macro_indicator.sql``).
Детерминированно, без LLM. Зеркалит форму ``cbr_macro.py``: pure-парсеры (offline-
тестируемые на фикстурах) + тонкая httpx-обвязка (timeout, UA, retry); upsert делает
Celery-таска ``rosstat_macro_sync`` через SAVEPOINT-per-row.

────────────────────────────────────────────────────────────────────────────────
ИСТОЧНИК ДАННЫХ — open-data + ЕМИСС-SDMX + открытый xlsx (разведано live 2026-06-07/08)
────────────────────────────────────────────────────────────────────────────────
ЕМИСС / fedstat.ru — за WAF с DEV-машины (hard-403), но ДОСТУПЕН С ПРОДА (verified
2026-06-07: ``POST https://www.fedstat.ru/indicator/data.do`` body ``id=<id>&format=sdmx``
→ 200 + SDMX-ML v1.0 GenericData). Поэтому ЕМИСС-fetch выполняется ТОЛЬКО на проде
(Celery); парсер SDMX — pure и dev-тестируем на фикстурах ``tests/fixtures/emiss_*.xml``.

ЧТО ПРИЗЕМЛЯЕТСЯ из ЕМИСС (см. EMISS_INDICATORS ниже):
  • income_per_capita — «Среднедушевые денежные доходы населения», fedstat id=57039,
    размерность ``s_OKATO`` (Свердл = ОКАТО 65000000000), квартальный (рубль/мес).
    ВАЖНО: дефолтный SDMX-экспорт этого индикатора СОДЕРЖИТ все субъекты, в т.ч. 65.

ЧТО НЕ ПРИЗЕМЛЯЕТСЯ из ЕМИСС (документировано, частичная поставка допустима ТЗ §7.11):
  • cpi (ИПЦ) — fedstat id=31074 «Индексы потребительских цен на товары и услуги»
    (% , монотип, есть месячные и квартальные периоды). НЕ landed: индикатор много-
    мерный (поля 57831=ОКАТО, 58273=виды товаров/услуг, 57937=виды показателя,
    33560=период, 3=год), и его ДЕФОЛТНЫЙ data.do-экспорт отдаёт лишь узкий срез
    (ОКАТО 030/14/15 — ЦФО/Белгород/Брянск), БЕЗ Свердл-65. Чтобы выбрать регион,
    fedstat требует POST ``dataGrid.do`` c ``selectedFilterIds=<fieldId>_<internalId>``,
    перечисляющим выбранные коды ПО ВСЕМ размерностям, где ``internalId`` — внутренний
    id кода (НЕ значение ОКАТО), берётся отдельным filter-tree AJAX; плюс на странице
    индикатора reCAPTCHA. Это глубокий RE — отложено (закрывается тем же EmissIndicatorSpec
    + параметризацией data.do-выборки, когда добудем filter_id-маппинг).
  • construction_price_index (индекс цен на СМР) — из ЕМИСС НЕ берётся (кандидат
    EMISS id=31108 «Индексы цен производителей на строительную продукцию» доступен тем
    же открытым SDMX-путём POST data.do, но fedstat за WAF с dev — не верифицируем
    здесь). ПРИЗЕМЛЯЕТСЯ из ОТКРЫТОГО xlsx Росстата (см. блок «Индекс цен на СМР» ниже,
    region='rf'), reCAPTCHA-gated dataGrid.do НАМЕРЕННО НЕ используется.

``rosstat.gov.ru/opendata`` — БЕЗ WAF, машиночитаем (российский стандарт открытых
данных 4.0):
  • реестр   ``/opendata/list.csv``                 — CSV (property,title,value,format),
                                                       value = URL ``…/<code>/meta.csv``;
  • метаданные ``/opendata/<code>/meta.csv``         — CP1251 CSV (property,value); строка
                                                       ``data-<ts>-structure-<ts>.csv`` →
                                                       URL фактического файла данных;
  • данные   ``/opendata/<code>/data-<ts>-…csv``     — табличный CSV; регионы ключуются
                                                       ОКТМО (Свердловская обл. = префикс
                                                       ``65``; ЕКБ-горокруг = ``6570100000``).

Что РЕАЛЬНО приземляется отсюда для региона 65 (см. ниже DATASETS):
  • ``population_total`` — Численность постоянного населения (датасет ``…-population``,
    муниципальный срез; берём субъект-агрегат ОКТМО ``6500000000`` + г. Екатеринбург
    ``657010000011000``). Это § 7.11 «демография (численность)».

Индекс цен на СМР приземляется из ОТКРЫТОГО xlsx Росстата (НЕ из opendata-реестра — там
0 совпадений; и НЕ из ЕМИСС/dataGrid.do):
  • construction_price_index — «Индексы цен производителей на строительную продукцию по
    РФ» (region='rf', месячный, к предыдущему месяцу, %). Прямая ссылка на xlsx со
    страницы rosstat.gov.ru «Цены»; reCAPTCHA-gated dataGrid.do НАМЕРЕННО НЕ используется
    (см. блок «Индекс цен на СМР» ниже + HARD SAFETY CONSTRAINT #946).

Что НЕ приземляется из open-data (но среднедушевые доходы идут через ЕМИСС-прод):
  • ИПЦ — EMISS-only и в open-data его нет (0 совпадений по реестру); из ЕМИСС пока не
    landed (много-мерный, см. EMISS_INDICATORS-блок выше).

Standalone smoke (ручная проверка; open-data — c dev, ЕМИСС-fetch — только с прода):
    python -m app.services.scrapers.rosstat_emiss

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

from __future__ import annotations

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

import httpx
from openpyxl import load_workbook

logger = logging.getLogger(__name__)

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

ROSSTAT_OPENDATA_BASE = "https://rosstat.gov.ru/opendata"

# ЕМИСС / fedstat.ru — SDMX-экспорт индикатора (POST id=<id>&format=sdmx). За WAF с
# dev (403), доступен с прода (см. шапку модуля). verify off — тот же российский гос-УЦ.
FEDSTAT_DATA_URL = "https://www.fedstat.ru/indicator/data.do"

# Индекс цен на строительную продукцию (СМР) — ОТКРЫТЫЙ xlsx Росстата (БЕЗ captcha,
# БЕЗ dataGrid.do): страница «Цены» → «Индексы цен и средние цены строительной
# продукции» → «Индексы цен производителей на строительную продукцию по РФ». Имя файла
# несёт месяц-вёрстки (Invest_ind_stroitel_MM-YYYY.xlsx) и обновляется ежемесячно →
# URL НЕ хардкодим (иначе протух бы каждый месяц и ingest тихо перестал бы тянуться).
# Резолвим от текущей даты с look-back (Росстат публикует с лагом 1-3 мес): пробуем
# текущий месяц и назад до _CONSTRUCTION_MAX_LOOKBACK, берём первый 200 — см.
# _fetch_construction_latest. Доступен с dev (verify off, российский гос-УЦ). live 2026-06-08.
ROSSTAT_CONSTRUCTION_XLSX_URL_TEMPLATE = (
    "https://rosstat.gov.ru/storage/mediabank/Invest_ind_stroitel_{mm:02d}-{yyyy}.xlsx"
)
_CONSTRUCTION_MAX_LOOKBACK = 6  # мес назад от текущего (запас на лаг публикации Росстата)

# Браузероподобный UA: rosstat.gov.ru отдаёт open-data без WAF, но «голый» httpx-UA
# иногда ловит 403 на edge-кэше — даём обычный desktop-Chrome UA (как nspd_client).
USER_AGENT = (
    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
    "(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
)
DEFAULT_TIMEOUT_S = 30.0
DEFAULT_RETRIES = 3

# Кодировки CSV Росстата в порядке проб: data-файлы бывают и utf-8(-sig), и CP1251;
# meta.csv почти всегда CP1251. Декодируем «первой подошедшей» (см. _decode_csv).
_CSV_ENCODINGS: tuple[str, ...] = ("utf-8-sig", "cp1251", "utf-8")

# rosstat.gov.ru отдаёт TLS-цепочку российского гос-УЦ (Russian Trusted / Минцифры),
# корневой CA которой НЕ в trust-store наших контейнеров — verify=True падает с
# CERTIFICATE_VERIFY_FAILED (подтверждено live 2026-06-07). Отключаем верификацию:
# данные ПУБЛИЧНЫЕ open-data, секреты не передаются → MITM-риск незначим. Прецедент:
# ekb_geoportal_client._VERIFY_TLS / nspd_lite._SSL_CTX для тех же росс-гос-эндпоинтов.
_VERIFY_TLS = False

# ОКТМО-коды интересующих территорий (Свердловская обл. / ЕКБ). ТЗ §7.11 «region 65».
#   • субъект «Свердловская область» — ОКТМО-агрегат '6500000000' (region='sverdl');
#   • г. Екатеринбург (горокруг)     — ОКТМО '657010000011000' (region='ekb').
# Ключуем по ТОЧНОМУ ОКТМО (а не по имени) — имена в датасетах варьируются
# («Муниципальные образования Свердловской области» и т.п.), ОКТМО стабилен.
OKTMO_SVERDLOVSK_OBLAST = "6500000000"
OKTMO_EKATERINBURG = "657010000011000"


class RosstatScraperError(RuntimeError):
    """Сетевая / протокольная ошибка обращения к rosstat.gov.ru/opendata."""


# ── dataset specs (что и откуда тянем) ──────────────────────────────────────────


@dataclass(frozen=True)
class RegionTarget:
    """Целевая территория ряда: ОКТМО-ключ → (region-метка macro_indicator)."""

    oktmo: str
    region: str  # метка region в macro_indicator: 'sverdl' | 'ekb'


@dataclass(frozen=True)
class DatasetSpec:
    """Описание одного приземляемого open-data датасета Росстата.

    Атрибуты:
        code: код датасета в реестре (часть URL ``/opendata/<code>/…``).
        indicator_type: тип ряда в macro_indicator (напр. 'population_total').
        unit: единица измерения (справочно, в PK не участвует).
        value_col_header: заголовок колонки со значением в data-CSV
            (для population — 'total'; матчим без учёта регистра/пробелов).
        oktmo_col_header: заголовок колонки с ОКТМО-кодом (для population — 'kode').
        year_col_header: заголовок колонки с годом наблюдения, если он есть в данных
            (для population — 'year'); None → год берётся из meta ``modified``.
        targets: список территорий (ОКТМО → region-метка), которые извлекаем.
        comment: пояснение для colонки comment macro_indicator.
    """

    code: str
    indicator_type: str
    unit: str
    value_col_header: str
    oktmo_col_header: str
    year_col_header: str | None
    targets: tuple[RegionTarget, ...]
    comment: str = ""


# Демография (§7.11 «численность населения»). Датасет муниципального уровня; берём
# субъект-агрегат Свердловской обл. + г. Екатеринбург. year — в самих данных ('year').
POPULATION_SPEC = DatasetSpec(
    code="7708234640-population",
    indicator_type="population_total",
    unit="чел",
    value_col_header="total",
    oktmo_col_header="kode",
    year_col_header="year",
    targets=(
        RegionTarget(oktmo=OKTMO_SVERDLOVSK_OBLAST, region="sverdl"),
        RegionTarget(oktmo=OKTMO_EKATERINBURG, region="ekb"),
    ),
    comment="Росстат open-data 'Численность постоянного населения' (#946 §7.11)",
)

# Активные спеки скрейпера. Расширяется добавлением DatasetSpec (напр. когда появится
# доступ к EMISS-рядам ИПЦ/доходов — у них будет свой Spec с тем же upsert-контрактом).
DATASETS: tuple[DatasetSpec, ...] = (POPULATION_SPEC,)


# ── parsed-row contract ─────────────────────────────────────────────────────────


@dataclass(frozen=True)
class MacroRow:
    """Одна готовая к upsert строка macro_indicator (источник 'rosstat')."""

    indicator_type: str
    region: str
    obs_date: date
    value: Decimal
    unit: str
    comment: str


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


def _decode_csv(raw: bytes) -> str:
    """Декодировать байты CSV Росстата «первой подошедшей» кодировкой (_CSV_ENCODINGS).

    Росстат смешивает utf-8(-sig) и CP1251 между файлами. Возвращает str; если ни
    одна кодировка не подошла строго — падаем на utf-8 с errors='replace' (лучше
    частично-битый текст, чем исключение на одном файле).
    """
    for enc in _CSV_ENCODINGS:
        try:
            return raw.decode(enc)
        except UnicodeDecodeError:
            continue
    logger.warning("Rosstat CSV: ни одна кодировка не подошла строго — utf-8/replace")
    return raw.decode("utf-8", errors="replace")


def _sniff_rows(text: str) -> list[list[str]]:
    """Разобрать CSV-текст в список строк, авто-детект разделителя (',' либо ';').

    Берём тот разделитель, что даёт > 1 колонки в первой непустой строке. Росстат
    использует и запятую (population), и точку-с-запятой (часть рядов) как delimiter.
    """
    for delim in (",", ";"):
        rows = list(csv.reader(io.StringIO(text), delimiter=delim))
        first = next((r for r in rows if any(c.strip() for c in r)), [])
        if len(first) > 1:
            return rows
    # фоллбэк — как запятая (даже если одна колонка)
    return list(csv.reader(io.StringIO(text), delimiter=","))


def _norm(s: str) -> str:
    """Нормализовать заголовок колонки для матча: lower + strip пробелов/BOM."""
    return (s or "").strip().lstrip("﻿").lower()


def parse_meta_data_url(meta_text: str) -> str | None:
    """Извлечь URL файла данных из meta.csv (open-data standard 4.0).

    meta.csv — две колонки (property,value). Строка данных имеет property вида
    ``data-<ts>-structure-<ts>.csv`` и value = абсолютный URL CSV-данных. Если
    таких строк несколько — возвращаем ПОСЛЕДНЮЮ (самый свежий снапшот). None —
    если строки данных нет.
    """
    rows = _sniff_rows(meta_text)
    data_url: str | None = None
    for row in rows:
        if len(row) < 2:
            continue
        prop = row[0].strip()
        if prop.startswith("data-") and row[1].strip().lower().startswith("http"):
            data_url = row[1].strip()
    return data_url


def parse_meta_modified_year(meta_text: str) -> int | None:
    """Извлечь год из meta.csv ``modified`` (формат 'YYYYMMDD') — фоллбэк для obs_date.

    Используется, когда в самих данных нет колонки года (year_col_header is None).
    None — если поле отсутствует / не парсится.
    """
    rows = _sniff_rows(meta_text)
    for row in rows:
        if len(row) >= 2 and row[0].strip() == "modified":
            digits = row[1].strip()[:4]
            if digits.isdigit():
                return int(digits)
    return None


def _parse_value(raw: str) -> Decimal | None:
    """Распарсить числовое значение Росстата → Decimal.

    Принимает запятую как десятичный разделитель (Росстат: '1,5'), вырезает
    пробелы-разделители тысяч (включая узкий неразрывный  / ). Пустая
    строка / '-' / нечисло → None (строка пропускается caller'ом).
    """
    raw = (raw or "").strip()
    if not raw or raw in {"-", "—", "…", "..."}:
        return None
    cleaned = raw.replace(" ", "").replace(" ", "").replace(" ", "").replace(",", ".")
    try:
        return Decimal(cleaned)
    except (InvalidOperation, ValueError):
        logger.warning("Rosstat: не удалось распарсить значение %r", raw)
        return None


def _obs_date_for_year(year: int) -> date:
    """Годовой ряд → obs_date нормализуем к 1 января года (как monthly-ряды к 1-му числу)."""
    return date(year, 1, 1)


def parse_dataset_rows(
    data_text: str,
    spec: DatasetSpec,
    *,
    fallback_year: int | None,
) -> list[MacroRow]:
    """Pure-парсер data-CSV датасета → список MacroRow для целевых ОКТМО.

    Алгоритм:
      1. Находит индекс колонок ОКТМО / значения / года по их заголовкам
         (spec.*_col_header, матч через _norm). Нет нужной колонки → [] (+warning).
      2. Для каждой строки: если ОКТМО ∈ spec.targets — парсит значение и год
         (из year-колонки либо fallback_year), эмитит MacroRow с region-меткой цели.
      3. Дубли по (indicator_type, region, obs_date) схлопываются (последний выигрывает) —
         защита от повторов строк в исходнике; PK macro_indicator всё равно один.

    Никогда не бросает на данных: битые/пустые ячейки → строка пропускается.
    """
    rows = _sniff_rows(data_text)
    if not rows:
        return []

    # Заголовок — первая строка, где встречается искомый заголовок ОКТМО-колонки.
    want_oktmo = _norm(spec.oktmo_col_header)
    want_value = _norm(spec.value_col_header)
    want_year = _norm(spec.year_col_header) if spec.year_col_header else None

    header_idx: int | None = None
    for i, row in enumerate(rows[:5]):  # заголовок Росстата в первых нескольких строках
        norm = [_norm(c) for c in row]
        if want_oktmo in norm and want_value in norm:
            header_idx = i
            break
    if header_idx is None:
        logger.warning(
            "Rosstat %s: не найдены колонки %r/%r в заголовке",
            spec.code,
            spec.oktmo_col_header,
            spec.value_col_header,
        )
        return []

    header = [_norm(c) for c in rows[header_idx]]
    col_oktmo = header.index(want_oktmo)
    col_value = header.index(want_value)
    col_year = header.index(want_year) if want_year and want_year in header else None

    target_by_oktmo = {t.oktmo: t for t in spec.targets}
    by_key: dict[tuple[str, date], MacroRow] = {}

    for row in rows[header_idx + 1 :]:
        if len(row) <= max(col_oktmo, col_value):
            continue
        oktmo = (row[col_oktmo] or "").strip()
        target = target_by_oktmo.get(oktmo)
        if target is None:
            continue
        value = _parse_value(row[col_value])
        if value is None:
            continue
        year: int | None = None
        if col_year is not None and len(row) > col_year:
            yr = (row[col_year] or "").strip()[:4]
            if yr.isdigit():
                year = int(yr)
        if year is None:
            year = fallback_year
        if year is None:
            logger.warning("Rosstat %s: нет года для ОКТМО %s — пропуск", spec.code, oktmo)
            continue
        obs_date = _obs_date_for_year(year)
        by_key[(target.region, obs_date)] = MacroRow(
            indicator_type=spec.indicator_type,
            region=target.region,
            obs_date=obs_date,
            value=value,
            unit=spec.unit,
            comment=spec.comment,
        )

    return [by_key[k] for k in sorted(by_key, key=lambda k: (k[0], k[1]))]


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


def _http_get(client: httpx.Client, url: str, *, retries: int = DEFAULT_RETRIES) -> bytes:
    """GET с ретраями (сеть / 5xx) → байты тела. Module-level для monkeypatch в тестах.

    Raises:
        RosstatScraperError: на не-200 / сетевой ошибке после всех ретраев.
    """
    import time

    last_exc: Exception | None = None
    for attempt in range(retries + 1):
        try:
            resp = client.get(url)
        except httpx.HTTPError as e:
            last_exc = e
            logger.warning(
                "Rosstat GET network error (attempt %d/%d) %s: %s",
                attempt + 1,
                retries + 1,
                url,
                e,
            )
        else:
            if resp.status_code == 200:
                return resp.content
            last_exc = RosstatScraperError(
                f"Rosstat GET HTTP {resp.status_code} {url}: {resp.text[:200]}"
            )
            if resp.status_code < 500:
                break  # 4xx — ретрай бессмыслен
            logger.warning(
                "Rosstat GET HTTP %d (attempt %d/%d) %s",
                resp.status_code,
                attempt + 1,
                retries + 1,
                url,
            )
        if attempt < retries:
            time.sleep(min(2**attempt, 10))
    raise RosstatScraperError(f"Rosstat GET failed after {retries + 1} attempts {url}: {last_exc}")


def _http_post(
    client: httpx.Client,
    url: str,
    *,
    data: dict[str, str],
    retries: int = DEFAULT_RETRIES,
) -> bytes:
    """POST form-data с ретраями (сеть / 5xx) → байты тела. Для ЕМИСС data.do.

    Module-level для monkeypatch в тестах. Раздельно от _http_get: fedstat принимает
    данные ТОЛЬКО POST'ом (form-urlencoded id+format), GET отдаёт SPA-оболочку.

    Raises:
        RosstatScraperError: на не-200 / сетевой ошибке после всех ретраев.
    """
    import time

    last_exc: Exception | None = None
    for attempt in range(retries + 1):
        try:
            resp = client.post(url, data=data)
        except httpx.HTTPError as e:
            last_exc = e
            logger.warning(
                "EMISS POST network error (attempt %d/%d) %s: %s",
                attempt + 1,
                retries + 1,
                url,
                e,
            )
        else:
            if resp.status_code == 200:
                return resp.content
            last_exc = RosstatScraperError(
                f"EMISS POST HTTP {resp.status_code} {url}: {resp.text[:200]}"
            )
            if resp.status_code < 500:
                break  # 4xx (в т.ч. WAF-403 с dev) — ретрай бессмыслен
            logger.warning(
                "EMISS POST HTTP %d (attempt %d/%d) %s",
                resp.status_code,
                attempt + 1,
                retries + 1,
                url,
            )
        if attempt < retries:
            time.sleep(min(2**attempt, 10))
    raise RosstatScraperError(f"EMISS POST failed after {retries + 1} attempts {url}: {last_exc}")


def fetch_dataset(
    spec: DatasetSpec,
    *,
    timeout_s: float = DEFAULT_TIMEOUT_S,
    retries: int = DEFAULT_RETRIES,
    client: httpx.Client | None = None,
) -> list[MacroRow]:
    """Загрузить и распарсить один датасет Росстата → список MacroRow.

    Два запроса: meta.csv (→ URL данных + fallback-год) и сам data-CSV. Парсинг —
    в pure-функциях выше (offline-тестируемых). Возвращает строки для целевых ОКТМО.

    Args:
        spec: описание датасета (код, колонки, целевые ОКТМО).
        timeout_s: httpx timeout на запрос.
        retries: число ретраев на сетевых / 5xx ошибках.
        client: переиспользуемый httpx.Client (для батча). None → создаём свой.

    Raises:
        RosstatScraperError: на сетевой/HTTP-ошибке либо если meta не содержит URL данных.
    """
    own_client = client is None
    if client is None:
        client = httpx.Client(
            timeout=timeout_s,
            headers={"User-Agent": USER_AGENT, "Accept-Language": "ru-RU,ru;q=0.9"},
            follow_redirects=True,
            verify=_VERIFY_TLS,
        )
    try:
        meta_url = f"{ROSSTAT_OPENDATA_BASE}/{spec.code}/meta.csv"
        meta_text = _decode_csv(_http_get(client, meta_url, retries=retries))
        data_url = parse_meta_data_url(meta_text)
        if not data_url:
            raise RosstatScraperError(f"Rosstat {spec.code}: meta.csv без URL данных")
        fallback_year = parse_meta_modified_year(meta_text)

        data_text = _decode_csv(_http_get(client, data_url, retries=retries))
        rows = parse_dataset_rows(data_text, spec, fallback_year=fallback_year)
        logger.info(
            "Rosstat %s (%s): распарсено %d строк для %d целевых ОКТМО",
            spec.code,
            spec.indicator_type,
            len(rows),
            len(spec.targets),
        )
        return rows
    finally:
        if own_client:
            client.close()


def fetch_all_datasets(
    specs: tuple[DatasetSpec, ...] = DATASETS,
    *,
    timeout_s: float = DEFAULT_TIMEOUT_S,
    retries: int = DEFAULT_RETRIES,
) -> list[MacroRow]:
    """Загрузить все активные датасеты Росстата → плоский список MacroRow.

    Один общий httpx.Client на батч. Сбой одного датасета логируется и НЕ валит
    остальные (graceful — частичная поставка допустима ТЗ).
    """
    out: list[MacroRow] = []
    with httpx.Client(
        timeout=timeout_s,
        headers={"User-Agent": USER_AGENT, "Accept-Language": "ru-RU,ru;q=0.9"},
        follow_redirects=True,
        verify=_VERIFY_TLS,
    ) as client:
        for spec in specs:
            try:
                out.extend(fetch_dataset(spec, retries=retries, client=client))
            except RosstatScraperError as e:
                logger.warning("Rosstat: датасет %s пропущен: %s", spec.code, e)
    return out


# ══════════════════════════════════════════════════════════════════════════════════
# ЕМИСС / fedstat.ru — SDMX-ML v1.0 GenericData (EMISS-only ряды, fetch только с прода)
# ══════════════════════════════════════════════════════════════════════════════════
# Источник: POST https://www.fedstat.ru/indicator/data.do  body: id=<id>&format=sdmx
#   → SDMX-ML v1.0 GenericData (см. tests/fixtures/emiss_income_57039.xml). Структура:
#     <GenericData xmlns=...message  xmlns:generic=...generic  xmlns:structure=...>
#       <CodeLists><structure:CodeList id="s_OKATO">…коды регионов…</structure:CodeList>
#       <Description><Indicator id="57039" name="…"/></Description>   (fedstat-расширение)
#       <DataSet>
#         <generic:Series>
#           <generic:SeriesKey><generic:Value concept="s_OKATO" value="65000000000"/></…>
#           <generic:Attributes>
#             <generic:Value concept="EI"     value="рубль"/>      (единица измерения)
#             <generic:Value concept="PERIOD" value="I квартал"/>  (под-период года)
#           </generic:Attributes>
#           <generic:Obs><generic:Time>2024</generic:Time>
#                        <generic:ObsValue value="54006"/></generic:Obs>
#         </generic:Series> …
# Один <Series> = один (регион, PERIOD) ряд; год наблюдения — в <Obs><Time> (напр. 2024),
# фактический момент = PERIOD + Time-год (I квартал 2024 → 2024-01-01). Регион берём из
# SeriesKey concept="s_OKATO" (ОКАТО-код), фильтруем по целевым кодам (Свердл = 65…).
# Парсер pure → dev-тестируем на фикстурах; сам fetch (httpx POST) исполняется на проде.

_SDMX_NS = {
    "msg": "http://www.SDMX.org/resources/SDMXML/schemas/v1_0/message",
    "g": "http://www.SDMX.org/resources/SDMXML/schemas/v1_0/generic",
}

# PERIOD-значения fedstat → стартовый месяц периода (obs_date нормализуем к 1-му числу
# стартового месяца под-периода, как monthly-ряды к 1-му числу). Покрывает кварталы и
# месяцы (ИПЦ публикуется и помесячно). Незнакомый PERIOD → None (ряд пропускается).
_QUARTER_START_MONTH: dict[str, int] = {
    "i квартал": 1,
    "ii квартал": 4,
    "iii квартал": 7,
    "iv квартал": 10,
}
_MONTH_NAME_TO_NUM: dict[str, int] = {
    "январь": 1,
    "февраль": 2,
    "март": 3,
    "апрель": 4,
    "май": 5,
    "июнь": 6,
    "июль": 7,
    "август": 8,
    "сентябрь": 9,
    "октябрь": 10,
    "ноябрь": 11,
    "декабрь": 12,
}
# Годовой под-период (некоторые индикаторы): «значение показателя за год», «год».
_YEAR_PERIODS: frozenset[str] = frozenset({"год", "значение показателя за год", "за год"})


@dataclass(frozen=True)
class EmissIndicatorSpec:
    """Описание одного приземляемого ЕМИСС-индикатора (fedstat data.do, SDMX).

    Атрибуты:
        indicator_id: числовой fedstat ``id`` (param ``id=`` в data.do).
        indicator_type: тип ряда в macro_indicator (напр. 'income_per_capita').
        unit: единица измерения (справочно; в PK не участвует).
        frequency: 'monthly' | 'quarterly' | 'yearly' — для колонки frequency.
        targets: целевые территории (ОКАТО-код → region-метка macro_indicator).
        comment: пояснение для колонки comment macro_indicator.
    """

    indicator_id: str
    indicator_type: str
    unit: str
    frequency: str
    targets: tuple[RegionTarget, ...]
    comment: str = ""


# Свердловская обл. = ОКАТО '65000000000' (region='sverdl'). ЕКБ-горокруг в ОКАТО-
# размерности ЕМИСС-индикаторов отсутствует (только субъектовый срез), поэтому здесь
# целимся ТОЛЬКО в субъект-65 (в отличие от open-data population, где есть и ЕКБ).
OKATO_SVERDLOVSK_OBLAST = "65000000000"

INCOME_PER_CAPITA_SPEC = EmissIndicatorSpec(
    indicator_id="57039",
    indicator_type="income_per_capita",
    unit="руб",
    frequency="quarterly",
    targets=(RegionTarget(oktmo=OKATO_SVERDLOVSK_OBLAST, region="sverdl"),),
    comment="ЕМИСС id=57039 'Среднедушевые денежные доходы населения' (#946 §7.11)",
)

# Активные ЕМИСС-индикаторы. ИПЦ (id=31074) пока НЕ здесь — многомерный, дефолт-экспорт
# data.do без Свердл, нужна dataGrid-выборка (см. шапку). Индекс цен на СМР берётся НЕ из
# ЕМИСС, а из открытого xlsx Росстата (CONSTRUCTION_PRICE_SPEC ниже).
EMISS_INDICATORS: tuple[EmissIndicatorSpec, ...] = (INCOME_PER_CAPITA_SPEC,)


@dataclass(frozen=True)
class EmissRow:
    """Одна готовая к upsert строка macro_indicator из ЕМИСС (source='emiss').

    Отдельно от ``MacroRow`` (open-data, source='rosstat', yearly): ЕМИСС-ряды несут
    свою frequency (quarterly/monthly), source и period_type — контракт upsert'а у
    них иной.

    period_type: гранулярность под-периода ('year' | 'quarter' | 'month' | 'unknown').
        Берётся из _emiss_period_granularity(PERIOD). Необходима как часть PK
        macro_indicator (migration 163), чтобы годовой агрегат ('год' → 'year') и
        Q1 ('I квартал' → 'quarter') за один год не перезаписывали друг друга при
        ON CONFLICT DO UPDATE (оба дают obs_date=YYYY-01-01) (#1606).
    """

    indicator_type: str
    region: str
    obs_date: date
    value: Decimal
    unit: str
    frequency: str
    comment: str
    period_type: str = "unknown"


def _emiss_period_to_month(period: str) -> int | None:
    """PERIOD-строку fedstat → стартовый месяц под-периода (1..12) или None.

    Квартал ('I квартал'…'IV квартал') → первый месяц квартала; месяц ('январь'…) →
    его номер; годовой под-период ('год') → 1 (январь). Незнакомое → None (caller
    пропустит ряд + warning) — защита от неожиданных под-периодов (недели и т.п.).
    """
    p = (period or "").strip().lower()
    if not p:
        return None
    if p in _QUARTER_START_MONTH:
        return _QUARTER_START_MONTH[p]
    if p in _MONTH_NAME_TO_NUM:
        return _MONTH_NAME_TO_NUM[p]
    if p in _YEAR_PERIODS:
        return 1
    return None


def _emiss_period_granularity(period: str) -> str:
    """PERIOD-строку fedstat → гранулярность ('year' | 'quarter' | 'month').

    Используется как третья компонента ключа дедупликации в parse_emiss_sdmx, чтобы
    годовой ряд ('год') и квартальный Q1 ('I квартал') не перезаписывали друг друга при
    одинаковых (region, obs_date=YYYY-01-01) (#1606).
    Возвращает 'unknown' для нераспознанных под-периодов (на практике такие ряды
    пропускаются ещё до этого вызова через _emiss_period_to_month → None).
    """
    p = (period or "").strip().lower()
    if p in _YEAR_PERIODS:
        return "year"
    if p in _QUARTER_START_MONTH:
        return "quarter"
    if p in _MONTH_NAME_TO_NUM:
        return "month"
    return "unknown"


def _series_concepts(series: ET.Element) -> tuple[dict[str, str], dict[str, str]]:
    """Извлечь (SeriesKey, Attributes) concept→value словари из <generic:Series>.

    SeriesKey несёт размерности (s_OKATO и пр.); Attributes — EI (единица) и PERIOD.
    Отсутствующий блок → пустой словарь (не падаем).
    """

    def _vals(parent: ET.Element | None) -> dict[str, str]:
        out: dict[str, str] = {}
        if parent is None:
            return out
        for v in parent.findall("g:Value", _SDMX_NS):
            concept = v.get("concept")
            value = v.get("value")
            if concept is not None and value is not None:
                out[concept] = value
        return out

    key = _vals(series.find("g:SeriesKey", _SDMX_NS))
    attrs = _vals(series.find("g:Attributes", _SDMX_NS))
    return key, attrs


def parse_emiss_sdmx(raw: bytes | str, spec: EmissIndicatorSpec) -> list[EmissRow]:
    """Pure-парсер SDMX-ML v1.0 GenericData fedstat → список EmissRow целевых регионов.

    Алгоритм:
      1. Парсит XML (ET). Битый/непарсящийся XML → [] (+warning), не бросает.
      2. Для каждого <generic:Series>: регион = SeriesKey concept='s_OKATO'. Если ОКАТО
         не среди spec.targets — ряд пропускается. PERIOD (Attributes) → стартовый месяц
         под-периода; год — из <Obs><Time>; obs_date = date(year, month, 1). Значение —
         из <Obs><ObsValue value=...> через _parse_value (запятая-десятичный, разделители).
      3. Дубли по (region, obs_date, granularity) схлопываются (последний выигрывает).
         Ключ трёхкомпонентный — гранулярность ('year'/'quarter'/'month') предотвращает
         перезапись годового ряда ('год' → obs_date YYYY-01-01) Q1-рядом ('I квартал' →
         тот же YYYY-01-01) при совместном их присутствии в одном SDMX-ответе (#1606).

    Никогда не бросает на данных: битые ячейки/периоды/годы → наблюдение пропускается.
    """
    if isinstance(raw, bytes):
        try:
            text_xml = raw.decode("utf-8")
        except UnicodeDecodeError:
            text_xml = raw.decode("utf-8", errors="replace")
    else:
        text_xml = raw
    try:
        root = ET.fromstring(text_xml)
    except ET.ParseError as e:
        logger.warning("EMISS %s: SDMX XML не распарсился: %s", spec.indicator_id, e)
        return []

    dataset = root.find("msg:DataSet", _SDMX_NS)
    if dataset is None:
        logger.warning("EMISS %s: нет <DataSet> в SDMX", spec.indicator_id)
        return []

    target_by_okato = {t.oktmo: t for t in spec.targets}
    # Ключ: (region, obs_date, granularity) — три компоненты предотвращают коллизию
    # годового ряда ('год' → YYYY-01-01, granularity='year') и Q1 ('I квартал' →
    # тот же YYYY-01-01, granularity='quarter') при их совместном присутствии в SDMX (#1606).
    by_key: dict[tuple[str, date, str], EmissRow] = {}

    for series in dataset.findall("g:Series", _SDMX_NS):
        key, attrs = _series_concepts(series)
        okato = key.get("s_OKATO", "").strip()
        target = target_by_okato.get(okato)
        if target is None:
            continue
        period_raw = attrs.get("PERIOD", "")
        month = _emiss_period_to_month(period_raw)
        if month is None:
            logger.warning(
                "EMISS %s: неизвестный PERIOD %r (ОКАТО %s) — пропуск",
                spec.indicator_id,
                period_raw,
                okato,
            )
            continue
        granularity = _emiss_period_granularity(period_raw)
        for obs in series.findall("g:Obs", _SDMX_NS):
            time_el = obs.find("g:Time", _SDMX_NS)
            val_el = obs.find("g:ObsValue", _SDMX_NS)
            if time_el is None or time_el.text is None or val_el is None:
                continue
            yr = time_el.text.strip()[:4]
            if not yr.isdigit():
                continue
            value = _parse_value(val_el.get("value", ""))
            if value is None:
                continue
            obs_date = date(int(yr), month, 1)
            by_key[(target.region, obs_date, granularity)] = EmissRow(
                indicator_type=spec.indicator_type,
                region=target.region,
                obs_date=obs_date,
                value=value,
                unit=spec.unit,
                frequency=spec.frequency,
                comment=spec.comment,
                period_type=granularity,
            )

    return [by_key[k] for k in sorted(by_key, key=lambda k: (k[0], k[1]))]


def fetch_emiss_indicator(
    spec: EmissIndicatorSpec,
    *,
    timeout_s: float = DEFAULT_TIMEOUT_S,
    retries: int = DEFAULT_RETRIES,
    client: httpx.Client | None = None,
) -> list[EmissRow]:
    """Загрузить и распарсить один ЕМИСС-индикатор с fedstat → список EmissRow.

    ВНИМАНИЕ: fedstat.ru за WAF с dev-машины (403) — этот fetch рассчитан на исполнение
    с ПРОДА (Celery). POST ``data.do`` body ``id=<id>&format=sdmx``; verify off (российский
    гос-УЦ, как и open-data выше). Парсинг — в pure ``parse_emiss_sdmx`` (offline-тестируем).

    Raises:
        RosstatScraperError: на сетевой/HTTP-ошибке после ретраев.
    """
    own_client = client is None
    if client is None:
        client = httpx.Client(
            timeout=timeout_s,
            headers={"User-Agent": USER_AGENT, "Accept-Language": "ru-RU,ru;q=0.9"},
            follow_redirects=True,
            verify=_VERIFY_TLS,
        )
    try:
        raw = _http_post(
            client,
            f"{FEDSTAT_DATA_URL}",
            data={"id": spec.indicator_id, "format": "sdmx"},
            retries=retries,
        )
        rows = parse_emiss_sdmx(raw, spec)
        logger.info(
            "EMISS %s (%s): распарсено %d строк для %d целевых ОКАТО",
            spec.indicator_id,
            spec.indicator_type,
            len(rows),
            len(spec.targets),
        )
        return rows
    finally:
        if own_client:
            client.close()


def fetch_all_emiss(
    specs: tuple[EmissIndicatorSpec, ...] = EMISS_INDICATORS,
    *,
    timeout_s: float = DEFAULT_TIMEOUT_S,
    retries: int = DEFAULT_RETRIES,
) -> list[EmissRow]:
    """Загрузить все активные ЕМИСС-индикаторы → плоский список EmissRow.

    Один общий httpx.Client на батч. Сбой одного индикатора логируется и НЕ валит
    остальные (graceful — частичная поставка допустима ТЗ). Исполняется на проде.
    """
    out: list[EmissRow] = []
    with httpx.Client(
        timeout=timeout_s,
        headers={"User-Agent": USER_AGENT, "Accept-Language": "ru-RU,ru;q=0.9"},
        follow_redirects=True,
        verify=_VERIFY_TLS,
    ) as client:
        for spec in specs:
            try:
                out.extend(fetch_emiss_indicator(spec, retries=retries, client=client))
            except RosstatScraperError as e:
                logger.warning("EMISS: индикатор %s пропущен: %s", spec.indicator_id, e)
    return out


# ══════════════════════════════════════════════════════════════════════════════════
# Индекс цен на строительную продукцию (СМР) — ОТКРЫТЫЙ xlsx Росстата (#946, EPIC2)
# ══════════════════════════════════════════════════════════════════════════════════
# Источник (разведано live 2026-06-08, fetch работает С DEV): страница
#   https://rosstat.gov.ru/statistics/price → раздел «строительная продукция» →
#   xlsx ROSSTAT_CONSTRUCTION_XLSX_URL_TEMPLATE «Индексы цен производителей на строительную
#   продукцию по РФ в 1995-2025 гг.». reCAPTCHA-gated dataGrid.do НЕ используется —
#   это статический файл прямой ссылкой (см. HARD SAFETY CONSTRAINT в шапке #946).
# Форма листа «1» (широкий pivot, верифицировано на реальном файле):
#   r3 — заголовок;  r5 — «на конец периода, в %»;
#   r6 — годы по колонкам (B..) c возможным сноска-маркером ('2022 1)' и т.п.);
#   r7 — секция «К предыдущему месяцу» (то, что берём: ИСТИННЫЙ месячный ряд);
#   r8..r19 — месяцы Январь..Декабрь, ячейка = индекс % за (месяц, год-колонка);
#   r20 — секция «К декабрю предыдущего года» (НЕ берём — иной базис, годовой).
# Один (месяц, год) → одно наблюдение; obs_date = date(year, month, 1) — как и прочие
# месячные ряды нормализуем к 1-му числу. Регион = 'rf' (файл — общефедеральный).
# Будущие/неопубликованные месяцы текущего года приходят пустой ячейкой → None → скип
# (None-not-0: пропуск, НИКОГДА не подставляем 0). Парсер pure → dev-тестируем на фикстуре.

# Заголовки секций листа: какую берём (месячная), какую игнорируем (годовая к декабрю).
_CONSTR_SECTION_MONTHLY = "к предыдущему месяцу"
_CONSTR_SECTION_TO_DEC = "к декабрю предыдущего года"


@dataclass(frozen=True)
class XlsxIndicatorSpec:
    """Описание приземляемого ряда из открытого xlsx Росстата (широкий pivot год×месяц).

    Атрибуты:
        url_template: шаблон ссылки на xlsx с плейсхолдерами {mm:02d}/{yyyy}
            (ROSSTAT_CONSTRUCTION_XLSX_URL_TEMPLATE) — резолвится по дате в fetch.
        sheet: имя листа с данными (для construction — '1').
        indicator_type: тип ряда в macro_indicator ('construction_price_index').
        region: метка region (общефедеральный файл → 'rf').
        unit: единица (для индекса цен — '%'; справочно, в PK не участвует).
        frequency: 'monthly' — берём секцию «К предыдущему месяцу».
        comment: пояснение для колонки comment macro_indicator.
    """

    url_template: str
    sheet: str
    indicator_type: str
    region: str
    unit: str
    frequency: str
    comment: str = ""


CONSTRUCTION_PRICE_SPEC = XlsxIndicatorSpec(
    url_template=ROSSTAT_CONSTRUCTION_XLSX_URL_TEMPLATE,
    sheet="1",
    indicator_type="construction_price_index",
    region="rf",
    unit="%",
    frequency="monthly",
    comment=(
        "Росстат 'Индексы цен производителей на строительную продукцию по РФ', "
        "к предыдущему месяцу (#946 §7.11; СМР, open-data, без dataGrid.do/captcha)"
    ),
)


def _parse_year_cell(raw: Any) -> int | None:
    """Год из ячейки-заголовка → int. Допускает сноска-маркер ('2022 1)' → 2022) и
    числовой год (openpyxl отдаёт int). Не-год / пусто → None (колонка пропускается)."""
    if raw is None:
        return None
    if isinstance(raw, int):
        return raw if 1900 <= raw <= 2100 else None
    digits = str(raw).strip()[:4]
    if digits.isdigit():
        year = int(digits)
        return year if 1900 <= year <= 2100 else None
    return None


def parse_construction_index_xlsx(content: bytes, spec: XlsxIndicatorSpec) -> list[MacroRow]:
    """Pure-парсер открытого xlsx Росстата (индекс цен на СМР) → список MacroRow.

    Алгоритм (форма листа — см. блок выше):
      1. Открывает лист spec.sheet. Находит строку годов-заголовков (первая, где ≥2
         ячеек распарсились как год через _parse_year_cell) → карта колонка→год.
      2. Идёт по строкам ПОСЛЕ заголовка только внутри секции «К предыдущему месяцу»
         (по достижении секции «К декабрю предыдущего года» / иной — останавливается).
         Строка месяца (ячейка A ∈ _MONTH_NAME_TO_NUM) → для каждой год-колонки берёт
         значение через _parse_value; пусто/нечисло → пропуск (None-not-0).
      3. obs_date = date(year, month, 1). Дубли по (region, obs_date) схлопываются.

    Graceful: пустой/битый xlsx, нет листа, нет строки годов → [] (+warning), не бросает.
    """
    if not content:
        return []
    try:
        wb = load_workbook(BytesIO(content), read_only=True, data_only=True)
    except Exception as e:  # openpyxl бросает разные типы на битом файле
        logger.warning("Rosstat construction: не удалось открыть xlsx: %s", e)
        return []
    try:
        if spec.sheet not in wb.sheetnames:
            logger.warning("Rosstat construction: нет листа %r в xlsx", spec.sheet)
            return []
        rows = list(wb[spec.sheet].iter_rows(values_only=True))
    finally:
        wb.close()
    if not rows:
        return []

    # 1. Строка годов: первая, где ≥2 ячеек (после A) распарсились как год.
    year_by_col: dict[int, int] = {}
    header_idx: int | None = None
    for r_idx, row in enumerate(rows):
        cols = {c: y for c in range(1, len(row)) if (y := _parse_year_cell(row[c]))}
        if len(cols) >= 2:
            header_idx, year_by_col = r_idx, cols
            break
    if header_idx is None:
        logger.warning("Rosstat construction: не найдена строка годов-заголовков")
        return []

    # 2. Месяцы внутри секции «К предыдущему месяцу» (до начала годовой секции).
    by_key: dict[tuple[str, date], MacroRow] = {}
    in_monthly = False
    for row in rows[header_idx + 1 :]:
        if not row:
            continue
        label = str(row[0] or "").strip().lower()
        if label == _CONSTR_SECTION_MONTHLY:
            in_monthly = True
            continue
        if label == _CONSTR_SECTION_TO_DEC or (label and "к декабрю" in label):
            break  # дальше идёт годовой базис — нам не нужен
        month = _MONTH_NAME_TO_NUM.get(label)
        if month is None or not in_monthly:
            continue
        for col, year in year_by_col.items():
            if col >= len(row):
                continue
            value = _parse_value(str(row[col]) if row[col] is not None else "")
            if value is None:
                continue  # пустой/будущий месяц → пропуск (None-not-0)
            obs_date = date(year, month, 1)
            by_key[(spec.region, obs_date)] = MacroRow(
                indicator_type=spec.indicator_type,
                region=spec.region,
                obs_date=obs_date,
                value=value,
                unit=spec.unit,
                comment=spec.comment,
            )

    return [by_key[k] for k in sorted(by_key, key=lambda k: (k[0], k[1]))]


def _fetch_construction_latest(
    client: httpx.Client,
    *,
    url_template: str,
    today: date,
    retries: int = DEFAULT_RETRIES,
    max_lookback: int = _CONSTRUCTION_MAX_LOOKBACK,
) -> bytes:
    """Тело свежайшего xlsx индекса цен на СМР: резолв от today с look-back по месяцам.

    Имя файла Росстата несёт месяц-вёрстки (Invest_ind_stroitel_MM-YYYY.xlsx) и меняется
    ежемесячно с лагом публикации. Пробуем текущий месяц и назад до max_lookback мес —
    возвращаем тело первого ответившего 200 (404 у _http_get ломается сразу, без ретрая,
    поэтому отсутствующие месяцы дёшевы). dataGrid.do/captcha НЕ задействованы.

    Raises:
        RosstatScraperError: если ни один кандидат (today..-max_lookback мес) не отдал 200.
    """
    year, month = today.year, today.month
    last_exc: Exception | None = None
    for _ in range(max_lookback + 1):
        url = url_template.format(mm=month, yyyy=year)
        try:
            return _http_get(client, url, retries=retries)
        except RosstatScraperError as exc:
            last_exc = exc
            logger.info("Rosstat СМР xlsx нет за %02d-%d, пробуем предыдущий месяц", month, year)
        month -= 1
        if month == 0:
            month, year = 12, year - 1
    raise RosstatScraperError(
        f"Rosstat СМР xlsx: не найден за {max_lookback + 1} мес от {today.isoformat()}: {last_exc}"
    )


def fetch_construction_index(
    spec: XlsxIndicatorSpec = CONSTRUCTION_PRICE_SPEC,
    *,
    today: date | None = None,
    timeout_s: float = DEFAULT_TIMEOUT_S,
    retries: int = DEFAULT_RETRIES,
    client: httpx.Client | None = None,
) -> list[MacroRow]:
    """Загрузить открытый xlsx Росстата (индекс цен на СМР) → список MacroRow.

    URL резолвится по дате с look-back (имя файла меняется ежемесячно — см.
    _fetch_construction_latest), затем pure-парсинг. Работает С DEV (verify off,
    российский гос-УЦ — как и прочие rosstat.gov.ru-эндпоинты). dataGrid.do/captcha
    НЕ задействованы. today=None → date.today() (инъекция для тестов).

    Raises:
        RosstatScraperError: на сетевой/HTTP-ошибке после ретраев / если файл не найден.
    """
    own_client = client is None
    if client is None:
        client = httpx.Client(
            timeout=timeout_s,
            headers={"User-Agent": USER_AGENT, "Accept-Language": "ru-RU,ru;q=0.9"},
            follow_redirects=True,
            verify=_VERIFY_TLS,
        )
    try:
        content = _fetch_construction_latest(
            client, url_template=spec.url_template, today=today or date.today(), retries=retries
        )
        rows = parse_construction_index_xlsx(content, spec)
        logger.info(
            "Rosstat construction (%s): распарсено %d месячных наблюдений (region=%s)",
            spec.indicator_type,
            len(rows),
            spec.region,
        )
        return rows
    finally:
        if own_client:
            client.close()


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


def _main() -> None:
    """``python -m app.services.scrapers.rosstat_emiss`` — печать нескольких строк
    для ручной верификации против живого rosstat.gov.ru."""
    logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
    rows = fetch_all_datasets()
    logger.info("Rosstat rows fetched: %d", len(rows))
    for r in rows:
        logger.info(
            "  %s / %s @ %s = %s %s",
            r.indicator_type,
            r.region,
            r.obs_date.isoformat(),
            r.value,
            r.unit,
        )
    if not rows:
        logger.warning("No rows — проверь доступность rosstat.gov.ru/opendata.")

    # Индекс цен на СМР — открытый xlsx, тоже доступен с dev (печатаем хвост ряда).
    constr = fetch_construction_index()
    logger.info("Rosstat construction rows fetched: %d", len(constr))
    for r in constr[-6:]:
        logger.info(
            "  %s / %s @ %s = %s %s",
            r.indicator_type,
            r.region,
            r.obs_date.isoformat(),
            r.value,
            r.unit,
        )


if __name__ == "__main__":
    _main()
