"""Celery task: ingest постановлений об изъятии/резервировании ЗУ → ``land_reservation`` (#1062).

#1062 (ИРД-5), эпик #1067 (GG-форсайт). Загружает PDF-постановления с pravo.gov66.ru
через pravo_gov66_client, извлекает кад-номера через page_reservation_parser,
UPSERT-ит в land_reservation (м.136).

Геометрии зон здесь НЕТ — join к cad_parcels.geom на read-side (reservation_lookup.py).

Beat НЕ настроен: постановления выходят редко, запуск ручной/по требованию.
  celery call tasks.reservation_ingest.ingest_reservations

Conventions (зеркало ird_harvest.py / backend.md):
  • Session lifecycle (#2445 D4): PDF fetch происходит БЕЗ открытой сессии — pravo.gov66
    иногда медленный (таймаут до 60с), держать connection-pool slot на всё время внешнего
    запроса нельзя. Короткоживущий ``SessionLocal()`` открывается ТОЛЬКО на запись строк
    одного документа (open → upsert → commit → close per item), не на весь цикл.
  • SAVEPOINT per-row (``with db.begin_nested():``) — битая строка не валит батч.
  • CAST(:x AS type) — НИКОГДА :x::text (psycopg v3).
  • ON CONFLICT (cad_num, act_number) → обновляет метаданные + fetched_at.
  • httpx.Client (не requests). Сбой одного дока не валит прогон.
  • q-запросы — 2-3 слова (движок pravo.gov66 строгий AND/фраза, 1 или 4+ → 0 хитов).
  • Pacing/backoff к pravo.gov66.ru — внутри pravo_gov66_client (_get_with_backoff),
    НЕ здесь: каждый search/page/pdf запрос уже пейсится на уровне клиента.
"""

from __future__ import annotations

import logging
import re
from typing import Any

from sqlalchemy import text
from sqlalchemy.orm import Session

from app.core.db import SessionLocal
from app.services.scrapers.page_reservation_parser import extract_from_pdf
from app.services.scrapers.pravo_gov66_client import (
    fetch_pdf,
    get_document_pdf_url,
    search_documents,
)
from app.workers.celery_app import celery_app

logger = logging.getLogger(__name__)

# ── Запросы для enumerate ──────────────────────────────────────────────────────
# 2-3 слова: движок pravo.gov66 строгий AND/фраза; 1 или 4+ → 0 результатов.
_RESERVATION_QUERIES: list[str] = [
    "изъятие земельных участков",
    "резервирование земельных участков",
]

# Фильтр заголовков: документ должен содержать изъят|резерв И Екатеринбург|Свердлов.
_RE_RELEVANCE = re.compile(r"изъят|резерв", re.IGNORECASE | re.UNICODE)
_RE_GEO = re.compile(r"Екатеринбург|Свердлов", re.IGNORECASE | re.UNICODE)

# UPSERT: ON CONFLICT (cad_num, act_number) → обновляем все поля кроме id.
# CAST(:x AS type) — psycopg v3. DO NOTHING fallback если act_number IS NULL (NULL != NULL → нет
# конфликта, вставляется дубль — acceptable для NULL-act_number edge-case).
_UPSERT_SQL = text(
    """
    INSERT INTO land_reservation (
        cad_num, reservation_kind, basis_act, act_number, act_date,
        purpose, doc_url, source, is_active, raw_excerpt, fetched_at
    ) VALUES (
        CAST(:cad_num AS text),
        CAST(:reservation_kind AS text),
        CAST(:basis_act AS text),
        CAST(:act_number AS text),
        CAST(:act_date AS date),
        CAST(:purpose AS text),
        CAST(:doc_url AS text),
        CAST(:source AS text),
        true,
        CAST(:raw_excerpt AS text),
        now()
    )
    ON CONFLICT (cad_num, act_number) DO UPDATE SET
        reservation_kind = EXCLUDED.reservation_kind,
        basis_act        = EXCLUDED.basis_act,
        act_date         = EXCLUDED.act_date,
        purpose          = EXCLUDED.purpose,
        doc_url          = EXCLUDED.doc_url,
        source           = EXCLUDED.source,
        raw_excerpt      = EXCLUDED.raw_excerpt,
        fetched_at       = now()
    """
)


def _is_relevant_title(title: str) -> bool:
    """Проверяет, что заголовок документа касается изъятия/резервирования ЗУ в регионе."""
    return bool(_RE_RELEVANCE.search(title) and _RE_GEO.search(title))


def _fetch_pdf_safe(pdf_url: str) -> bytes | None:
    """Скачивает PDF по URL, graceful при ошибке."""
    try:
        return fetch_pdf(pdf_url)
    except Exception as exc:
        logger.error("_fetch_pdf_safe: не удалось загрузить %s: %s", pdf_url, exc)
        return None


def _upsert_records_in_session(db: Session, records_meta: list[dict[str, Any]]) -> int:
    """UPSERT записей в land_reservation на уже открытой сессии.

    Возвращает число upsert'нутых строк.
    """
    count = 0
    for row in records_meta:
        try:
            with db.begin_nested():  # SAVEPOINT per-row
                db.execute(_UPSERT_SQL, row)
            count += 1
        except Exception as exc:
            logger.warning(
                "_upsert_records: пропуск cad_num=%s: %s",
                row.get("cad_num"),
                exc,
            )
    return count


def _upsert_records(records_meta: list[dict[str, Any]]) -> int:
    """Открывает короткоживущую сессию ТОЛЬКО на запись одного документа → commit → close.

    Session lifecycle (#2445 D4): вызывается ПОСЛЕ внешнего PDF-fetch, никогда во время
    него — держать открытую транзакцию/connection-pool slot на время сетевого запроса
    к pravo.gov66.ru (таймаут до 60с) недопустимо (см. WAF-инцидент #2443).
    """
    if not records_meta:
        return 0
    db: Session = SessionLocal()
    try:
        count = _upsert_records_in_session(db, records_meta)
        db.commit()
        return count
    except Exception:
        db.rollback()
        raise
    finally:
        db.close()


def _enumerate_documents(since: str | None = None) -> list[dict[str, Any]]:
    """Обходит все поисковые запросы и возвращает дедуплицированный список документов.

    Args:
        since: дата начала периода «DD.MM.YYYY» для инкрементального обхода.

    Returns:
        Список {'doc_id', 'title', 'url'}, дедуплицированный по doc_id.
    """
    seen_ids: set[str] = set()
    docs: list[dict[str, Any]] = []

    for query in _RESERVATION_QUERIES:
        hits = search_documents(query, since=since)
        logger.info(
            "_enumerate_documents: query=%r → %d хитов (до фильтрации)",
            query,
            len(hits),
        )
        for hit in hits:
            doc_id: str = hit["doc_id"]
            title: str = hit.get("title", "")
            if doc_id in seen_ids:
                continue
            if not _is_relevant_title(title):
                logger.debug(
                    "_enumerate_documents: пропуск doc_id=%s (нерелевантный заголовок): %r",
                    doc_id,
                    title,
                )
                continue
            seen_ids.add(doc_id)
            docs.append(hit)

    logger.info("_enumerate_documents: итого %d релевантных документов", len(docs))
    return docs


@celery_app.task(name="tasks.reservation_ingest.ingest_reservations")
def ingest_reservations(
    docs: list[dict[str, Any]] | None = None,
    since: str | None = None,
) -> dict[str, int]:
    """Загружает и ингестирует постановления об изъятии/резервировании ЗУ.

    Args:
        docs: список документов для обработки. Каждый словарь должен содержать:
            url (str) — прямая ссылка на PDF или страницу документа,
            act_number (str | None) — номер акта (опц.),
            basis_act (str) — заголовок/описание акта,
            purpose (str | None) — цель (опц.).
            Если None — enumerate с pravo.gov66 по _RESERVATION_QUERIES.
        since: дата начала периода «DD.MM.YYYY» для инкрементального обхода.
            Игнорируется если docs задан явно.

    Returns:
        {'docs': N_fetched, 'parcels': M_upserted}.
    """
    if docs is not None:
        # Ручной override: docs передан явно, enumerate не делаем.
        return _ingest_explicit_docs(docs)

    # Enumerate с pravo.gov66.
    enumerated = _enumerate_documents(since=since)
    return _ingest_enumerated_docs(enumerated)


def _ingest_explicit_docs(docs: list[dict[str, Any]]) -> dict[str, int]:
    """Ингестирует явно переданный список документов (back-compat с ручным вызовом).

    Session lifecycle (#2445 D4): PDF fetch (сетевой, может занять до 60с) выполняется
    БЕЗ открытой DB-сессии; ``_upsert_records`` открывает свою короткоживущую сессию
    только на запись строк одного документа (commit+close сразу после).
    """
    total_parcels = 0
    for doc in docs:
        url = doc.get("url", "")
        act_number = doc.get("act_number")
        basis_act = doc.get("basis_act", "")
        doc_purpose = doc.get("purpose")
        doc_kind = doc.get("kind", "изъятие")

        logger.info("ingest_reservations(explicit): загрузка url=%s act=%s", url, act_number)

        pdf_bytes = _fetch_pdf_safe(url)  # без открытой сессии — только сетевой запрос
        if pdf_bytes is None:
            logger.warning("ingest_reservations(explicit): пропуск url=%s (не загружен)", url)
            continue

        records = extract_from_pdf(pdf_bytes)
        logger.info("ingest_reservations(explicit): url=%s → %d КН", url, len(records))
        if not records:
            continue

        rows = [
            {
                "cad_num": rec.cad_num,
                "reservation_kind": rec.reservation_kind or doc_kind,
                "basis_act": basis_act,
                "act_number": rec.act_number or act_number,
                "act_date": rec.act_date,
                "purpose": rec.purpose or doc_purpose,
                "doc_url": url,
                "source": "page_pdf",
                "raw_excerpt": rec.raw_excerpt,
            }
            for rec in records
        ]
        total_parcels += _upsert_records(rows)  # short-lived session: open→upsert→commit→close

    logger.info(
        "ingest_reservations(explicit): готово docs=%d parcels=%d",
        len(docs),
        total_parcels,
    )
    return {"docs": len(docs), "parcels": total_parcels}


def _ingest_enumerated_docs(enumerated: list[dict[str, Any]]) -> dict[str, int]:
    """Ингестирует документы, найденные через enumerate с pravo.gov66.

    Session lifecycle (#2445 D4): оба сетевых вызова на документ (get_document_pdf_url,
    fetch_pdf) выполняются БЕЗ открытой DB-сессии; ``_upsert_records`` открывает свою
    короткоживущую сессию только на запись строк одного документа.
    """
    total_parcels = 0
    fetched_docs = 0
    for hit in enumerated:
        doc_id: str = hit["doc_id"]
        title: str = hit.get("title", "")
        doc_page_url: str = hit["url"]

        logger.info("ingest_reservations(enum): doc_id=%s title=%r", doc_id, title)

        pdf_url = get_document_pdf_url(doc_id)  # сеть, без открытой сессии
        if pdf_url is None:
            logger.warning(
                "ingest_reservations(enum): doc_id=%s — PDF не найден на странице", doc_id
            )
            continue

        pdf_bytes = _fetch_pdf_safe(pdf_url)  # сеть, без открытой сессии
        if pdf_bytes is None:
            logger.warning("ingest_reservations(enum): doc_id=%s — не удалось скачать PDF", doc_id)
            continue

        fetched_docs += 1
        records = extract_from_pdf(pdf_bytes)
        logger.info("ingest_reservations(enum): doc_id=%s → %d КН", doc_id, len(records))
        if not records:
            continue

        rows = [
            {
                "cad_num": rec.cad_num,
                "reservation_kind": rec.reservation_kind,
                "basis_act": title,
                "act_number": rec.act_number,
                "act_date": rec.act_date,
                "purpose": rec.purpose,
                "doc_url": doc_page_url,
                "source": "pravo_gov66",
                "raw_excerpt": rec.raw_excerpt,
            }
            for rec in records
        ]
        total_parcels += _upsert_records(rows)  # short-lived session: open→upsert→commit→close

    logger.info(
        "ingest_reservations(enum): готово fetched=%d parcels=%d",
        fetched_docs,
        total_parcels,
    )
    return {"docs": fetched_docs, "parcels": total_parcels}


__all__ = ["ingest_reservations"]
