"""On-demand cadastre geometry fetching for /site-finder.

Issue #93: пользователь вводит cad-номер которого нет в БД (cad_quarters_geom /
cad_buildings / cad_parcels_geom) — раньше получал 404 «Загрузи через NSPD geo»
(dead-end для non-admin). Теперь:

1. `find_or_enqueue_fetch(db, cad_num)` — atomic helper:
   - Если cad есть в БД → return ("ready", None)
   - Если уже есть active on-demand job → return ("fetching", job_id)
   - Иначе создаёт новый NSPD job + enqueues Celery task → return ("fetching", job_id)
2. `fetch_status(db, cad_num)` — polling endpoint helper:
   - Smart status: проверяет и БД (ready), и nspd_geo_jobs (fetching/failed),
     и nspd_geo_targets (per-cad status, distinguishes not_in_nspd vs failed).

Дедупликация: `pg_try_advisory_xact_lock(hashtext(cad_num))` оборачивает
шаги "check active job → enqueue new" в `find_or_enqueue_fetch`. Lock держится
до COMMIT текущей транзакции (xact_lock — transaction-scoped). Параллельные
запросы на тот же cad: второй увидит lock=false и фолбэк-чит активный job уже
от первого транзакта (после первого COMMIT).
"""

from __future__ import annotations

import logging
from typing import Literal

from sqlalchemy import text
from sqlalchemy.orm import Session

logger = logging.getLogger(__name__)

FetchStatus = Literal["ready", "fetching", "failed", "not_in_nspd", "invalid_format"]

# Source kind для on-demand jobs — отдельный от bulk-операций ("rosreestr_pending",
# "manual_list"), позволяет фильтровать в admin UI и в analytics queries.
ON_DEMAND_SOURCE_KIND = "auto_on_demand"


def detect_thematic_id(cad_num: str) -> int | None:
    """3-segment cad → quarter (thematic_id=2); 4-segment → parcel (thematic_id=1).

    Building cad (5-segment) обычно не вводится пользователем напрямую,
    но если придёт — попробуем как parcel.
    """
    parts = cad_num.split(":")
    n = len(parts)
    if n == 3:
        return 2  # quarter
    if n == 4:
        return 1  # parcel
    if n == 5:
        return 5  # building
    return None


def validate_cad_format(cad_num: str) -> bool:
    """Проверка формата: NN:NN:NNNNNN[:NN[:NN]].

    Каждый сегмент — только цифры (1+).
    """
    parts = cad_num.split(":")
    if not (3 <= len(parts) <= 5):
        return False
    return all(p.isdigit() and len(p) >= 1 for p in parts)


def cad_exists_in_db(db: Session, cad_num: str) -> bool:
    """Проверка: есть ли НЕПУСТАЯ geometry для cad_num в любой из 3 таблиц.

    Использует EXISTS short-circuit — самая дешёвая проверка.

    NB: `geom IS NOT NULL` обязателен — есть ~964 строк в cad_parcels_geom с meta,
    но NULL-геометрией. Без фильтра функция врала бы «geometry есть» → analyze
    fallback решал бы "ready"/не ставил NSPD-fetch → участок навсегда застревал в
    202 (никогда не подтянул бы реальную геометрию). С фильтром null-geom участок
    корректно идёт в fetch → НСПД отдаёт геометрию → analyze работает (или 404).
    """
    row = db.execute(
        text(
            """
            SELECT 1 WHERE EXISTS (
                SELECT 1 FROM cad_quarters_geom g
                 WHERE g.cad_number = :c AND g.geom IS NOT NULL
                UNION ALL
                SELECT 1 FROM cad_buildings b
                 WHERE b.cad_num = :c AND b.geom IS NOT NULL
                UNION ALL
                SELECT 1 FROM cad_parcels_geom p
                 WHERE p.cad_num = :c AND p.geom IS NOT NULL
            ) LIMIT 1
            """
        ),
        {"c": cad_num},
    ).first()
    return row is not None


def find_active_on_demand_job(db: Session, cad_num: str) -> int | None:
    """Найти существующий on-demand job (queued/running/paused) для этого cad.

    Возвращает job_id или None. Если в БД есть FAILED on-demand за последние 60
    секунд — тоже None (чтобы повторно пробовать). Если есть DONE job, но cad
    отсутствует в БД (на NSPD не нашлось) — тоже None, но caller через
    `fetch_status` отличит этот случай как `not_in_nspd`.

    NB (issue #1356): 'paused' тоже считается active. Job переходит в 'paused'
    при WAF (consecutive>=8) или Celery soft_time_limit (6h) — нетронутые targets
    остаются 'pending', а job авто-резюмится через cleanup_zombies/worker_ready.
    Это in-flight состояние, а не «не найдено»: без него paused job проваливался
    бы в неверный ответ 'not_in_nspd' в `fetch_status`.
    """
    row = db.execute(
        text(
            """
            SELECT j.job_id
              FROM nspd_geo_jobs j
              JOIN nspd_geo_targets t ON t.job_id = j.job_id
             WHERE j.source_kind = :src
               AND j.status IN ('queued', 'running', 'paused')
               AND t.cad_num = :c
             ORDER BY j.created_at DESC
             LIMIT 1
            """
        ),
        {"src": ON_DEMAND_SOURCE_KIND, "c": cad_num},
    ).first()
    return int(row[0]) if row else None


def find_recent_completed_job(db: Session, cad_num: str) -> dict | None:
    """Last on-demand job (done/failed) для этого cad — для smart status.

    Используется в `fetch_status` чтобы отличить:
    - target.status='done' + cad есть в БД → ready
    - target.status='done' + cad нет в БД → not_in_nspd (НСПД не вернул geometry)
    - target.status='failed' → failed (rate-limit/WAF/timeout)
    """
    row = db.execute(
        text(
            """
            SELECT j.job_id, j.status AS job_status,
                   t.status AS target_status, t.error_msg
              FROM nspd_geo_jobs j
              JOIN nspd_geo_targets t ON t.job_id = j.job_id
             WHERE j.source_kind = :src
               AND t.cad_num = :c
             ORDER BY j.created_at DESC
             LIMIT 1
            """
        ),
        {"src": ON_DEMAND_SOURCE_KIND, "c": cad_num},
    ).first()
    if not row:
        return None
    return {
        "job_id": int(row[0]),
        "job_status": row[1],
        "target_status": row[2],
        "error_msg": row[3],
    }


def enqueue_on_demand_fetch(db: Session, cad_num: str, thematic_id: int) -> int:
    """Создать новый on-demand NSPD job + enqueue Celery task.

    Возвращает job_id. Использует существующий `enqueue_geo_job` helper из
    `app.workers.tasks.nspd_geo` для единообразия с bulk операциями.
    """
    from app.services.job_settings import get_setting_value
    from app.workers.tasks.nspd_geo import enqueue_geo_job, process_nspd_geo_job

    name = f"on-demand: {cad_num}"
    job_kind_map = {1: "parcels", 2: "quarters", 5: "buildings"}
    job_kind = job_kind_map.get(thematic_id, "mixed")

    job_id = enqueue_geo_job(
        name=name,
        job_kind=job_kind,
        source_kind=ON_DEMAND_SOURCE_KIND,
        source_params={"cad_num": cad_num, "trigger": "site-finder"},
        cad_nums_with_thematic=[(cad_num, thematic_id)],
        # On-demand priority — rate_ms ниже чем у bulk (быстрее), чтобы
        # пользователь получал ответ за ~10-20с.
        rate_ms=200,
        triggered_by="auto_on_demand",
    )
    # Enqueue в high-priority queue если есть, иначе fallback "geo"
    geo_queue = get_setting_value("nspd_geo", "queue_name", "geo")
    process_nspd_geo_job.apply_async(args=[job_id], queue=geo_queue, priority=9)

    logger.info(
        "on-demand cadastre fetch enqueued: cad=%s job=%s thematic=%s",
        cad_num,
        job_id,
        thematic_id,
    )
    return job_id


def find_or_enqueue_fetch(db: Session, cad_num: str) -> tuple[FetchStatus, int | None, str | None]:
    """Главный entrypoint для analyze fallback flow.

    Returns: (status, job_id, error_msg).

    - ("ready", None, None) — cad уже в БД, можно делать analyze
    - ("invalid_format", None, msg) — bad format
    - ("fetching", job_id, None) — fetch инициирован или уже идёт
    - ("not_in_nspd", None, msg) — последний fetch вернул empty (НСПД нет cad)
    - ("failed", job_id, msg) — последний fetch упал (rate-limit/WAF)

    Caller (analyze endpoint) использует status чтобы решить:
    - "ready" → продолжить с analyze (path existing)
    - "fetching" → 202 + job_id для polling
    - "not_in_nspd" / "invalid_format" → 404 с понятным сообщением
    - "failed" → 503 с retry-after

    Дедупликация (через pg advisory lock):
      Шаги 3-6 ниже (check active → check recent → enqueue) оборачиваются
      `pg_try_advisory_xact_lock(hashtext(cad_num))`. Lock automatically
      released at xact end. Параллельные запросы на тот же cad:
      - Первый запрос получает lock → выполняет enqueue → COMMIT → release.
      - Второй запрос lock=false → re-проверяет active job (тот что enqueue'нул
        первый и уже видим в DB после COMMIT) → возвращает ("fetching", job_id1).
    """
    # 1) Format check (cheap, lock-free)
    if not validate_cad_format(cad_num):
        return (
            "invalid_format",
            None,
            ("Неверный формат кадастрового номера. Ожидается NN:NN:NNNNNN или NN:NN:NNNNNN:NN"),
        )

    # 2) Already in DB? (lock-free read)
    if cad_exists_in_db(db, cad_num):
        return ("ready", None, None)

    # 3-6) Critical section: advisory lock на hashtext(cad_num) гарантирует
    # один enqueue per cad даже при concurrent requests.
    got_lock = db.execute(
        text("SELECT pg_try_advisory_xact_lock(hashtext(:c))"),
        {"c": cad_num},
    ).scalar()

    # Lock acquired or not — в обоих случаях сначала проверяем active job
    # (если другой xact уже enqueue'нул — мы видим его после его COMMIT).
    active_job_id = find_active_on_demand_job(db, cad_num)
    if active_job_id is not None:
        return ("fetching", active_job_id, None)

    if not got_lock:
        # Lock потерян concurrent xact но active job ещё не закоммичен.
        # Возвращаем "fetching" с None job_id — frontend начнёт polling и
        # подцепит job через /fetch-status (там тоже проверка active job).
        logger.info("advisory lock contention for cad=%s — caller will poll status", cad_num)
        return ("fetching", None, None)

    # 4) Recent completed job? Distinguish not_in_nspd vs failed.
    recent = find_recent_completed_job(db, cad_num)
    if recent:
        if recent["target_status"] == "done":
            # NSPD не вернул geometry (target_status=done but cad не появился в БД)
            return (
                "not_in_nspd",
                recent["job_id"],
                f"Кадастровый номер {cad_num} не найден в НСПД.",
            )
        elif recent["target_status"] == "failed":
            # Re-enqueue новый job — даём ещё попытку
            logger.info("retry on-demand fetch after prior failure: cad=%s", cad_num)
            # fall through to enqueue ниже

    # 5) Determine thematic_id from cad format
    thematic_id = detect_thematic_id(cad_num)
    if thematic_id is None:
        return (
            "invalid_format",
            None,
            f"Cannot determine cadastre type from {cad_num}",
        )

    # 6) Enqueue new on-demand fetch (lock освободится на COMMIT после return)
    try:
        job_id = enqueue_on_demand_fetch(db, cad_num, thematic_id)
        return ("fetching", job_id, None)
    except Exception as e:  # pragma: no cover
        logger.exception("on-demand enqueue failed for cad=%s", cad_num)
        return ("failed", None, f"Не удалось поставить fetch в очередь: {e}")


def fetch_status(db: Session, cad_num: str) -> dict:
    """Polling endpoint helper. Возвращает dict для FastAPI JSON response.

    Контракт для frontend:
    {
        "status": "ready" | "fetching" | "not_in_nspd" | "failed" | "invalid_format",
        "job_id": int | None,
        "error_msg": str | None,
        "eta_seconds": int | None,  # для fetching — оценка ожидания
    }
    """
    if not validate_cad_format(cad_num):
        return {
            "status": "invalid_format",
            "job_id": None,
            "error_msg": "Неверный формат кадастрового номера",
            "eta_seconds": None,
        }

    # Recheck DB first — может быть NSPD job уже отработал
    if cad_exists_in_db(db, cad_num):
        return {
            "status": "ready",
            "job_id": None,
            "error_msg": None,
            "eta_seconds": None,
        }

    # Active in-flight job?
    active_job_id = find_active_on_demand_job(db, cad_num)
    if active_job_id is not None:
        # Estimate ETA — depends on queue depth, simplified to ~15s
        return {
            "status": "fetching",
            "job_id": active_job_id,
            "error_msg": None,
            "eta_seconds": 15,
        }

    # Recent completed job for diagnosis
    recent = find_recent_completed_job(db, cad_num)
    if recent:
        if recent["target_status"] == "done":
            # Job finished but cad still not in DB → NSPD не отдал geometry
            return {
                "status": "not_in_nspd",
                "job_id": recent["job_id"],
                "error_msg": (
                    f"Кадастровый номер {cad_num} не найден в НСПД. "
                    "Проверьте формат: NN:NN:NNNNNN[:NN]"
                ),
                "eta_seconds": None,
            }
        elif recent["target_status"] == "failed":
            return {
                "status": "failed",
                "job_id": recent["job_id"],
                "error_msg": recent["error_msg"]
                or "НСПД временно недоступен. Попробуйте через минуту.",
                "eta_seconds": None,
            }
        else:
            # Non-terminal target (issue #1356): чаще всего 'pending' у paused/queued
            # job, который ещё не дошёл до этого cad. Активный job обычно ловится выше
            # в `find_active_on_demand_job`, но на гонке статусов сюда может прилететь
            # job с pending-target — это in-flight, а НЕ 'not_in_nspd'. Консервативно
            # отдаём 'fetching', чтобы frontend продолжил polling, а не показал 404.
            return {
                "status": "fetching",
                "job_id": recent["job_id"],
                "error_msg": None,
                "eta_seconds": 15,
            }

    # No job at all — first request without prior history
    return {
        "status": "not_in_nspd",
        "job_id": None,
        "error_msg": (
            f"Кадастровый номер {cad_num} не загружен. "
            "Откройте /site-finder и введите cad — он будет загружен автоматически."
        ),
        "eta_seconds": None,
    }
