"""Тесты pure-парсеров Росстат-скрейпера (offline, без живой сети).

Покрывают: разбор meta.csv (URL данных + год), парсинг значений (запятая-десятичный,
разделители тысяч), извлечение целевых ОКТМО-строк из data-CSV (Свердловская обл. /
ЕКБ), фоллбэк года из meta, дедуп по (region, obs_date), graceful на пустом /
без-нужных-колонок CSV. Фикстуры — из реального формата open-data Росстата 4.0
(verified live 2026-06-07: датасет 7708234640-population).
"""

from __future__ import annotations

from datetime import date
from decimal import Decimal
from pathlib import Path

import pytest

from app.services.scrapers.rosstat_emiss import (
    CONSTRUCTION_PRICE_SPEC,
    OKTMO_EKATERINBURG,
    OKTMO_SVERDLOVSK_OBLAST,
    POPULATION_SPEC,
    DatasetSpec,
    RegionTarget,
    _parse_value,
    _parse_year_cell,
    parse_construction_index_xlsx,
    parse_dataset_rows,
    parse_meta_data_url,
    parse_meta_modified_year,
)

# Фикстура: обрезанный РЕАЛЬНЫЙ xlsx Росстата «Индексы цен производителей на строительную
# продукцию по РФ» (лист '1', секции «К предыдущему месяцу» 2022-2025 + «К декабрю
# предыдущего года»; снято live 2026-06-08 с Invest_ind_stroitel_*.xlsx). Декабрь-2025
# намеренно ПУСТ (будущий/неопубликованный месяц → проверка None-not-0 пропуска).
_FIXTURES = Path(__file__).resolve().parent.parent / "fixtures"
CONSTRUCTION_XLSX = (_FIXTURES / "rosstat_construction_stroitel.xlsx").read_bytes()

# Реальный shape meta.csv Росстата (open-data 4.0): property,value; строка данных —
# property 'data-<ts>-structure-<ts>.csv' → абсолютный URL CSV-данных.
FIXTURE_META = (
    "property,value\n"
    "standardversion,https://rosstat.gov.ru/storage/.../versiya_4.0.pdf\n"
    'identifier,7708234640-population\n'
    'title,"Численность постоянного населения"\n'
    "created,20180905\n"
    "modified,20140919\n"
    "format,CSV\n"
    "data-20140919T1709-structure-20141013T1710.csv,"
    "https://rosstat.gov.ru/opendata/7708234640-population/"
    "data-20140919T1709-structure-20141013T1710.csv\n"
    "structure-20141013T1710.csv,"
    "https://rosstat.gov.ru/opendata/7708234640-population/structure-20141013T1710.csv\n"
)

# Реальный shape data-CSV (population): header + муниципальные строки, ОКТМО в 'kode',
# значение в 'total', год в 'year'. Запятая — разделитель полей; значения — целые.
FIXTURE_POPULATION_DATA = (
    "number,year,kode,region,municipalities,total,urban,rural\n"
    '1,2014,7900000000,Республика Адыгея,Муниципальные образования Республики Адыгеи,'
    "446406,209929,236477\n"
    "2,2014,6500000000,Свердловская область,Муниципальные образования Свердловской области,"
    "4320677,3500000,820677\n"
    "3,2014,657010000011000,Свердловская область,г. Екатеринбург,1412346,1412346,0\n"
    "4,2014,657013640011000,Свердловская область,Верх-Исетский район,201817,201817,0\n"
)


def test_parse_meta_data_url() -> None:
    """meta.csv → URL последнего data-файла."""
    url = parse_meta_data_url(FIXTURE_META)
    assert url == (
        "https://rosstat.gov.ru/opendata/7708234640-population/"
        "data-20140919T1709-structure-20141013T1710.csv"
    )


def test_parse_meta_modified_year() -> None:
    """meta.csv 'modified' (YYYYMMDD) → год."""
    assert parse_meta_modified_year(FIXTURE_META) == 2014


def test_parse_meta_missing_data_url() -> None:
    """meta без строки данных → None (не бросает)."""
    assert parse_meta_data_url("property,value\nidentifier,foo\n") is None


def test_parse_value_comma_decimal() -> None:
    """Запятая как десятичный разделитель: '1,5' → Decimal('1.5')."""
    assert _parse_value("1,5") == Decimal("1.5")


def test_parse_value_thousands_separator() -> None:
    """Пробелы-разделители тысяч вырезаются: '4 320 677' → Decimal('4320677')."""
    assert _parse_value("4 320 677") == Decimal("4320677")
    # узкий неразрывный пробел (Росстат иногда так)
    assert _parse_value("1 412 346") == Decimal("1412346")


def test_parse_value_empty_and_dash() -> None:
    """Пустое / прочерк / многоточие → None (строка пропускается)."""
    assert _parse_value("") is None
    assert _parse_value("-") is None
    assert _parse_value("…") is None
    assert _parse_value("не число") is None


def test_parse_dataset_targets_only() -> None:
    """Из data-CSV извлекаются ТОЛЬКО целевые ОКТМО (субъект + ЕКБ), не районы/др.регионы."""
    rows = parse_dataset_rows(FIXTURE_POPULATION_DATA, POPULATION_SPEC, fallback_year=None)
    by_region = {r.region: r for r in rows}
    assert set(by_region) == {"sverdl", "ekb"}
    # Адыгея и Верх-Исетский район — НЕ среди целей
    assert all(r.region in {"sverdl", "ekb"} for r in rows)


def test_parse_dataset_values_and_dates() -> None:
    """Значения и obs_date (1 января года из колонки 'year') корректны."""
    rows = parse_dataset_rows(FIXTURE_POPULATION_DATA, POPULATION_SPEC, fallback_year=None)
    by_region = {r.region: r for r in rows}
    assert by_region["sverdl"].value == Decimal("4320677")
    assert by_region["sverdl"].obs_date == date(2014, 1, 1)
    assert by_region["ekb"].value == Decimal("1412346")
    assert by_region["ekb"].obs_date == date(2014, 1, 1)
    # тип / единица из spec
    assert by_region["sverdl"].indicator_type == "population_total"
    assert by_region["sverdl"].unit == "чел"


def test_parse_dataset_fallback_year_when_no_year_col() -> None:
    """Если в spec year_col_header=None — год берётся из fallback_year (meta modified)."""
    spec_no_year = DatasetSpec(
        code=POPULATION_SPEC.code,
        indicator_type="population_total",
        unit="чел",
        value_col_header="total",
        oktmo_col_header="kode",
        year_col_header=None,  # год не из данных
        targets=(RegionTarget(oktmo=OKTMO_SVERDLOVSK_OBLAST, region="sverdl"),),
    )
    rows = parse_dataset_rows(FIXTURE_POPULATION_DATA, spec_no_year, fallback_year=2014)
    assert len(rows) == 1
    assert rows[0].obs_date == date(2014, 1, 1)


def test_parse_dataset_dedup_last_wins() -> None:
    """Дубль строки по (region, obs_date) схлопывается — последняя выигрывает."""
    data = (
        "number,year,kode,region,municipalities,total,urban,rural\n"
        "1,2014,6500000000,Свердловская область,МО,4320677,0,0\n"
        "2,2014,6500000000,Свердловская область,МО,4400000,0,0\n"  # дубль ОКТМО+год
    )
    rows = parse_dataset_rows(data, POPULATION_SPEC, fallback_year=None)
    assert len(rows) == 1
    assert rows[0].value == Decimal("4400000")


def test_parse_dataset_skips_unparsable_value() -> None:
    """Строка с непарсящимся значением пропускается, валидные остаются."""
    data = (
        "number,year,kode,region,municipalities,total,urban,rural\n"
        "1,2014,6500000000,Свердловская область,МО,-,0,0\n"  # value '-'
        "2,2014,657010000011000,Свердловская область,г. Екатеринбург,1412346,0,0\n"
    )
    rows = parse_dataset_rows(data, POPULATION_SPEC, fallback_year=None)
    assert len(rows) == 1
    assert rows[0].region == "ekb"


def test_parse_dataset_missing_columns_graceful() -> None:
    """CSV без нужных колонок (kode/total) → [] (+warning), не бросает."""
    data = "foo,bar\n1,2\n3,4\n"
    assert parse_dataset_rows(data, POPULATION_SPEC, fallback_year=2014) == []


def test_parse_dataset_empty_graceful() -> None:
    """Пустой CSV → []."""
    assert parse_dataset_rows("", POPULATION_SPEC, fallback_year=2014) == []


def test_oktmo_constants_used_in_spec() -> None:
    """Спека population таргетит именно субъект-ОКТМО и ЕКБ-ОКТМО (контракт ТЗ §7.11)."""
    oktmos = {t.oktmo for t in POPULATION_SPEC.targets}
    assert OKTMO_SVERDLOVSK_OBLAST in oktmos
    assert OKTMO_EKATERINBURG in oktmos


# ── индекс цен на СМР (открытый xlsx Росстата) ────────────────────────────────────


def test_parse_year_cell_plain_and_footnote() -> None:
    """Год из ячейки: чистый int, строка-год и сноска-маркер ('2022 1)' → 2022)."""
    assert _parse_year_cell(2024) == 2024
    assert _parse_year_cell("2022 1)") == 2022
    assert _parse_year_cell("2025") == 2025
    # не-год / пусто / вне диапазона → None
    assert _parse_year_cell(None) is None
    assert _parse_year_cell("Январь") is None
    assert _parse_year_cell(1800) is None


def test_construction_spec_contract() -> None:
    """Спека СМР: тип, region='rf', monthly, % — открытый xlsx, без dataGrid.do/captcha."""
    assert CONSTRUCTION_PRICE_SPEC.indicator_type == "construction_price_index"
    assert CONSTRUCTION_PRICE_SPEC.region == "rf"
    assert CONSTRUCTION_PRICE_SPEC.frequency == "monthly"
    assert CONSTRUCTION_PRICE_SPEC.unit == "%"
    assert "dataGrid" not in CONSTRUCTION_PRICE_SPEC.url_template
    assert CONSTRUCTION_PRICE_SPEC.url_template.startswith("https://rosstat.gov.ru/")
    assert "{mm:02d}" in CONSTRUCTION_PRICE_SPEC.url_template  # резолв по дате, НЕ хардкод месяца


def test_construction_url_resolves_with_lookback(monkeypatch: pytest.MonkeyPatch) -> None:
    """Имя файла меняется ежемесячно → резолвер идёт назад до первого 200 (не хардкод)."""
    from app.services.scrapers import rosstat_emiss as mod

    tried: list[str] = []

    def fake_get(client: object, url: str, *, retries: int = 0) -> bytes:
        tried.append(url)
        if len(tried) < 3:  # первые два месяца → 404
            raise mod.RosstatScraperError(f"HTTP 404 {url}")
        return CONSTRUCTION_XLSX

    monkeypatch.setattr(mod, "_http_get", fake_get)
    content = mod._fetch_construction_latest(
        client=object(),  # type: ignore[arg-type]  # не используется (get замокан)
        url_template=mod.ROSSTAT_CONSTRUCTION_XLSX_URL_TEMPLATE,
        today=date(2026, 6, 15),
    )
    assert content == CONSTRUCTION_XLSX
    assert tried == [
        "https://rosstat.gov.ru/storage/mediabank/Invest_ind_stroitel_06-2026.xlsx",
        "https://rosstat.gov.ru/storage/mediabank/Invest_ind_stroitel_05-2026.xlsx",
        "https://rosstat.gov.ru/storage/mediabank/Invest_ind_stroitel_04-2026.xlsx",
    ]


def test_construction_url_lookback_year_boundary_and_exhaustion(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """Все кандидаты 404 → RosstatScraperError; look-back корректно переходит через год."""
    from app.services.scrapers import rosstat_emiss as mod

    tried: list[str] = []

    def fake_get(client: object, url: str, *, retries: int = 0) -> bytes:
        tried.append(url)
        raise mod.RosstatScraperError(f"HTTP 404 {url}")

    monkeypatch.setattr(mod, "_http_get", fake_get)
    with pytest.raises(mod.RosstatScraperError):
        mod._fetch_construction_latest(
            client=object(),  # type: ignore[arg-type]
            url_template=mod.ROSSTAT_CONSTRUCTION_XLSX_URL_TEMPLATE,
            today=date(2026, 2, 15),
            max_lookback=3,
        )
    assert tried == [  # переход через год: 02-2026 → 01-2026 → 12-2025 → 11-2025
        "https://rosstat.gov.ru/storage/mediabank/Invest_ind_stroitel_02-2026.xlsx",
        "https://rosstat.gov.ru/storage/mediabank/Invest_ind_stroitel_01-2026.xlsx",
        "https://rosstat.gov.ru/storage/mediabank/Invest_ind_stroitel_12-2025.xlsx",
        "https://rosstat.gov.ru/storage/mediabank/Invest_ind_stroitel_11-2025.xlsx",
    ]


def test_parse_construction_monthly_series() -> None:
    """xlsx → месячный ряд СМР: region='rf', obs_date к 1-му числу, значение в %."""
    rows = parse_construction_index_xlsx(CONSTRUCTION_XLSX, CONSTRUCTION_PRICE_SPEC)
    assert rows, "ожидались строки из фикстуры"
    assert all(r.region == "rf" for r in rows)
    assert all(r.indicator_type == "construction_price_index" for r in rows)
    assert all(r.unit == "%" for r in rows)
    assert all(r.obs_date.day == 1 for r in rows)
    by_date = {r.obs_date: r.value for r in rows}
    # реальные значения «к предыдущему месяцу» из фикстуры (2022-2025)
    assert by_date[date(2022, 1, 1)] == Decimal("100.6")
    assert by_date[date(2025, 1, 1)] == Decimal("100.8")
    # годы покрывают 2022..2025
    assert {d.year for d in by_date} == {2022, 2023, 2024, 2025}


def test_parse_construction_skips_blank_future_month() -> None:
    """Пустая ячейка будущего/неопубликованного месяца → пропуск (None-not-0)."""
    rows = parse_construction_index_xlsx(CONSTRUCTION_XLSX, CONSTRUCTION_PRICE_SPEC)
    dates = {r.obs_date for r in rows}
    # Декабрь-2025 в фикстуре намеренно пуст → его НЕ должно быть (а не 0)
    assert date(2025, 12, 1) not in dates
    assert date(2025, 11, 1) in dates  # соседний заполненный — есть


def test_parse_construction_ignores_annual_section() -> None:
    """Секция «К декабрю предыдущего года» (годовой базис) НЕ попадает в месячный ряд.

    Годовые декабрьские значения большие (>105), месячные — около 100. Убеждаемся, что
    ни одно декабрьское наблюдение не унаследовало годовое значение.
    """
    rows = parse_construction_index_xlsx(CONSTRUCTION_XLSX, CONSTRUCTION_PRICE_SPEC)
    dec_vals = [r.value for r in rows if r.obs_date.month == 12]
    assert dec_vals, "ожидались декабрьские месячные наблюдения"
    assert all(v < Decimal("105") for v in dec_vals)


def test_parse_construction_empty_and_garbage_graceful() -> None:
    """Пустые байты / не-xlsx → [] (+warning), не бросает."""
    assert parse_construction_index_xlsx(b"", CONSTRUCTION_PRICE_SPEC) == []
    assert parse_construction_index_xlsx(b"not a zip", CONSTRUCTION_PRICE_SPEC) == []


def test_parse_construction_missing_sheet_graceful() -> None:
    """Если запрошенного листа нет в книге → [] (+warning)."""
    from dataclasses import replace

    spec_bad = replace(CONSTRUCTION_PRICE_SPEC, sheet="НЕТ_ТАКОГО")
    assert parse_construction_index_xlsx(CONSTRUCTION_XLSX, spec_bad) == []
