"""ГАР/ФИАС loader: «квартир на дом» (знаменатель «доли квартир дома в продаже»).

Парсит региональный ГАР-дамп (новый формат, по-региональная папка, напр. `66/`) и
наполняет таблицу `gar_house_flats` числом помещений-квартир под каждым домом, затем
матчит к `houses` по НОРМАЛИЗОВАННОМУ адресу (`tradein_normalize_short_addr`) и
проставляет `houses.gar_flat_count` (мигр. 143).

Формат ГАР (данные — в АТРИБУТАХ элементов, не в тексте):
  - AS_ADDR_OBJ_*.XML    — адресные объекты (регион/город/улица).
        OBJECTID, OBJECTGUID, NAME, TYPENAME, LEVEL, ISACTUAL, ISACTIVE.
  - AS_HOUSES_*.XML      — дома.
        OBJECTID, OBJECTGUID, HOUSENUM, ADDNUM1/2, HOUSETYPE, ADDTYPE1/2, ISACTUAL, ISACTIVE.
  - AS_APARTMENTS_*.XML  — помещения/квартиры.
        OBJECTID, NUMBER, APARTTYPE, ISACTUAL, ISACTIVE.
  - AS_MUN_HIERARCHY_*.XML — иерархия (приоритетна; ADM — fallback).
        OBJECTID, PARENTOBJID, ISACTIVE — объект OBJECTID лежит под PARENTOBJID.

Подсчёт квартир: число OBJECTID помещений, чей PARENTOBJID в иерархии == OBJECTID дома
(помещения — прямые дети дома). Адрес дома: parent дома в иерархии = улица/адр-объект;
поднимаемся по parent'ам до города/региона, собирая «<TYPENAME NAME>» сегменты, + номер дома.

Память: ВСЕ файлы (особенно AS_APARTMENTS/AS_MUN_HIERARCHY для региона 66) — многогигабайтные.
Парсим стримингом через `lxml.etree.iterparse` с очисткой обработанных элементов + их
предыдущих сиблингов (никогда не грузим дерево целиком). Единственная крупная структура в
памяти — set OBJECTID активных помещений (≈десятки МБ на регион).

Соответствие нормализации: `norm_address` считается тем же `tradein_normalize_short_addr`,
что применяется к адресам `houses` при матче (мигр. 063), но — для устойчивости к
не-ЕКБ-населённым пунктам — поверх «улица + номер дома» строки (эквивалентно
fn(full_address) для ЕКБ, где fn срезает регион/город). full_address хранится отдельно как
человекочитаемая provenance-цепочка.

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

from __future__ import annotations

import logging
import os
import time
from collections import defaultdict
from collections.abc import Iterator
from dataclasses import dataclass, field

from lxml import etree
from sqlalchemy import text
from sqlalchemy.orm import Session

logger = logging.getLogger(__name__)

# Глобы файлов ГАР (матчим case-insensitive: .XML/.xml).
ADDR_OBJ_PREFIX = "AS_ADDR_OBJ_"
HOUSES_PREFIX = "AS_HOUSES_"
APARTMENTS_PREFIX = "AS_APARTMENTS_"
MUN_HIER_PREFIX = "AS_MUN_HIERARCHY_"
ADM_HIER_PREFIX = "AS_ADM_HIERARCHY_"  # fallback, если MUN отсутствует

DEFAULT_BATCH_SIZE = 2000

# ADDTYPE дома (AS_ADDHOUSE_TYPES): 1=Корпус, 2=Строение, 3=Сооружение. Рендер — аббревиатура.
_ADDTYPE_ABBR: dict[str, str] = {"1": "к", "2": "стр", "3": "соор"}

# LEVEL/типы региона-уровня: рендерим «<NAME> <TYPENAME>» (Свердловская обл), остальное —
# «<TYPENAME> <NAME>» (ул Шаумяна / г Екатеринбург). Косметика full_address (для людей);
# на матч не влияет (norm_address считается из «улица + номер»).
_REGION_LIKE_TYPES = {"обл", "область", "край", "респ", "республика", "ао", "аобл"}


# ─────────────────────────────────────────────────────────────────────────────
# In-memory модели
# ─────────────────────────────────────────────────────────────────────────────
@dataclass(slots=True)
class AddrObj:
    object_id: int
    guid: str
    name: str
    typename: str
    level: str


@dataclass(slots=True)
class GarHouse:
    object_id: int
    guid: str
    housenum: str
    addnum1: str
    addnum2: str
    addtype1: str
    addtype2: str


@dataclass(slots=True)
class GarHouseRow:
    """Строка-приёмник для gar_house_flats (norm_address считает SQL-fn в INSERT)."""

    house_guid: str
    object_id: int
    region_code: str
    flat_count: int
    full_address: str
    street_name: str | None
    house_num: str
    norm_src: str  # вход для tradein_normalize_short_addr (улица + номер)
    gar_version: str

    def to_params(self) -> dict[str, object]:
        return {
            "house_guid": self.house_guid,
            "object_id": self.object_id,
            "region_code": self.region_code,
            "flat_count": self.flat_count,
            "full_address": self.full_address,
            "street_name": self.street_name,
            "house_num": self.house_num,
            "norm_src": self.norm_src,
            "gar_version": self.gar_version,
        }


@dataclass(slots=True)
class GarParseStats:
    addr_objects: int = 0
    houses: int = 0
    apartments: int = 0  # суммарно активных помещений (= sum(flat_count))
    rows: int = 0  # построенных строк gar_house_flats


# ─────────────────────────────────────────────────────────────────────────────
# Поиск файлов
# ─────────────────────────────────────────────────────────────────────────────
def _find_files(dir_path: str, prefix: str) -> list[str]:
    """Файлы dir_path, чьё имя начинается с prefix и кончается .xml (case-insensitive)."""
    out: list[str] = []
    for name in sorted(os.listdir(dir_path)):
        upper = name.upper()
        if upper.startswith(prefix.upper()) and upper.endswith(".XML"):
            out.append(os.path.join(dir_path, name))
    return out


# ─────────────────────────────────────────────────────────────────────────────
# Стриминговый парсер (lxml iterparse + очистка)
# ─────────────────────────────────────────────────────────────────────────────
def _local_name(tag: object) -> str:
    """Локальное имя тега без namespace; '' для не-элементов (комментарии/PI)."""
    if not isinstance(tag, str):
        return ""
    return tag.rsplit("}", 1)[-1]


def _stream_rows(path: str, marker_attr: str) -> Iterator[etree._Element]:
    """Yield row-элементов файла, у которых есть атрибут marker_attr, освобождая память.

    После выдачи элемента (caller обязан прочитать атрибуты СРАЗУ в теле цикла) элемент
    очищается, а его уже обработанные предыдущие сиблинги удаляются из дерева — так
    память не растёт на многогигабайтных файлах. `huge_tree=True` снимает security-лимит
    libxml2 на размер; `recover=True` терпит мелкие дефекты XML.
    """
    context = etree.iterparse(
        path, events=("end",), recover=True, huge_tree=True, resolve_entities=False
    )
    for _event, elem in context:
        is_elem = isinstance(elem.tag, str)
        if is_elem and marker_attr in elem.attrib:
            yield elem
        # Чистим текущий элемент и срезаем предшествующие сиблинги (bounded memory).
        elem.clear()
        parent = elem.getparent()
        if parent is not None:
            while elem.getprevious() is not None:
                del parent[0]
    del context


def _is_active(elem: etree._Element, *, need_actual: bool) -> bool:
    """ISACTIVE='1' (+ опц. ISACTUAL='1'). Отсутствующий атрибут считаем НЕ активным."""
    if elem.get("ISACTIVE") != "1":
        return False
    if need_actual and elem.get("ISACTUAL") != "1":
        return False
    return True


def _to_int(value: str | None) -> int | None:
    if value is None:
        return None
    value = value.strip()
    if not value:
        return None
    try:
        return int(value)
    except ValueError:
        return None


def parse_addr_objects(paths: list[str]) -> dict[int, AddrObj]:
    """OBJECTID → AddrObj по активным (ISACTUAL=1 AND ISACTIVE=1) адресным объектам."""
    out: dict[int, AddrObj] = {}
    for path in paths:
        for elem in _stream_rows(path, "OBJECTGUID"):
            if not _is_active(elem, need_actual=True):
                continue
            oid = _to_int(elem.get("OBJECTID"))
            guid = elem.get("OBJECTGUID")
            if oid is None or not guid:
                continue
            out[oid] = AddrObj(
                object_id=oid,
                guid=guid,
                name=(elem.get("NAME") or "").strip(),
                typename=(elem.get("TYPENAME") or "").strip(),
                level=(elem.get("LEVEL") or "").strip(),
            )
    return out


def parse_houses(paths: list[str]) -> dict[int, GarHouse]:
    """OBJECTID → GarHouse по активным (ISACTUAL=1 AND ISACTIVE=1) домам."""
    out: dict[int, GarHouse] = {}
    for path in paths:
        for elem in _stream_rows(path, "HOUSENUM"):
            if not _is_active(elem, need_actual=True):
                continue
            oid = _to_int(elem.get("OBJECTID"))
            guid = elem.get("OBJECTGUID")
            if oid is None or not guid:
                continue
            out[oid] = GarHouse(
                object_id=oid,
                guid=guid,
                housenum=(elem.get("HOUSENUM") or "").strip(),
                addnum1=(elem.get("ADDNUM1") or "").strip(),
                addnum2=(elem.get("ADDNUM2") or "").strip(),
                addtype1=(elem.get("ADDTYPE1") or "").strip(),
                addtype2=(elem.get("ADDTYPE2") or "").strip(),
            )
    return out


def parse_apartment_ids(paths: list[str]) -> set[int]:
    """Set OBJECTID активных (ISACTUAL=1 AND ISACTIVE=1) помещений/квартир.

    Тип помещения (APARTTYPE) не фильтруем — знаменатель = все активные помещения дома
    (квартиры + нежилые); это сознательное приближение «всего квартир в доме».
    """
    out: set[int] = set()
    for path in paths:
        for elem in _stream_rows(path, "OBJECTID"):
            if not _is_active(elem, need_actual=True):
                continue
            oid = _to_int(elem.get("OBJECTID"))
            if oid is not None:
                out.add(oid)
    return out


def parse_hierarchy(
    paths: list[str],
    apartment_ids: set[int],
    house_ids: set[int],
    addr_ids: set[int],
) -> tuple[dict[int, int], dict[int, int]]:
    """Единый стриминговый проход по иерархии (только активные ISACTIVE=1).

    Возвращает (apt_count, child_to_parent):
      - apt_count[parent_house_objectid] = число помещений-детей этого дома. Считается
        проверкой членства OBJECTID в apartment_ids; каждый помещение учитывается ОДИН раз
        (discard из set после учёта — защита от дубль-строк иерархии + bounded memory).
      - child_to_parent[oid] = PARENTOBJID, но ТОЛЬКО для домов и адр-объектов (нужно для
        сборки адреса) — иерархия региона целиком в память не грузится.
    """
    apt_count: dict[int, int] = defaultdict(int)
    child_to_parent: dict[int, int] = {}
    for path in paths:
        for elem in _stream_rows(path, "PARENTOBJID"):
            if elem.get("ISACTIVE") != "1":
                continue
            oid = _to_int(elem.get("OBJECTID"))
            pid = _to_int(elem.get("PARENTOBJID"))
            if oid is None or pid is None:
                continue
            if oid in apartment_ids:
                apt_count[pid] += 1
                apartment_ids.discard(oid)
            if oid in house_ids or oid in addr_ids:
                child_to_parent[oid] = pid
    return dict(apt_count), child_to_parent


# ─────────────────────────────────────────────────────────────────────────────
# Сборка адреса
# ─────────────────────────────────────────────────────────────────────────────
def _render_segment(ao: AddrObj) -> str:
    """Косметический рендер адр-сегмента для full_address."""
    name = ao.name.strip()
    typ = ao.typename.strip()
    if not typ:
        return name
    if typ.lower() in _REGION_LIKE_TYPES:
        return f"{name} {typ}".strip()
    return f"{typ} {name}".strip()


def render_house_num(house: GarHouse) -> str:
    """Номер дома: HOUSENUM (+ корпус/строение из ADDTYPE/ADDNUM, если есть)."""
    parts: list[str] = []
    if house.housenum:
        parts.append(house.housenum)
    if house.addnum1:
        abbr = _ADDTYPE_ABBR.get(house.addtype1, "")
        parts.append(f"{abbr}{house.addnum1}".strip())
    if house.addnum2:
        abbr = _ADDTYPE_ABBR.get(house.addtype2, "")
        parts.append(f"{abbr}{house.addnum2}".strip())
    return " ".join(p for p in parts if p)


def _walk_segments(
    start_parent: int | None,
    addr_objs: dict[int, AddrObj],
    child_to_parent: dict[int, int],
) -> list[AddrObj]:
    """Сегменты адр-объектов снизу вверх (улица → … → регион), начиная с start_parent."""
    segments: list[AddrObj] = []
    visited: set[int] = set()
    cur = start_parent
    while cur is not None and cur in addr_objs and cur not in visited:
        visited.add(cur)
        segments.append(addr_objs[cur])
        cur = child_to_parent.get(cur)
    return segments


def assemble_house_address(
    house: GarHouse,
    addr_objs: dict[int, AddrObj],
    child_to_parent: dict[int, int],
) -> tuple[str, str | None, str]:
    """(full_address, street_name, house_num) для дома, поднимаясь по иерархии parent'ов.

    street_name = непосредственный адр-родитель дома (обычно улица), «<TYPENAME> <NAME>».
    full_address = регион…город…улица (косметически) + номер дома.
    """
    house_num = render_house_num(house)
    parent_id = child_to_parent.get(house.object_id)
    segments = _walk_segments(parent_id, addr_objs, child_to_parent)

    street_name = _render_segment(segments[0]) if segments else None

    # full_address: разворачиваем (регион → улица) + номер.
    chain = [_render_segment(s) for s in reversed(segments)]
    if house_num:
        chain.append(house_num)
    full_address = ", ".join(c for c in chain if c)
    return full_address, street_name, house_num


def _norm_src(street_name: str | None, house_num: str, full_address: str) -> str:
    """Вход для tradein_normalize_short_addr: «улица, номер» (устойчиво к не-ЕКБ нас.пунктам).

    Падаем на full_address, если улица не определилась (редкий дом без адр-родителя).
    """
    if street_name and house_num:
        return f"{street_name}, {house_num}"
    if street_name:
        return street_name
    return full_address


def build_gar_rows(
    addr_objs: dict[int, AddrObj],
    houses: dict[int, GarHouse],
    apt_count: dict[int, int],
    child_to_parent: dict[int, int],
    region_code: str,
    gar_version: str,
) -> list[GarHouseRow]:
    """Строки gar_house_flats: на каждый дом — адрес + flat_count (0, если нет помещений)."""
    rows: list[GarHouseRow] = []
    for oid, house in houses.items():
        full_address, street_name, house_num = assemble_house_address(
            house, addr_objs, child_to_parent
        )
        rows.append(
            GarHouseRow(
                house_guid=house.guid,
                object_id=oid,
                region_code=region_code,
                flat_count=apt_count.get(oid, 0),
                full_address=full_address,
                street_name=street_name,
                house_num=house_num,
                norm_src=_norm_src(street_name, house_num, full_address),
                gar_version=gar_version,
            )
        )
    return rows


def parse_gar_region(
    dir_path: str, region_code: str, gar_version: str
) -> tuple[list[GarHouseRow], GarParseStats]:
    """Парсит распакованный ГАР-каталог региона → (строки gar_house_flats, статистика).

    Чистая (без БД) часть — тестируется на синтетических фикстурах.
    """
    if not os.path.isdir(dir_path):
        raise NotADirectoryError(f"GAR dir not found: {dir_path}")

    addr_paths = _find_files(dir_path, ADDR_OBJ_PREFIX)
    house_paths = _find_files(dir_path, HOUSES_PREFIX)
    apt_paths = _find_files(dir_path, APARTMENTS_PREFIX)
    hier_paths = _find_files(dir_path, MUN_HIER_PREFIX)
    if not hier_paths:
        hier_paths = _find_files(dir_path, ADM_HIER_PREFIX)
        if hier_paths:
            logger.warning("GAR %s: AS_MUN_HIERARCHY отсутствует, использую ADM fallback", dir_path)

    if not house_paths:
        raise FileNotFoundError(f"AS_HOUSES_*.XML не найдены в {dir_path}")
    if not hier_paths:
        raise FileNotFoundError(f"AS_MUN_HIERARCHY/ADM_HIERARCHY_*.XML не найдены в {dir_path}")

    logger.info(
        "GAR parse %s: addr=%d houses=%d apartments=%d hierarchy=%d файлов",
        dir_path,
        len(addr_paths),
        len(house_paths),
        len(apt_paths),
        len(hier_paths),
    )

    addr_objs = parse_addr_objects(addr_paths)
    houses = parse_houses(house_paths)
    apartment_ids = parse_apartment_ids(apt_paths)
    apartments_total = len(apartment_ids)
    logger.info(
        "GAR parse %s: addr_obj=%d houses=%d active_apartments=%d",
        dir_path,
        len(addr_objs),
        len(houses),
        apartments_total,
    )

    apt_count, child_to_parent = parse_hierarchy(
        hier_paths, apartment_ids, set(houses.keys()), set(addr_objs.keys())
    )

    rows = build_gar_rows(addr_objs, houses, apt_count, child_to_parent, region_code, gar_version)
    counted = sum(r.flat_count for r in rows)
    stats = GarParseStats(
        addr_objects=len(addr_objs),
        houses=len(houses),
        apartments=counted,
        rows=len(rows),
    )
    logger.info(
        "GAR parse %s: построено строк=%d, помещений учтено под домами=%d (из %d активных)",
        dir_path,
        stats.rows,
        counted,
        apartments_total,
    )
    return rows, stats


# ─────────────────────────────────────────────────────────────────────────────
# Upsert в gar_house_flats
# ─────────────────────────────────────────────────────────────────────────────
# norm_address считает SQL-fn прямо в INSERT (эквивалентно «UPDATE после insert»), вход —
# «улица, номер» (:norm_src). CAST(:norm_src AS text) — psycopg v3 discipline (не :x::text).
_INSERT_SQL = text(
    """
    INSERT INTO gar_house_flats (
        house_guid, object_id, region_code, flat_count,
        full_address, norm_address, street_name, house_num, gar_version
    )
    VALUES (
        :house_guid, :object_id, :region_code, :flat_count,
        :full_address,
        tradein_normalize_short_addr(CAST(:norm_src AS text)),
        :street_name, :house_num, :gar_version
    )
    ON CONFLICT (house_guid) DO UPDATE SET
        object_id    = EXCLUDED.object_id,
        region_code  = EXCLUDED.region_code,
        flat_count   = EXCLUDED.flat_count,
        full_address = EXCLUDED.full_address,
        norm_address = EXCLUDED.norm_address,
        street_name  = EXCLUDED.street_name,
        house_num    = EXCLUDED.house_num,
        loaded_at    = now(),
        gar_version  = EXCLUDED.gar_version
    """
)


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


def upsert_gar_houses(
    db: Session, rows: list[GarHouseRow], *, batch_size: int = DEFAULT_BATCH_SIZE
) -> int:
    """Батчевый UPSERT строк в gar_house_flats. НЕ коммитит (коммитит caller).

    SAVEPOINT (`begin_nested`) на КАЖДЫЙ батч: сбойный батч откатывается изолированно, не
    срывая всю транзакцию и не врёт счётчику. ON CONFLICT (house_guid) → идемпотентно.
    """
    upserted = 0
    failed_batches = 0
    for batch in _chunks(rows, batch_size):
        params = [r.to_params() for r in batch]
        try:
            with db.begin_nested():
                db.execute(_INSERT_SQL, params)
            upserted += len(batch)
        except Exception:
            failed_batches += 1
            logger.warning(
                "gar upsert: батч из %d строк сбойнул (пропущен)", len(batch), exc_info=True
            )
    if failed_batches:
        logger.warning("gar upsert: сбойных батчей=%d", failed_batches)
    logger.info("gar upsert: upsert'нуто строк=%d", upserted)
    return upserted


# ─────────────────────────────────────────────────────────────────────────────
# Матчер ГАР → houses
# ─────────────────────────────────────────────────────────────────────────────
# Идемпотентный set-based UPDATE по КАНОНИЧЕСКОМУ ключу адреса tradein_canon_addr
# (мигр. 144), а не по точному равенству нормализованного адреса: tradein_normalize_short_addr
# почти ничего не канонизирует → houses-адрес «ул. Шаумяна,20» никогда не равен ГАР
# «ул. Шаумяна, 20» и точный матч давал 0. Канон агрессивно схлопывает тип улицы / пунктуацию
# (см. tradein_canon_addr) → 0→~41% (2769/6808 вторички, ЕКБ-restricted).
#
# На каждый canon берём ОДНУ ГАР-строку (DISTINCT ON … ORDER BY flat_count DESC, house_guid) —
# детерминированный tie-break: максимальный flat_count, при равенстве — лексикографически
# меньший house_guid. Houses-сторона: tradein_canon_addr(COALESCE(short/full/address)) — тот же
# канон, что и в gar_pick. ЕКБ-ограничение (:city ILIKE по full_address) обязательно: без него
# «Машиностроителей 6» в 4 городах region 66 даёт ложные коллизии. Предикат
# `gar_flat_count IS DISTINCT FROM` → повторный прогон не трогает уже совпавшие строки.
_MATCH_SQL = text(
    """
    WITH gar_pick AS (
        SELECT DISTINCT ON (canon)
            tradein_canon_addr(norm_address) AS canon, house_guid, flat_count
        FROM gar_house_flats
        WHERE flat_count > 0
          AND tradein_canon_addr(norm_address) <> ''
          AND (
              CAST(:region AS text) IS NULL
              OR region_code = CAST(:region AS text)
          )
          AND (
              CAST(:city AS text) IS NULL
              OR full_address ILIKE '%' || CAST(:city AS text) || '%'
          )
        ORDER BY canon, flat_count DESC, house_guid
    )
    UPDATE houses h
    SET gar_house_guid   = gp.house_guid,
        gar_flat_count   = gp.flat_count,
        gar_matched_at   = now(),
        gar_match_method = 'canon_addr'
    FROM gar_pick gp
    WHERE tradein_canon_addr(
              COALESCE(h.short_address, h.full_address, h.address)
          ) = gp.canon
      AND h.gar_flat_count IS DISTINCT FROM gp.flat_count
    """
)


def match_houses_to_gar(
    db: Session,
    *,
    region_code: str | None = None,
    city_filter: str | None = "Екатеринбург",
) -> int:
    """Матч gar_house_flats → houses по КАНОНИЧЕСКОМУ адресу (мигр. 144). НЕ коммитит (caller).

    city_filter (умолч. «Екатеринбург») ограничивает ГАР-сторону по full_address ILIKE —
    защита от cross-town коллизий внутри region 66; None отключает фильтр (city-aware матч
    за пределами ЕКБ — future work). Возвращает число обновлённых домов. Идемпотентно
    (plain UPDATE, IS DISTINCT FROM gate).
    """
    result = db.execute(_MATCH_SQL, {"region": region_code, "city": city_filter})
    matched = result.rowcount
    logger.info(
        "gar match: проставлено gar_flat_count домам=%d (region=%s, city=%s)",
        matched,
        region_code,
        city_filter,
    )
    return matched


# ─────────────────────────────────────────────────────────────────────────────
# Orchestration
# ─────────────────────────────────────────────────────────────────────────────
@dataclass(slots=True)
class GarLoadResult:
    addr_objects: int = 0
    houses: int = 0
    apartments: int = 0
    upserted: int = 0
    duration_sec: float = field(default=0.0)

    def to_counts(self) -> dict[str, int]:
        return {
            "addr_objects": self.addr_objects,
            "houses": self.houses,
            "apartments": self.apartments,
            "upserted": self.upserted,
            "duration_sec": int(self.duration_sec),
        }


def load_gar_region(
    dir_path: str,
    region_code: str,
    gar_version: str,
    *,
    db: Session | None = None,
    batch_size: int = DEFAULT_BATCH_SIZE,
) -> GarLoadResult:
    """Парс ГАР-каталога региона → UPSERT в gar_house_flats (коммит). Без матча.

    Возвращает счётчики (houses, apartments, upserted). Если db не передан — открывает
    собственную SessionLocal. Матч (match_houses_to_gar) — отдельный шаг (см. CLI).
    """
    start = time.monotonic()
    own_session = db is None
    if db is None:
        from app.core.db import SessionLocal

        db = SessionLocal()

    try:
        rows, stats = parse_gar_region(dir_path, region_code, gar_version)
        upserted = upsert_gar_houses(db, rows, batch_size=batch_size)
        db.commit()
        result = GarLoadResult(
            addr_objects=stats.addr_objects,
            houses=stats.houses,
            apartments=stats.apartments,
            upserted=upserted,
            duration_sec=time.monotonic() - start,
        )
        logger.info(
            "load_gar_region: region=%s version=%s houses=%d apartments=%d upserted=%d "
            "duration=%.1fs",
            region_code,
            gar_version,
            result.houses,
            result.apartments,
            result.upserted,
            result.duration_sec,
        )
        return result
    except Exception:
        logger.exception("load_gar_region: region=%s FAILED", region_code)
        try:
            db.rollback()
        except Exception:
            pass
        raise
    finally:
        if own_session:
            db.close()
