"""ГИС ЖКХ loader: «квартир на дом» (знаменатель «доли квартир дома в продаже»).

Тянет точный счёт квартир (residentialPremiseCount) из публичного API ГИС ЖКХ
(dom.gosuslugi.ru) и проставляет `houses.zhkh_*` (мигр. 146/149). В отличие от ГАР-loader'а
(стриминговый парс многогигабайтного XML + fuzzy canon-match адреса) здесь матч ТРИВИАЛЬНЫЙ:
`houses.gar_house_guid` — это И ЕСТЬ ЖКХ `houseGuid` (проверено), поэтому ищем дом строго по
этому guid, без нормализации/канона адреса.

ГИС ЖКХ — точнее ГАР (ГАР дико недосчитывает квартиры в МКД, см. мигр. 149): резолвит ~90%
вторички и даёт год ввода (operationYear) + этажность (maxFloorCount) как бонус.

API-рецепт (verified, прод):
  POST searchByAddress?pageIndex=1&elementsPerPage=100 — БЕЗ auth/cookies.
  · pageIndex — 1-based: pageIndex=0 молча отдаёт пусто (200). Всегда 1.
  · elementsPerPage cap = 100 (≥200 → 403) → батч ≤100 guid на запрос, одна страница.
  · Тело — ПОЛНЫЙ DTO (все поля, неиспользуемые = None); fiasHouseCodeList — батч guid.
  · Ответ {"total":N,"items":[...]}; per item residentialPremiseCount (строка) = счёт квартир,
    operationYear/buildingYear = год, maxFloorCount (строка-диапазон "10-30") = этажность,
    address.house.houseGuid = входной guid (ключ для обратного маппинга).

psycopg v3: SQL через `text(...)` использует `CAST(:x AS type)`, НИКОГДА `:x::type`.
"""

from __future__ import annotations

import logging
import re
import time
import uuid
from collections.abc import Iterator
from dataclasses import dataclass
from typing import Any

import httpx
from sqlalchemy import text
from sqlalchemy.orm import Session

logger = logging.getLogger(__name__)

# ─────────────────────────────────────────────────────────────────────────────
# Константы API
# ─────────────────────────────────────────────────────────────────────────────
# pageIndex/elementsPerPage — query-параметры (НЕ в теле). pageIndex 1-based (=0 → пусто),
# elementsPerPage cap=100 (≥200 → 403).
ENDPOINT = (
    "https://dom.gosuslugi.ru/homemanagement/api/rest/services/houses/public/"
    "searchByAddress?pageIndex=1&elementsPerPage=100"
)
# FIAS GUID Свердловской обл. (region 66) — обязателен в теле DTO.
REGION_GUID = "92b30014-4d52-4e2e-892d-928142b924bf"
# Batch = elementsPerPage cap: ≤100 guid/запрос, одна страница (pageIndex=1).
BATCH_SIZE = 100
# Группировка UPDATE в один SAVEPOINT: сбойный ЧАНК (≤200 строк) откатывается изолированно,
# остальные чанки доезжают; повторный прогон идемпотентен (IS DISTINCT FROM gate).
SAVEPOINT_CHUNK = 200
DEFAULT_UA = (
    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
    "(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
)


# ─────────────────────────────────────────────────────────────────────────────
# Чистые хелперы парсинга (юнит-тестируются без сети/БД)
# ─────────────────────────────────────────────────────────────────────────────
# NB: maxFloorCount хранится СЫРЫМ ("10-30"/"17,23") в zhkh_floors_raw — числовой парс
# этажности отложен до PR фильтра новостроек (#2), который и решит, как трактовать диапазон.
def parse_int(raw: object) -> int | None:
    """Устойчивый str/int → int|None.

    None/«»/«0»/нечисло → None (0 квартир — не пригодный знаменатель; ≤0 тоже отбрасываем).
    """
    if raw is None or isinstance(raw, bool):
        return None
    if isinstance(raw, int):
        return raw if raw > 0 else None
    s = str(raw).strip()
    if not s:
        return None
    try:
        n = int(s)
    except ValueError:
        return None
    return n if n > 0 else None


_FLOORS_RE = re.compile(r"\d+")


def _parse_floors_max(floors_raw: str | None) -> int | None:
    """Макс. этаж из сырой maxFloorCount («10-30»/«17,23»/«9») → int|None.

    Зеркалит мигр. 152 (max((m[1])::int) по regexp '[0-9]+' над zhkh_floors_raw): берём все
    числовые прогоны и возвращаем максимум; нет цифр/None → None.
    """
    if not floors_raw:
        return None
    nums = [int(m) for m in _FLOORS_RE.findall(floors_raw)]
    return max(nums) if nums else None


@dataclass(slots=True)
class ZhkhHouse:
    """Распарсенная ЖКХ-строка дома (ключ обратного маппинга — house_guid)."""

    house_guid: str
    flat_count: int | None
    year: int | None
    floors_raw: str | None


def _parse_item(item: dict[str, Any]) -> ZhkhHouse | None:
    """Извлечь guid + поля из item ответа ГИС ЖКХ. None, если нет houseGuid.

    year = operationYear (предпочтительно) иначе parse_int(buildingYear);
    flat_count = parse_int(residentialPremiseCount); floors_raw = сырая maxFloorCount.
    """
    address = item.get("address") or {}
    house = address.get("house") or {}
    guid = house.get("houseGuid")
    if not guid:
        return None

    year = parse_int(item.get("operationYear"))
    if year is None:
        year = parse_int(item.get("buildingYear"))

    floors_value = item.get("maxFloorCount")
    floors_raw = None if floors_value is None else str(floors_value)

    return ZhkhHouse(
        house_guid=str(guid),
        flat_count=parse_int(item.get("residentialPremiseCount")),
        year=year,
        floors_raw=floors_raw,
    )


# ─────────────────────────────────────────────────────────────────────────────
# HTTP: заголовки + тело + fetch
# ─────────────────────────────────────────────────────────────────────────────
def _headers() -> dict[str, str]:
    """Заголовки запроса. request-guid/session-guid — свежие random uuid4 (random OK)."""
    return {
        "content-type": "application/json;charset=UTF-8",
        "accept": "application/json; charset=utf-8",
        "state-guid": "/houses",
        "request-guid": str(uuid.uuid4()),
        "session-guid": str(uuid.uuid4()),
        "user-agent": DEFAULT_UA,
        "referer": "https://dom.gosuslugi.ru/",
        "origin": "https://dom.gosuslugi.ru",
    }


def _build_body(batch: list[str]) -> dict[str, Any]:
    """ПОЛНЫЙ DTO searchByAddress (15 полей; неиспользуемые = None). batch → fiasHouseCodeList."""
    return {
        "regionCode": REGION_GUID,
        "fiasHouseCodeList": batch,
        "estStatus": None,
        "strStatus": None,
        "calcCount": True,
        "houseConditionRefList": None,
        "houseTypeRefList": None,
        "houseManagementTypeRefList": None,
        "cadastreNumber": None,
        "oktmo": None,
        "statuses": ["APPROVED"],
        "regionProperty": None,
        "municipalProperty": None,
        "hostelTypeCodes": None,
        "useReadOnlyDataSource": True,
    }


def _body_by_cadastre(cad: str) -> dict[str, Any]:
    """Тот же полный DTO searchByAddress, но поиск по кадастру: fiasHouseCodeList=None,
    cadastreNumber=cad (ОДИНОЧНОЕ значение, НЕ список — verified, прод). Остальные 15 ключей
    идентичны _build_body."""
    return {
        "regionCode": REGION_GUID,
        "fiasHouseCodeList": None,
        "estStatus": None,
        "strStatus": None,
        "calcCount": True,
        "houseConditionRefList": None,
        "houseTypeRefList": None,
        "houseManagementTypeRefList": None,
        "cadastreNumber": cad,
        "oktmo": None,
        "statuses": ["APPROVED"],
        "regionProperty": None,
        "municipalProperty": None,
        "hostelTypeCodes": None,
        "useReadOnlyDataSource": True,
    }


def _chunk_guids(guids: list[str], size: int) -> Iterator[list[str]]:
    for i in range(0, len(guids), size):
        yield guids[i : i + size]


def _fetch_batch(
    batch: list[str], *, client: httpx.Client, max_retries: int
) -> list[dict[str, Any]]:
    """POST одного батча guid (pageIndex=1) → items. Ретрай на 403/5xx/httpx-ошибках.

    Backoff 1,2,4с между попытками. На финальном провале — warning + пустой список (батч
    пропускается, весь прогон не срывается). Прочие 4xx (битый запрос) — не ретраим.
    """
    body = _build_body(batch)
    last_err = ""
    for attempt in range(1, max_retries + 1):
        try:
            resp = client.post(ENDPOINT, json=body, headers=_headers())
            if resp.status_code != 403 and resp.status_code < 500:
                resp.raise_for_status()  # прочие 4xx → невосстановимый HTTPStatusError
                data = resp.json()
                items = data.get("items")
                return items if isinstance(items, list) else []
            last_err = f"HTTP {resp.status_code}"
        except httpx.HTTPStatusError as exc:
            logger.warning(
                "zhkh fetch: батч %d guid — невосстановимый %s (пропущен)", len(batch), exc
            )
            return []
        except (httpx.HTTPError, ValueError) as exc:
            last_err = repr(exc)
        if attempt < max_retries:
            backoff = 2 ** (attempt - 1)
            logger.warning(
                "zhkh fetch: батч %d guid сбой (%s), retry %d/%d через %dс",
                len(batch),
                last_err,
                attempt,
                max_retries,
                backoff,
            )
            time.sleep(backoff)
    logger.warning(
        "zhkh fetch: батч %d guid сбойнул после %d попыток (%s), пропущен",
        len(batch),
        max_retries,
        last_err,
    )
    return []


def fetch_zhkh(
    guids: list[str],
    *,
    client: httpx.Client,
    sleep_sec: float = 0.4,
    max_retries: int = 3,
) -> dict[str, ZhkhHouse]:
    """Бьёт guid на батчи по BATCH_SIZE, POST'ит каждый, парсит items → dict по house_guid.

    Между батчами — пауза sleep_sec (вежливость). Прогресс логируется каждые 10 батчей.
    Сбойный батч пропускается (см. _fetch_batch), а не срывает весь прогон.
    """
    result: dict[str, ZhkhHouse] = {}
    batches = list(_chunk_guids(guids, BATCH_SIZE))
    total = len(batches)
    for idx, batch in enumerate(batches, start=1):
        for item in _fetch_batch(batch, client=client, max_retries=max_retries):
            parsed = _parse_item(item)
            if parsed is not None:
                result[parsed.house_guid] = parsed
        if idx % 10 == 0 or idx == total:
            logger.info("zhkh fetch: батч %d/%d, резолвлено домов=%d", idx, total, len(result))
        if idx < total and sleep_sec > 0:
            time.sleep(sleep_sec)
    return result


def fetch_by_cadastre(cad: str, *, client: httpx.Client, max_retries: int = 3) -> ZhkhHouse | None:
    """POST searchByAddress по кадастру (одиночный cadastreNumber) → первый item → ZhkhHouse|None.

    Тот же ретрай-паттерн, что и _fetch_batch: 403/5xx/httpx-ошибки → backoff 1,2,4с и повтор;
    прочие 4xx (битый запрос) → None без ретрая; пустой ответ / нет items → None.
    """
    body = _body_by_cadastre(cad)
    last_err = ""
    for attempt in range(1, max_retries + 1):
        try:
            resp = client.post(ENDPOINT, json=body, headers=_headers())
            if resp.status_code != 403 and resp.status_code < 500:
                resp.raise_for_status()  # прочие 4xx → невосстановимый HTTPStatusError
                data = resp.json()
                items = data.get("items")
                if not isinstance(items, list) or not items:
                    return None
                return _parse_item(items[0])
            last_err = f"HTTP {resp.status_code}"
        except httpx.HTTPStatusError as exc:
            logger.warning("zhkh cadastre: %s — невосстановимый %s (пропущен)", cad, exc)
            return None
        except (httpx.HTTPError, ValueError) as exc:
            last_err = repr(exc)
        if attempt < max_retries:
            backoff = 2 ** (attempt - 1)
            logger.warning(
                "zhkh cadastre: %s сбой (%s), retry %d/%d через %dс",
                cad,
                last_err,
                attempt,
                max_retries,
                backoff,
            )
            time.sleep(backoff)
    logger.warning(
        "zhkh cadastre: %s сбойнул после %d попыток (%s), пропущен", cad, max_retries, last_err
    )
    return None


# ─────────────────────────────────────────────────────────────────────────────
# UPDATE houses по gar_house_guid (== ЖКХ houseGuid)
# ─────────────────────────────────────────────────────────────────────────────
# Идемпотентный UPDATE: gate `IS DISTINCT FROM` → повторный прогон не трогает уже совпавшие
# строки. CAST(:x AS type) — psycopg v3 discipline (никогда :x::type).
_UPDATE_SQL = text(
    """
    UPDATE houses
    SET zhkh_flat_count = CAST(:fc AS int),
        zhkh_house_guid = CAST(:g AS text),
        zhkh_matched_at = now(),
        zhkh_year = CAST(:yr AS smallint),
        zhkh_floors_raw = CAST(:fr AS text)
    WHERE gar_house_guid = CAST(:g AS text)
      AND (
          zhkh_flat_count IS DISTINCT FROM CAST(:fc AS int)
          OR zhkh_year IS DISTINCT FROM CAST(:yr AS smallint)
          OR zhkh_floors_raw IS DISTINCT FROM CAST(:fr AS text)
      )
    """
)

_SELECT_GUIDS_SQL = text(
    """
    SELECT DISTINCT gar_house_guid
    FROM houses
    WHERE gar_house_guid IS NOT NULL
    """
)

# Тот же запрос с LIMIT для сэмплинга (--limit). Отдельный text() — psycopg v3 не любит
# условную инъекцию LIMIT в строку.
_SELECT_GUIDS_LIMIT_SQL = text(
    """
    SELECT DISTINCT gar_house_guid
    FROM houses
    WHERE gar_house_guid IS NOT NULL
    LIMIT CAST(:limit AS int)
    """
)


def _chunk_houses(items: list[ZhkhHouse], size: int) -> Iterator[list[ZhkhHouse]]:
    for i in range(0, len(items), size):
        yield items[i : i + size]


def match_houses_to_zhkh(
    db: Session,
    *,
    region_code: str = "66",
    limit: int | None = None,
    dry_run: bool = False,
    sleep_sec: float = 0.4,
) -> dict[str, int]:
    """ГИС ЖКХ → houses.zhkh_* по gar_house_guid (== ЖКХ houseGuid). НЕ коммитит (caller).

    region_code — информационно/в лог (все наши guid — region 66; параметр для паритета с
    ГАР-CLI). limit (если задан) ограничивает выборку домов (сэмпл). dry_run — НОЛЬ записей,
    только подсчёт. Возвращает {houses_seen, zhkh_resolved, zhkh_with_flatcount, houses_updated}.
    """
    if limit is not None:
        rows = db.execute(_SELECT_GUIDS_LIMIT_SQL, {"limit": limit}).scalars().all()
    else:
        rows = db.execute(_SELECT_GUIDS_SQL).scalars().all()
    guids = [str(g) for g in rows if g]
    houses_seen = len(guids)
    logger.info(
        "zhkh match: домов с gar_house_guid=%d (region=%s, limit=%s, dry_run=%s)",
        houses_seen,
        region_code,
        limit,
        dry_run,
    )

    with httpx.Client(timeout=40) as client:
        zhkh_by_guid = fetch_zhkh(guids, client=client, sleep_sec=sleep_sec)

    zhkh_resolved = len(zhkh_by_guid)
    with_flatcount = [
        z for z in zhkh_by_guid.values() if z.flat_count is not None and z.flat_count > 0
    ]
    zhkh_with_flatcount = len(with_flatcount)

    houses_updated = 0
    if dry_run:
        logger.info(
            "zhkh match DRY-RUN: резолвлено=%d с flat_count=%d (записей не делаем)",
            zhkh_resolved,
            zhkh_with_flatcount,
        )
    else:
        for chunk in _chunk_houses(with_flatcount, SAVEPOINT_CHUNK):
            chunk_updated = 0
            try:
                with db.begin_nested():
                    for z in chunk:
                        res = db.execute(
                            _UPDATE_SQL,
                            {
                                "fc": z.flat_count,
                                "g": z.house_guid,
                                "yr": z.year,
                                "fr": z.floors_raw,
                            },
                        )
                        chunk_updated += res.rowcount
                houses_updated += chunk_updated
            except Exception:
                logger.warning(
                    "zhkh match: чанк из %d UPDATE сбойнул (откат savepoint)",
                    len(chunk),
                    exc_info=True,
                )

    result = {
        "houses_seen": houses_seen,
        "zhkh_resolved": zhkh_resolved,
        "zhkh_with_flatcount": zhkh_with_flatcount,
        "houses_updated": houses_updated,
    }
    logger.info(
        "zhkh match DONE: seen=%d resolved=%d with_flatcount=%d updated=%d (dry_run=%s)",
        houses_seen,
        zhkh_resolved,
        zhkh_with_flatcount,
        houses_updated,
        dry_run,
    )
    return result


# ─────────────────────────────────────────────────────────────────────────────
# Cadastre-fallback: резолв ЖКХ по listings.building_cadastral_number (UPDATE по houses.id)
# ─────────────────────────────────────────────────────────────────────────────
# Гид-loader промахивается по домам, чей gar_house_guid ≠ ЖКХ houseGuid (напр. Переходный 6:
# наш guid → ЖКХ total 0, но кадастр 66:41:0604007:747 → ЖКХ 165). Здесь матчим по кадастру
# листингов и UPDATE'им строго по houses.id (ЖКХ houseGuid отличается → match по guid невозможен).
# Идемпотентность: gate `zhkh_flat_count IS NULL` → повторный прогон не трогает уже резолвленные.
_UPDATE_BY_ID_SQL = text(
    """
    UPDATE houses
    SET zhkh_flat_count = CAST(:fc AS int),
        zhkh_house_guid = CAST(:g AS text),
        zhkh_matched_at = now(),
        zhkh_year = CAST(:yr AS smallint),
        zhkh_floors_raw = CAST(:fr AS text),
        zhkh_floors = CAST(:zf AS smallint)
    WHERE id = CAST(:hid AS bigint)
      AND zhkh_flat_count IS NULL
    """
)

# Дома без ЖКХ-счёта, у которых есть кадастр здания в листингах: берём САМЫЙ ЧАСТЫЙ непустой
# building_cadastral_number на дом (DISTINCT ON + ORDER BY count(*) DESC).
_SELECT_CADASTRE_SQL = text(
    """
    SELECT DISTINCT ON (l.house_id_fk)
           l.house_id_fk, l.building_cadastral_number
    FROM listings l
    JOIN houses h ON h.id = l.house_id_fk
    WHERE h.zhkh_flat_count IS NULL
      AND l.building_cadastral_number IS NOT NULL
    GROUP BY l.house_id_fk, l.building_cadastral_number
    ORDER BY l.house_id_fk, count(*) DESC
    """
)

# Тот же запрос с LIMIT (сэмпл): LIMIT применяется ПОСЛЕ DISTINCT ON → ограничивает число домов.
_SELECT_CADASTRE_LIMIT_SQL = text(
    """
    SELECT DISTINCT ON (l.house_id_fk)
           l.house_id_fk, l.building_cadastral_number
    FROM listings l
    JOIN houses h ON h.id = l.house_id_fk
    WHERE h.zhkh_flat_count IS NULL
      AND l.building_cadastral_number IS NOT NULL
    GROUP BY l.house_id_fk, l.building_cadastral_number
    ORDER BY l.house_id_fk, count(*) DESC
    LIMIT CAST(:limit AS int)
    """
)


def _chunk_targets(targets: list[tuple[int, str]], size: int) -> Iterator[list[tuple[int, str]]]:
    for i in range(0, len(targets), size):
        yield targets[i : i + size]


def match_houses_to_zhkh_by_cadastre(
    db: Session,
    *,
    limit: int | None = None,
    dry_run: bool = False,
    sleep_sec: float = 0.5,
    cooldown: int = 150,
    chunk: int = 15,
) -> dict[str, int]:
    """Cadastre-fallback резолв ЖКХ для домов, чей gar_house_guid ≠ ЖКХ houseGuid.

    Выбирает дома без zhkh_flat_count, у которых есть building_cadastral_number в листингах
    (самый частый непустой кадастр на дом), ищет каждый дом в ГИС ЖКХ по cadastreNumber и при
    flat_count>0 UPDATE'ит houses ПО id (ЖКХ houseGuid отличается → match по guid невозможен,
    см. Переходный 6).

    Throttle-aware: лукапы режутся на чанки по `chunk`, между чанками — time.sleep(cooldown)
    (публичное API лимитит ~per-window, как и батч-путь); sleep_sec — пауза между отдельными
    запросами. Коммит per-chunk (не dry-run) → прогресс длинного прогона переживает обрыв.
    dry_run → НОЛЬ записей (только подсчёт резолвимых). Идемпотентно (gate zhkh_flat_count IS NULL).
    Возвращает {houses_seen, resolved, updated}.
    """
    if limit is not None:
        rows = db.execute(_SELECT_CADASTRE_LIMIT_SQL, {"limit": limit}).all()
    else:
        rows = db.execute(_SELECT_CADASTRE_SQL).all()
    targets: list[tuple[int, str]] = [
        (int(r.house_id_fk), str(r.building_cadastral_number)) for r in rows
    ]
    houses_seen = len(targets)
    logger.info(
        "zhkh cadastre match: домов без ЖКХ с кадастром=%d (limit=%s, dry_run=%s, chunk=%d, "
        "cooldown=%dс, sleep=%.2fс)",
        houses_seen,
        limit,
        dry_run,
        chunk,
        cooldown,
        sleep_sec,
    )

    resolved = 0
    updated = 0
    batches = list(_chunk_targets(targets, chunk))
    total_chunks = len(batches)
    with httpx.Client(timeout=40) as client:
        for chunk_idx, batch in enumerate(batches, start=1):
            for hid, cad in batch:
                zh = fetch_by_cadastre(cad, client=client)
                if zh is not None and zh.flat_count is not None and zh.flat_count > 0:
                    resolved += 1
                    if not dry_run:
                        zf = _parse_floors_max(zh.floors_raw)
                        try:
                            with db.begin_nested():
                                res = db.execute(
                                    _UPDATE_BY_ID_SQL,
                                    {
                                        "fc": zh.flat_count,
                                        "g": zh.house_guid,
                                        "yr": zh.year,
                                        "fr": zh.floors_raw,
                                        "zf": zf,
                                        "hid": hid,
                                    },
                                )
                                updated += res.rowcount
                        except Exception:
                            logger.warning(
                                "zhkh cadastre match: UPDATE house_id=%d cad=%s сбойнул "
                                "(откат savepoint)",
                                hid,
                                cad,
                                exc_info=True,
                            )
                if sleep_sec > 0:
                    time.sleep(sleep_sec)
            if not dry_run:
                db.commit()  # фиксируем прогресс чанка до cooldown
            logger.info(
                "zhkh cadastre match: чанк %d/%d, резолвлено=%d, updated=%d",
                chunk_idx,
                total_chunks,
                resolved,
                updated,
            )
            if chunk_idx < total_chunks and cooldown > 0:
                time.sleep(cooldown)

    result = {"houses_seen": houses_seen, "resolved": resolved, "updated": updated}
    logger.info(
        "zhkh cadastre match DONE: seen=%d resolved=%d updated=%d (dry_run=%s)",
        houses_seen,
        resolved,
        updated,
        dry_run,
    )
    return result
