"""Backfill objective_complex_mapping (#203).

Auto-match DOM.РФ комплексов к Objective dataset через fuzzy name matching.
Goal: coverage 3% → 40%+ для mv_layout_velocity.

Schema facts (confirmed via pg MCP):
- domrf_kn_objects: фильтр is_ekb = true (~1285 ЕКБ объектов).
  district_name ILIKE '%екатеринбург%' возвращает 0 строк — не использовать.
- objective_corpus_room_month: group_name = 'Екатеринбург' (единственное значение),
  263 distinct project_name.
- objective_complex_mapping: UNIQUE (objective_complex_name, objective_group),
  колонки match_method + match_score + is_reviewed поддерживают audit trail.

match_method history:
  'fuzzy'          — legacy Anton SQLite import (avg score 0.98, 127 rows)
  'fuzzy_trgm'     — pg_trgm backfill, auto-accept threshold=0.85 (первый запуск)
  'fuzzy_v2'       — pg_trgm backfill, pruned threshold=0.80 (второй запуск, #44)
  'auto_core_dev_v5' — core-name normalize (#2198 normalize_complex_name) обеих сторон +
                       dev-confirm. trgm по СЫРЫМ именам давал ~300 маппингов, 1253 obj_id
                       остались (низкая similarity: «Жилой комплекс "Малевич" (ЖК "Малевич")»
                       vs «Малевич»). Ядро-нормализация схлопывает обёртки → exact-core match;
                       dev-подтверждение (пересечение токенов / подстрока) отсеивает
                       одноимённые чужие проекты (Объектив = только ЕКБ). #2177 шаг 2.
  'auto_core_geo_v6' — гео-разрешение остатка core-pass (tier_b / ambiguous) через
                       DaData-геокод. Адрес objective-проекта (mode objective_lots.
                       address per project) → clean_address → (lat, lon); haversine
                       до domrf-точки (domrf_kn_objects.latitude/longitude, latest
                       snapshot). tier_b: дистанция ≤ порога → confirm (гео вместо
                       dev). ambiguous: геокодим ВСЕХ кандидатов ядра, confirm только
                       если РОВНО ОДИН в радиусе. score 0.80, is_reviewed=false. #2177.
  'manual'         — ручная корректура
"""

from __future__ import annotations

import argparse
import logging
import math
import re
import sys
from dataclasses import dataclass, field

from sqlalchemy import text
from sqlalchemy.orm import Session

from app.services.dadata_client import clean_address
from app.services.site_finder.best_layouts import normalize_complex_name

logger = logging.getLogger(__name__)

# Порог для auto-insert (высокая уверенность)
AUTO_ACCEPT_THRESHOLD = 0.85

# Pruned threshold для v2 run (ниже уверенность, is_reviewed=false → review queue)
AUTO_ACCEPT_THRESHOLD_V2 = 0.80

# Порог для review queue (средняя уверенность — Phase 2)
REVIEW_THRESHOLD = 0.6


@dataclass
class MatchCandidate:
    """Один candidate match DOM.РФ ↔ Objective."""

    domrf_obj_id: int
    domrf_comm_name: str
    domrf_dev_name: str | None
    objective_project_name: str
    similarity_score: float  # 0.0..1.0


def find_match_candidates(
    db: Session,
    *,
    only_unmapped: bool = True,
    min_threshold: float | None = None,
    limit: int | None = None,
) -> list[MatchCandidate]:
    """Поиск candidates через pg_trgm similarity.

    Использует CROSS JOIN LATERAL + similarity() для fuzzy match
    comm_name (DOM.РФ) ↔ project_name (Objective).

    Args:
        db: SQLAlchemy sync Session.
        only_unmapped: Если True — пропускает уже-mapped obj_id.
        min_threshold: Нижняя граница similarity для фильтрации кандидатов.
            По умолчанию REVIEW_THRESHOLD (0.6).
        limit: Максимальное число строк результата (для тестирования).

    Returns:
        Список MatchCandidate, отсортированных по убыванию similarity_score.
    """
    effective_min = min_threshold if min_threshold is not None else REVIEW_THRESHOLD

    # Формируем SQL. LIMIT добавляем через int() — SQL injection safe (только число).
    limit_clause = f"LIMIT {int(limit)}" if limit is not None else ""

    sql = text(
        f"""
        WITH domrf_unmapped AS (
            SELECT o.obj_id, o.comm_name, o.dev_name
            FROM domrf_kn_objects o
            WHERE o.is_ekb = true
              AND o.comm_name IS NOT NULL
              AND (
                  CAST(:only_unmapped AS boolean) = FALSE
                  OR NOT EXISTS (
                      SELECT 1 FROM objective_complex_mapping cm
                      WHERE cm.domrf_obj_id = o.obj_id
                  )
              )
        ),
        objective_distinct AS (
            SELECT DISTINCT project_name
            FROM objective_corpus_room_month
            WHERE group_name = 'Екатеринбург'
        )
        SELECT
            d.obj_id,
            d.comm_name,
            d.dev_name,
            obj.project_name,
            similarity(d.comm_name, obj.project_name) AS sim_score
        FROM domrf_unmapped d
        CROSS JOIN LATERAL (
            SELECT project_name
            FROM objective_distinct
            WHERE similarity(d.comm_name, project_name) > 0.4
            ORDER BY similarity(d.comm_name, project_name) DESC
            LIMIT 1
        ) obj
        WHERE similarity(d.comm_name, obj.project_name) >= CAST(:min_threshold AS float)
        ORDER BY sim_score DESC
        {limit_clause}
        """
    )

    rows = db.execute(
        sql,
        {"only_unmapped": only_unmapped, "min_threshold": effective_min},
    ).all()

    return [
        MatchCandidate(
            domrf_obj_id=int(r[0]),
            domrf_comm_name=str(r[1]),
            domrf_dev_name=str(r[2]) if r[2] is not None else None,
            objective_project_name=str(r[3]),
            similarity_score=float(r[4]),
        )
        for r in rows
    ]


def auto_apply_matches(
    db: Session,
    candidates: list[MatchCandidate],
    *,
    threshold: float = AUTO_ACCEPT_THRESHOLD,
    match_method: str = "fuzzy_trgm",
    dry_run: bool = False,
) -> dict[str, int]:
    """Apply candidates с score >= threshold в objective_complex_mapping.

    Кандидаты ниже threshold, но >= REVIEW_THRESHOLD попадают в review_queue
    (Phase 2 — UI для ручного review, в этом PR только считаются).

    ON CONFLICT DO NOTHING — если пара (objective_complex_name, objective_group)
    уже существует, строка пропускается без ошибки.

    Args:
        db: SQLAlchemy sync Session.
        candidates: Список из find_match_candidates().
        threshold: Минимальный score для auto-insert (default 0.85).
        match_method: Значение колонки match_method в БД. По умолчанию 'fuzzy_trgm'.
            Для re-run с пониженным порогом передавать 'fuzzy_v2'.
        dry_run: Если True — только логирует, не пишет в БД.

    Returns:
        dict с ключами auto_accepted, review_queue, skipped.
    """
    auto = [c for c in candidates if c.similarity_score >= threshold]
    review = [c for c in candidates if REVIEW_THRESHOLD <= c.similarity_score < threshold]

    if dry_run:
        logger.info(
            "DRY RUN: would auto-accept %d, review queue %d",
            len(auto),
            len(review),
        )
        return {"auto_accepted": len(auto), "review_queue": len(review), "skipped": 0}

    inserted = 0
    skipped = 0
    for c in auto:
        try:
            with db.begin_nested():
                result = db.execute(
                    text(
                        """
                        INSERT INTO objective_complex_mapping
                            (objective_complex_name, domrf_obj_id, objective_group,
                             match_method, match_score, is_reviewed)
                        VALUES (
                            CAST(:name AS text),
                            CAST(:obj_id AS bigint),
                            CAST(:group AS text),
                            CAST(:method AS text),
                            CAST(:score AS numeric),
                            CAST(:reviewed AS boolean)
                        )
                        ON CONFLICT (objective_complex_name, objective_group) DO NOTHING
                        """
                    ),
                    {
                        "name": c.objective_project_name,
                        "obj_id": c.domrf_obj_id,
                        "group": "Екатеринбург",
                        "method": match_method,
                        "score": c.similarity_score,
                        "reviewed": False,
                    },
                )
            if result.rowcount > 0:
                inserted += 1
            else:
                skipped += 1
        except Exception as e:
            logger.warning(
                "Insert failed для %s ↔ %s: %s",
                c.domrf_comm_name,
                c.objective_project_name,
                e,
            )
            skipped += 1

    db.commit()
    logger.info(
        "Backfill complete: auto_accepted=%d skipped=%d review_queue=%d",
        inserted,
        skipped,
        len(review),
    )
    return {"auto_accepted": inserted, "review_queue": len(review), "skipped": skipped}


def trigger_mv_refresh(db: Session) -> int:
    """REFRESH mv_layout_velocity после backfill.

    Вызывает существующий helper из layout_velocity_refresh.
    Передаём concurrently=True (MV уже заполнен).
    """
    from app.services.site_finder.layout_velocity_refresh import refresh_layout_velocity

    return refresh_layout_velocity(db, concurrently=True)


# ════════════════════════════════════════════════════════════════════════════
# Core-name pass (#2177 шаг 2) — auto_core_dev_v5
# ════════════════════════════════════════════════════════════════════════════

# objective_group у всех существующих строк mapping = 'Екатеринбург'
# (Объектив покрывает только ЕКБ). Новые строки пишем с тем же значением.
OBJECTIVE_GROUP = "Екатеринбург"

CORE_MATCH_METHOD = "auto_core_dev_v5"
CORE_MATCH_SCORE = 0.90
CORE_MATCH_NOTE = "#2177 step2: exact-core + dev-confirm"

# Префиксы/обёртки орг-формы застройщика, срезаемые перед сравнением.
# Порядок применения не важен (все режутся из целого норм-имени).
_DEV_JUNK_RE = re.compile(
    r"""
    \b(?:
        ооо | оао | пао | зао | ао | ип
      | сз                              # СЗ = специализированный застройщик (аббрев.)
      | специализированн\w*             # «специализированный застройщик»
      | застройщик\w*
      | девелопмент\w*
      | групп\w*                        # групп / группа
      | компани\w*
      | инвест\w*                       # инвест / инвестиции (частый шум)
      | строительн\w*
    )\b
    """,
    re.VERBOSE,
)


def _norm_dev(raw: str | None) -> str:
    """Нормализовать имя застройщика до сравниваемого ядра.

    lower → срез кавычек/скобок → срез орг-формы (ООО/АО/ПАО/ЗАО/«СЗ»/
    «специализированный застройщик»/«девелопмент»/«групп(а)») → схлопывание
    пробелов. Пустой / None → ''.

    Namespaced на _NAME_-уровне: возвращаем очищенную строку, дальнейшая
    классификация (пересечение токенов / подстрока) — в find_core_matches.
    """
    if not raw:
        return ""
    name = raw.strip().lower()
    # убрать кавычки-ёлочки/лапки/скобки — оставить только содержательные символы
    name = re.sub(r"[«»\"'`()\[\]]", " ", name)
    name = _DEV_JUNK_RE.sub(" ", name)
    # мусорные разделители → пробел (точки в «Астон.» и т.п. не трогаем на dev-стороне:
    # dev-имена обычно без них, но на всякий нормализуем распространённые)
    name = re.sub(r"[.,;/\\]", " ", name)
    name = re.sub(r"\s+", " ", name).strip()
    return name


def _dev_confirms(domrf_dev: str, objective_devs: set[str]) -> bool:
    """True если domrf-застройщик подтверждает связь с objective-проектом.

    У одного objective project_name бывает НЕСКОЛЬКО developers (SPV / очереди) —
    подтверждение по ЛЮБОМУ из них. Критерий (любой):
      • пересечение токенов норм-имён (общий содержательный токен), ИЛИ
      • одно норм-имя — подстрока другого (в любую сторону), НО только когда
        короткая сторона ≥ 4 символов: «дом»/«юг»/«ак» как подстрока чужого
        длинного имени («Домострой», «Югстройинвест») — ложное подтверждение,
        а именно dev-confirm — последняя защита от generic-core коллизий.

    Пустые имена не подтверждают (нет сигнала).
    """
    d = _norm_dev(domrf_dev)
    if not d:
        return False
    d_tokens = {t for t in d.split() if len(t) >= 3}
    for raw_odev in objective_devs:
        o = _norm_dev(raw_odev)
        if not o:
            continue
        if min(len(d), len(o)) >= 4 and (d in o or o in d):
            return True
        o_tokens = {t for t in o.split() if len(t) >= 3}
        if d_tokens & o_tokens:
            return True
    return False


@dataclass
class CoreMatch:
    """Один core-match domrf-объекта к objective-проекту."""

    domrf_obj_id: int
    domrf_comm_name: str
    domrf_dev_name: str | None
    core: str
    objective_project_name: str
    objective_project_id: int | None
    objective_developers: list[str]
    dev_confirmed: bool


@dataclass
class CoreMatchReport:
    """Итог классификации core-pass.

    tier_a  — ровно 1 objective-кандидат И dev-подтверждён → пишем в БД.
    tier_b  — ровно 1 кандидат, dev НЕ подтверждён → кандидат на DaData-гео
              проход, в БД НЕ пишем (только отчёт).
    ambiguous — >1 objective-кандидатов по core → в отчёт, разрешение вручную/гео.
    skipped_taken — objective_complex_name уже занят в mapping (UNIQUE-констрейнт;
                    его domrf-группа уже покрыта — дубли не нужны).
    """

    tier_a: list[CoreMatch] = field(default_factory=list)
    tier_b: list[CoreMatch] = field(default_factory=list)
    ambiguous: list[CoreMatch] = field(default_factory=list)
    skipped_taken: list[CoreMatch] = field(default_factory=list)

    def counts(self) -> dict[str, int]:
        return {
            "tier_a": len(self.tier_a),
            "tier_b": len(self.tier_b),
            "ambiguous": len(self.ambiguous),
            "skipped_taken": len(self.skipped_taken),
        }


# ── SQL: несопоставленные domrf ЕКБ-объекты, latest snapshot per obj_id ───────
# ЛОВУШКА is_ekb (прод-проверка 2026-07-03): флаг проставляется per-snapshot и на
# свежих снапшотах ПУСТ (все latest-строки is_ekb=false — см. также комментарий
# в 155_objective_mapping_trgm_backfill.sql). `WHERE is_ekb = true` внутри
# DISTINCT ON отдал бы устаревшие имена со старых снапшотов (или 0 строк после
# DISTINCT). Поэтому: ЕКБ-принадлежность = «obj_id имел is_ekb=true хоть на одном
# снапшоте» (1285 живых объектов), а строку берём с LATEST снапшота без флага.
_DOMRF_UNMAPPED_SQL = text("""
    SELECT DISTINCT ON (o.obj_id)
        o.obj_id, o.comm_name, o.dev_name
    FROM domrf_kn_objects o
    WHERE o.comm_name IS NOT NULL
      AND o.obj_id IN (
          SELECT DISTINCT e.obj_id FROM domrf_kn_objects e WHERE e.is_ekb = true
      )
      AND NOT EXISTS (
          SELECT 1 FROM objective_complex_mapping cm
          WHERE cm.domrf_obj_id = o.obj_id
      )
    ORDER BY o.obj_id, o.snapshot_date DESC NULLS LAST
""")

# ── SQL: objective-проекты — distinct project_name + агрегаты developer/id ────
# objective_project_id: в objective_lots есть колонка objective_project_id
# (68_schema_objective.sql). Берём MAX (стабилен per project_name; NULL если нет).
_OBJECTIVE_PROJECTS_SQL = text("""
    SELECT
        ol.project_name,
        ARRAY_REMOVE(ARRAY_AGG(DISTINCT ol.developer), NULL) AS developers,
        MAX(ol.objective_project_id) AS objective_project_id
    FROM objective_lots ol
    WHERE ol.project_name IS NOT NULL
      AND ol.project_name <> ''
    GROUP BY ol.project_name
""")

# ── SQL: уже занятые objective_complex_name в текущей группе ──────────────────
_TAKEN_NAMES_SQL = text("""
    SELECT objective_complex_name
    FROM objective_complex_mapping
    WHERE objective_group = CAST(:group AS text)
""")


def find_core_matches(db: Session) -> CoreMatchReport:
    """Сопоставить несопоставленные domrf ЕКБ-объекты с objective-проектами по ядру.

    Обе стороны нормализуются `normalize_complex_name` (#2198): обёртки вроде
    «Жилой комплекс "Малевич" (ЖК "Малевич")» схлопываются до ядра «малевич»,
    что даёт exact-core match там, где trgm по сырым именам не дотягивал.

    Классификация (см. CoreMatchReport):
      tier_a    — 1 objective-кандидат + dev-подтверждён (пишем);
      tier_b    — 1 кандидат, dev НЕ подтверждён (DaData-гео проход, не пишем);
      ambiguous — >1 кандидата (ручное/гео разрешение);
      skipped   — objective_complex_name уже в mapping (UNIQUE-констрейнт).

    Реального обращения к БД в тестах нет — db.execute мокается по паттерну файла.
    """
    report = CoreMatchReport()

    # objective-сторона: core → [(project_name, {devs}, project_id), ...]
    objective_by_core: dict[str, list[tuple[str, set[str], int | None]]] = {}
    for row in db.execute(_OBJECTIVE_PROJECTS_SQL).all():
        project_name = str(row[0])
        developers = {str(d) for d in (row[1] or []) if d}
        project_id = int(row[2]) if row[2] is not None else None
        core = normalize_complex_name(project_name)
        if not core:
            continue
        objective_by_core.setdefault(core, []).append((project_name, developers, project_id))

    # уже занятые objective_complex_name (skip — их группа уже покрыта)
    taken_names: set[str] = {
        str(r[0]) for r in db.execute(_TAKEN_NAMES_SQL, {"group": OBJECTIVE_GROUP}).all()
    }

    # domrf-сторона: несопоставленные ЕКБ, latest snapshot per obj_id
    for row in db.execute(_DOMRF_UNMAPPED_SQL).all():
        obj_id = int(row[0])
        comm_name = str(row[1])
        dev_name = str(row[2]) if row[2] is not None else None
        core = normalize_complex_name(comm_name)
        if not core:
            continue

        candidates = objective_by_core.get(core)
        if not candidates:
            continue  # нет objective-проекта с таким ядром — не наш случай

        if len(candidates) > 1:
            # >1 objective project_name делят одно ядро → ambiguous (не выбираем вслепую)
            proj_name, devs, proj_id = candidates[0]
            report.ambiguous.append(
                _build_core_match(obj_id, comm_name, dev_name, core, proj_name, proj_id, devs)
            )
            continue

        proj_name, devs, proj_id = candidates[0]

        if proj_name in taken_names:
            report.skipped_taken.append(
                _build_core_match(obj_id, comm_name, dev_name, core, proj_name, proj_id, devs)
            )
            continue

        match = _build_core_match(obj_id, comm_name, dev_name, core, proj_name, proj_id, devs)
        if match.dev_confirmed:
            report.tier_a.append(match)
        else:
            report.tier_b.append(match)

    logger.info("find_core_matches: %s", report.counts())
    return report


def _build_core_match(
    obj_id: int,
    comm_name: str,
    dev_name: str | None,
    core: str,
    proj_name: str,
    proj_id: int | None,
    devs: set[str],
) -> CoreMatch:
    """Сконструировать CoreMatch + посчитать dev_confirmed."""
    return CoreMatch(
        domrf_obj_id=obj_id,
        domrf_comm_name=comm_name,
        domrf_dev_name=dev_name,
        core=core,
        objective_project_name=proj_name,
        objective_project_id=proj_id,
        objective_developers=sorted(devs),
        dev_confirmed=_dev_confirms(dev_name or "", devs),
    )


def apply_core_matches(
    db: Session,
    report: CoreMatchReport,
    *,
    dry_run: bool = True,
) -> dict[str, int]:
    """Вставить ТОЛЬКО tier_a в objective_complex_mapping (auto_core_dev_v5).

    tier_b / ambiguous / skipped НЕ пишутся (см. CoreMatchReport). Каждая вставка
    в per-row SAVEPOINT (backend.md) с ON CONFLICT (objective_complex_name,
    objective_group) DO NOTHING — гонка/дубль не роняет весь проход.

    Args:
        db: SQLAlchemy sync Session.
        report: результат find_core_matches().
        dry_run: True (default) — только логирует counts + первые 30 пар, БЕЗ insert.

    Returns:
        dict: inserted, conflict_skipped, error_skipped, tier_a_total.
    """
    counts = report.counts()
    logger.info(
        "apply_core_matches (dry_run=%s): tier_a=%d tier_b=%d ambiguous=%d skipped_taken=%d",
        dry_run,
        counts["tier_a"],
        counts["tier_b"],
        counts["ambiguous"],
        counts["skipped_taken"],
    )

    for m in report.tier_a[:30]:
        logger.info(
            "  tier_a: obj_id=%s core=%r domrf=%r → objective=%r (dev %r ~ %r)",
            m.domrf_obj_id,
            m.core,
            m.domrf_comm_name,
            m.objective_project_name,
            m.domrf_dev_name,
            m.objective_developers,
        )

    if dry_run:
        return {
            "inserted": 0,
            "conflict_skipped": 0,
            "error_skipped": 0,
            "tier_a_total": counts["tier_a"],
        }

    inserted = 0
    conflict_skipped = 0
    error_skipped = 0
    for m in report.tier_a:
        try:
            with db.begin_nested():
                result = db.execute(
                    text(
                        """
                        INSERT INTO objective_complex_mapping
                            (objective_complex_name, objective_project_id, domrf_obj_id,
                             objective_group, match_method, match_score, is_reviewed, note)
                        VALUES (
                            CAST(:name AS text),
                            CAST(:project_id AS bigint),
                            CAST(:obj_id AS bigint),
                            CAST(:group AS text),
                            CAST(:method AS text),
                            CAST(:score AS numeric),
                            CAST(:reviewed AS boolean),
                            CAST(:note AS text)
                        )
                        ON CONFLICT (objective_complex_name, objective_group) DO NOTHING
                        """
                    ),
                    {
                        "name": m.objective_project_name,
                        "project_id": m.objective_project_id,
                        "obj_id": m.domrf_obj_id,
                        "group": OBJECTIVE_GROUP,
                        "method": CORE_MATCH_METHOD,
                        "score": CORE_MATCH_SCORE,
                        "reviewed": False,
                        "note": CORE_MATCH_NOTE,
                    },
                )
            if result.rowcount > 0:
                inserted += 1
            else:
                conflict_skipped += 1
        except Exception as e:
            logger.warning(
                "core insert failed для %s ↔ %s: %s",
                m.domrf_comm_name,
                m.objective_project_name,
                e,
            )
            error_skipped += 1

    db.commit()
    logger.info(
        "apply_core_matches done: inserted=%d conflict_skipped=%d error_skipped=%d",
        inserted,
        conflict_skipped,
        error_skipped,
    )
    return {
        "inserted": inserted,
        "conflict_skipped": conflict_skipped,
        "error_skipped": error_skipped,
        "tier_a_total": counts["tier_a"],
    }


def run_core_pass(*, apply: bool = False) -> dict[str, int]:
    """CLI-обёртка core-pass: открыть Session, найти матчи, (опц.) применить.

    Дефолт — dry-run. Реальный insert только при apply=True.
    """
    from app.core.db import SessionLocal

    db = SessionLocal()
    try:
        report = find_core_matches(db)
        return apply_core_matches(db, report, dry_run=not apply)
    finally:
        db.close()


# ════════════════════════════════════════════════════════════════════════════
# Geo-pass (#2177) — auto_core_geo_v6
# ════════════════════════════════════════════════════════════════════════════
#
# Разрешает ОСТАТОК core-pass (tier_b + ambiguous) географией: адрес objective-
# проекта → DaData-геокод → haversine до domrf-точки. tier_b подтверждается
# близостью (гео вместо dev-confirm); ambiguous — только если РОВНО ОДИН из
# кандидатов ядра в радиусе (иначе остаётся ambiguous).

GEO_MATCH_METHOD = "auto_core_geo_v6"
GEO_MATCH_SCORE = 0.80
GEO_MAX_DISTANCE_M = 500.0
# Бережный потолок вызовов DaData за один прогон (МАКС-тариф, но не жжём квоту
# на прогон-цикле). Превышение → стоп с warning, недобранные кандидаты reject'ятся
# как «без геокода».
GEO_DADATA_CALL_LIMIT = 200
_EARTH_RADIUS_M = 6_371_000.0


def _haversine_m(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
    """Расстояние (метры) между двумя точками по формуле гаверсинуса."""
    p1 = math.radians(lat1)
    p2 = math.radians(lat2)
    dphi = math.radians(lat2 - lat1)
    dlambda = math.radians(lon2 - lon1)
    a = math.sin(dphi / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dlambda / 2) ** 2
    return 2 * _EARTH_RADIUS_M * math.asin(min(1.0, math.sqrt(a)))


@dataclass
class GeoMatch:
    """Один подтверждённый гео-матч domrf-объекта к objective-проекту.

    origin — 'tier_b' | 'ambiguous' (из какого класса core-pass пришёл).
    distance_m — гаверсинус адрес-проекта ↔ domrf-точки (метры).
    """

    domrf_obj_id: int
    domrf_comm_name: str
    core: str
    objective_project_name: str
    objective_project_id: int | None
    address: str
    distance_m: float
    origin: str


@dataclass
class GeoReject:
    """Отклонённый гео-кандидат (для отчёта). reason: 'no_address' |
    'no_geocode' | 'too_far' | 'ambiguous_multi' | 'call_limit'.

    distance_m None когда дистанцию посчитать не удалось (нет адреса/геокода/
    координат domrf).
    """

    domrf_obj_id: int
    domrf_comm_name: str
    objective_project_name: str
    origin: str
    reason: str
    distance_m: float | None = None


@dataclass
class GeoMatchReport:
    """Итог geo-pass.

    confirmed — подтверждённые гео-матчи (пишутся в apply_geo_matches).
    rejected  — кандидаты без адреса/геокода/слишком далёкие/оставшиеся ambiguous.
    dadata_calls — сколько раз реально дёрнули DaData (для контроля квоты).
    call_limit_hit — True если упёрлись в GEO_DADATA_CALL_LIMIT (часть кандидатов
                     не обработана — см. rejected c reason='call_limit').
    """

    confirmed: list[GeoMatch] = field(default_factory=list)
    rejected: list[GeoReject] = field(default_factory=list)
    dadata_calls: int = 0
    call_limit_hit: bool = False

    def counts(self) -> dict[str, int]:
        return {
            "confirmed": len(self.confirmed),
            "rejected": len(self.rejected),
            "dadata_calls": self.dadata_calls,
        }


# ── SQL: mode (самый частый непустой) адрес objective_lots per project_name ────
# Адресов на проект много (по корпусам) — берём mode: GROUP BY (project_name,
# address) → COUNT DESC, DISTINCT ON (project_name) отбирает верхнюю строку.
# NULLIF(trim,'') отсекает пустые адреса до агрегации.
_OBJECTIVE_ADDRESS_SQL = text("""
    SELECT DISTINCT ON (project_name)
        project_name, address
    FROM (
        SELECT
            ol.project_name AS project_name,
            NULLIF(trim(ol.address), '') AS address,
            COUNT(*) AS cnt
        FROM objective_lots ol
        WHERE ol.project_name IS NOT NULL
          AND ol.project_name <> ''
          AND NULLIF(trim(ol.address), '') IS NOT NULL
        GROUP BY ol.project_name, NULLIF(trim(ol.address), '')
    ) agg
    ORDER BY project_name, cnt DESC, address
""")

# ── SQL: latest-snapshot координаты domrf per obj_id (для набора obj_id) ───────
# DISTINCT ON (obj_id) + snapshot_date DESC — берём свежую точку. Фильтр по
# конкретным obj_id (кандидаты geo-pass), NULL-координаты отбрасываются.
_DOMRF_COORDS_SQL = text("""
    SELECT DISTINCT ON (o.obj_id)
        o.obj_id, o.latitude, o.longitude
    FROM domrf_kn_objects o
    WHERE o.obj_id = ANY(:obj_ids)
      AND o.latitude IS NOT NULL
      AND o.longitude IS NOT NULL
    ORDER BY o.obj_id, o.snapshot_date DESC NULLS LAST
""")


def _load_project_addresses(db: Session) -> dict[str, str]:
    """project_name → mode-адрес (самый частый непустой) из objective_lots."""
    out: dict[str, str] = {}
    for row in db.execute(_OBJECTIVE_ADDRESS_SQL).all():
        name = str(row[0])
        addr = row[1]
        if addr:
            out[name] = str(addr)
    return out


def _load_domrf_coords(db: Session, obj_ids: list[int]) -> dict[int, tuple[float, float]]:
    """obj_id → (lat, lon) latest snapshot для заданного набора obj_id."""
    if not obj_ids:
        return {}
    out: dict[int, tuple[float, float]] = {}
    for row in db.execute(_DOMRF_COORDS_SQL, {"obj_ids": obj_ids}).all():
        lat = float(row[1])
        lon = float(row[2])
        out[int(row[0])] = (lat, lon)
    return out


def find_geo_matches(db: Session, *, max_distance_m: float = GEO_MAX_DISTANCE_M) -> GeoMatchReport:
    """Разрешить остаток core-pass (tier_b + ambiguous) через DaData-геокод.

    Алгоритм:
      1. find_core_matches → берём report.tier_b и report.ambiguous.
      2. Для каждого кандидата: адрес проекта (mode objective_lots.address) →
         clean_address → (lat, lon); haversine до domrf-точки.
         • tier_b: дистанция ≤ max_distance_m → confirm (гео вместо dev);
         • ambiguous: геокодим ВСЕХ кандидатов ядра; confirm только если РОВНО
           ОДИН в радиусе (иначе остаётся ambiguous);
         • нет адреса / DaData null / дистанция больше → reject c дистанцией.
      3. Кэш геокода в памяти прогона (адреса повторяются). Лимит вызовов DaData
         ≤ GEO_DADATA_CALL_LIMIT — при превышении стоп с warning, остаток reject.

    Args:
        db: SQLAlchemy sync Session.
        max_distance_m: радиус подтверждения (метры), default 500.

    Returns:
        GeoMatchReport (confirmed / rejected / dadata_calls / call_limit_hit).

    Реального обращения к БД / DaData в тестах нет — оба мокаются по паттерну файла.
    """
    core_report = find_core_matches(db)
    report = GeoMatchReport()

    tier_b = core_report.tier_b
    ambiguous = core_report.ambiguous
    if not tier_b and not ambiguous:
        logger.info("find_geo_matches: нет tier_b/ambiguous кандидатов — nothing to do")
        return report

    addresses = _load_project_addresses(db)

    # ── ambiguous: нужны ВСЕ objective-кандидаты ядра, не только первый ────────
    # core-report.ambiguous хранит по одному CoreMatch на domrf-obj_id (первый
    # кандидат). Восстанавливаем полный список project_name'ов ядра из objective-
    # стороны, чтобы геокодить всех и выбрать единственного в радиусе.
    objective_by_core = _load_objective_by_core(db)

    # obj_id'ы, для которых нужны координаты domrf (tier_b + ambiguous)
    needed_obj_ids = [m.domrf_obj_id for m in tier_b] + [m.domrf_obj_id for m in ambiguous]
    domrf_coords = _load_domrf_coords(db, needed_obj_ids)

    # Кэш геокода в памяти прогона: address → (lat, lon) | None (None = «пробовали,
    # DaData не дал»; кэшируем и его, чтобы не жечь квоту на повтор одного адреса).
    geo_cache: dict[str, tuple[float, float] | None] = {}

    def _geocode(addr: str) -> tuple[float, float] | None:
        """Геокод с кэшем + лимитом вызовов. None при отсутствии/лимите.

        Адреса objective_lots идут БЕЗ города («ул. Гидрострой, д. 2а») —
        DaData на таком отдаёт qc_geo=5 (нет координат): прод-прогон 2026-07-03
        дал confirmed=0/45 именно из-за этого. Объектив покрывает только ЕКБ →
        безопасно префиксуем городом, если его нет в строке.
        """
        if addr in geo_cache:
            return geo_cache[addr]
        if report.dadata_calls >= GEO_DADATA_CALL_LIMIT:
            report.call_limit_hit = True
            return None
        query = addr if "екатеринбург" in addr.lower() else f"г Екатеринбург, {addr}"
        report.dadata_calls += 1
        coords = clean_address(query)
        geo_cache[addr] = coords
        return coords

    # ── tier_b: 1 кандидат, подтверждаем близостью ────────────────────────────
    for m in tier_b:
        domrf_pt = domrf_coords.get(m.domrf_obj_id)
        addr = addresses.get(m.objective_project_name)
        if not addr:
            report.rejected.append(_geo_reject(m, "tier_b", "no_address"))
            continue
        if domrf_pt is None:
            # у domrf нет координат — дистанцию не посчитать; не жжём квоту на геокод
            report.rejected.append(_geo_reject(m, "tier_b", "no_geocode"))
            continue
        if report.dadata_calls >= GEO_DADATA_CALL_LIMIT and addr not in geo_cache:
            report.call_limit_hit = True
            report.rejected.append(_geo_reject(m, "tier_b", "call_limit"))
            continue
        coords = _geocode(addr)
        if coords is None:
            report.rejected.append(_geo_reject(m, "tier_b", "no_geocode"))
            continue
        dist = _haversine_m(coords[0], coords[1], domrf_pt[0], domrf_pt[1])
        if dist <= max_distance_m:
            report.confirmed.append(_geo_confirm(m, addr, dist, "tier_b"))
        else:
            report.rejected.append(_geo_reject(m, "tier_b", "too_far", dist))

    # ── ambiguous: геокодим всех кандидатов ядра, confirm если РОВНО ОДИН близко ─
    for m in ambiguous:
        domrf_pt = domrf_coords.get(m.domrf_obj_id)
        if domrf_pt is None:
            report.rejected.append(_geo_reject(m, "ambiguous", "no_geocode"))
            continue
        candidates = objective_by_core.get(m.core, [])
        in_radius: list[tuple[str, int | None, str, float]] = []
        any_geocoded = False
        geocoded_count = 0
        for proj_name, _devs, proj_id in candidates:
            addr = addresses.get(proj_name)
            if not addr:
                continue
            if report.dadata_calls >= GEO_DADATA_CALL_LIMIT and addr not in geo_cache:
                report.call_limit_hit = True
                continue
            coords = _geocode(addr)
            if coords is None:
                continue
            any_geocoded = True
            geocoded_count += 1
            dist = _haversine_m(coords[0], coords[1], domrf_pt[0], domrf_pt[1])
            if dist <= max_distance_m:
                in_radius.append((proj_name, proj_id, addr, dist))

        # Ревью 2026-07-03: «ровно один в радиусе» доверяем ТОЛЬКО когда
        # геокодились ВСЕ кандидаты ядра. Иначе «один» — артефакт отсутствия
        # данных (сосед без адреса/геокода мог быть ближе) → слепой confirm
        # запрещён, остаётся честный reject partial_geocode.
        if len(in_radius) == 1 and geocoded_count < len(candidates):
            report.rejected.append(_geo_reject(m, "ambiguous", "partial_geocode", in_radius[0][3]))
        elif len(in_radius) == 1:
            proj_name, proj_id, addr, dist = in_radius[0]
            report.confirmed.append(
                GeoMatch(
                    domrf_obj_id=m.domrf_obj_id,
                    domrf_comm_name=m.domrf_comm_name,
                    core=m.core,
                    objective_project_name=proj_name,
                    objective_project_id=proj_id,
                    address=addr,
                    distance_m=dist,
                    origin="ambiguous",
                )
            )
        elif not any_geocoded:
            reason = "call_limit" if report.call_limit_hit else "no_geocode"
            report.rejected.append(_geo_reject(m, "ambiguous", reason))
        else:
            # 0 в радиусе, или >1 в радиусе → остаётся ambiguous
            nearest = min((d for *_, d in in_radius), default=None)
            report.rejected.append(_geo_reject(m, "ambiguous", "ambiguous_multi", nearest))

    logger.info(
        "find_geo_matches: %s call_limit_hit=%s",
        report.counts(),
        report.call_limit_hit,
    )
    return report


def _load_objective_by_core(db: Session) -> dict[str, list[tuple[str, set[str], int | None]]]:
    """core → [(project_name, {devs}, project_id), ...] (как в find_core_matches)."""
    out: dict[str, list[tuple[str, set[str], int | None]]] = {}
    for row in db.execute(_OBJECTIVE_PROJECTS_SQL).all():
        project_name = str(row[0])
        developers = {str(d) for d in (row[1] or []) if d}
        project_id = int(row[2]) if row[2] is not None else None
        core = normalize_complex_name(project_name)
        if not core:
            continue
        out.setdefault(core, []).append((project_name, developers, project_id))
    return out


def _geo_confirm(m: CoreMatch, address: str, distance_m: float, origin: str) -> GeoMatch:
    return GeoMatch(
        domrf_obj_id=m.domrf_obj_id,
        domrf_comm_name=m.domrf_comm_name,
        core=m.core,
        objective_project_name=m.objective_project_name,
        objective_project_id=m.objective_project_id,
        address=address,
        distance_m=distance_m,
        origin=origin,
    )


def _geo_reject(
    m: CoreMatch, origin: str, reason: str, distance_m: float | None = None
) -> GeoReject:
    return GeoReject(
        domrf_obj_id=m.domrf_obj_id,
        domrf_comm_name=m.domrf_comm_name,
        objective_project_name=m.objective_project_name,
        origin=origin,
        reason=reason,
        distance_m=distance_m,
    )


def apply_geo_matches(
    db: Session,
    report: GeoMatchReport,
    *,
    dry_run: bool = True,
) -> dict[str, int]:
    """Вставить report.confirmed в objective_complex_mapping (auto_core_geo_v6).

    Та же per-row SAVEPOINT + ON CONFLICT (objective_complex_name, objective_group)
    DO NOTHING механика, что в apply_core_matches. Каждая строка:
    match_method='auto_core_geo_v6', match_score=0.80, is_reviewed=false,
    note с дистанцией в метрах и origin (tier_b/ambiguous).

    Args:
        db: SQLAlchemy sync Session.
        report: результат find_geo_matches().
        dry_run: True (default) — только counts + первые 30 пар с дистанциями,
                 БЕЗ insert.

    Returns:
        dict: inserted, conflict_skipped, error_skipped, confirmed_total.
    """
    counts = report.counts()
    logger.info(
        "apply_geo_matches (dry_run=%s): confirmed=%d rejected=%d dadata_calls=%d",
        dry_run,
        counts["confirmed"],
        counts["rejected"],
        counts["dadata_calls"],
    )
    for gm in report.confirmed[:30]:
        logger.info(
            "  confirm[%s]: obj_id=%s core=%r domrf=%r → objective=%r (%.0f м, addr=%r)",
            gm.origin,
            gm.domrf_obj_id,
            gm.core,
            gm.domrf_comm_name,
            gm.objective_project_name,
            gm.distance_m,
            gm.address[:60],
        )

    if dry_run:
        return {
            "inserted": 0,
            "conflict_skipped": 0,
            "error_skipped": 0,
            "confirmed_total": counts["confirmed"],
        }

    inserted = 0
    conflict_skipped = 0
    error_skipped = 0
    for gm in report.confirmed:
        note = f"#2177 geo-pass ({gm.origin}): {gm.distance_m:.0f} м to domrf point"
        try:
            with db.begin_nested():
                result = db.execute(
                    text(
                        """
                        INSERT INTO objective_complex_mapping
                            (objective_complex_name, objective_project_id, domrf_obj_id,
                             objective_group, match_method, match_score, is_reviewed, note)
                        VALUES (
                            CAST(:name AS text),
                            CAST(:project_id AS bigint),
                            CAST(:obj_id AS bigint),
                            CAST(:group AS text),
                            CAST(:method AS text),
                            CAST(:score AS numeric),
                            CAST(:reviewed AS boolean),
                            CAST(:note AS text)
                        )
                        ON CONFLICT (objective_complex_name, objective_group) DO NOTHING
                        """
                    ),
                    {
                        "name": gm.objective_project_name,
                        "project_id": gm.objective_project_id,
                        "obj_id": gm.domrf_obj_id,
                        "group": OBJECTIVE_GROUP,
                        "method": GEO_MATCH_METHOD,
                        "score": GEO_MATCH_SCORE,
                        "reviewed": False,
                        "note": note,
                    },
                )
            if result.rowcount > 0:
                inserted += 1
            else:
                conflict_skipped += 1
        except Exception as e:
            logger.warning(
                "geo insert failed для %s ↔ %s: %s",
                gm.domrf_comm_name,
                gm.objective_project_name,
                e,
            )
            error_skipped += 1

    db.commit()
    logger.info(
        "apply_geo_matches done: inserted=%d conflict_skipped=%d error_skipped=%d",
        inserted,
        conflict_skipped,
        error_skipped,
    )
    return {
        "inserted": inserted,
        "conflict_skipped": conflict_skipped,
        "error_skipped": error_skipped,
        "confirmed_total": counts["confirmed"],
    }


def run_geo_pass(
    *, apply: bool = False, max_distance_m: float = GEO_MAX_DISTANCE_M
) -> dict[str, int]:
    """CLI-обёртка geo-pass: открыть Session, найти гео-матчи, (опц.) применить.

    Дефолт — dry-run. Реальный insert только при apply=True.
    """
    from app.core.db import SessionLocal

    db = SessionLocal()
    try:
        report = find_geo_matches(db, max_distance_m=max_distance_m)
        return apply_geo_matches(db, report, dry_run=not apply)
    finally:
        db.close()


def _main(argv: list[str] | None = None) -> int:
    parser = argparse.ArgumentParser(
        description="objective_backfill passes (#2177): core-pass (auto_core_dev_v5), "
        "geo-pass (auto_core_geo_v6)."
    )
    parser.add_argument(
        "--core-pass",
        action="store_true",
        help="Запустить core-name + dev-confirm проход (auto_core_dev_v5).",
    )
    parser.add_argument(
        "--geo-pass",
        action="store_true",
        help="Запустить DaData-гео проход по tier_b/ambiguous остатку core-pass "
        "(auto_core_geo_v6).",
    )
    parser.add_argument(
        "--max-distance-m",
        type=float,
        default=GEO_MAX_DISTANCE_M,
        help="Радиус подтверждения гео-матча в метрах (geo-pass; "
        f"default {GEO_MAX_DISTANCE_M:.0f}).",
    )
    parser.add_argument(
        "--apply",
        action="store_true",
        help="Реально писать матчи в БД (по умолчанию — dry-run).",
    )
    args = parser.parse_args(argv)

    logging.basicConfig(
        level=logging.INFO,
        format="%(asctime)s %(levelname)s %(name)s: %(message)s",
    )

    if not args.core_pass and not args.geo_pass:
        parser.print_help()
        return 0

    if args.core_pass:
        result = run_core_pass(apply=args.apply)
        logger.info("core-pass result: %s", result)

    if args.geo_pass:
        geo_result = run_geo_pass(apply=args.apply, max_distance_m=args.max_distance_m)
        logger.info("geo-pass result: %s", geo_result)

    return 0


if __name__ == "__main__":
    sys.exit(_main())
