"""scrape_runs helpers — tracking long-running pipeline runs (strangler-копия #2135).

Байт-эквивалент `app.services.scrape_runs` — чистые SQL-хелперы поверх таблицы
`scrape_runs` (миграции 015 + 051). Развязка от `app.*`: единственное намеренное
отличие — `sentry_sdk` импортируется опционально (kit standalone-импортируем, а
`sentry-sdk` не входит в его зависимости). Если пакет не установлен — alert-хук
best-effort no-op, поведение SQL-финализаторов идентично старому.
"""

from __future__ import annotations

import json
import logging
from typing import Any

from sqlalchemy import text
from sqlalchemy.orm import Session

try:  # sentry опционален — kit не тянет его в зависимостях
    import sentry_sdk
except ImportError:  # pragma: no cover - в проде backend-env sentry_sdk присутствует
    sentry_sdk = None  # type: ignore[assignment]

logger = logging.getLogger(__name__)

# Количество последовательных неудачных запусков (failed/banned), при достижении
# которого отправляется Sentry alert. Алерт срабатывает ровно при N-й подряд ошибке
# (anti-spam: не на каждой последующей).
CONSECUTIVE_FAILURE_ALERT_THRESHOLD = 3


def _column_counts(counters: dict[str, int]) -> tuple[int | None, int | None]:
    """Извлечь значения для dedicated-колонок total_seen / new_count из jsonb-counters.

    Все sweep-counters (YandexCitySweepCounters / CitySweepCounters / …) пишут число
    объявлений в выдаче как ``lots_fetched`` и число впервые вставленных как
    ``lots_inserted``. Раньше эти значения попадали ТОЛЬКО в counters jsonb, а
    выделенные колонки total_seen/new_count оставались 0 → admin/observability
    показывала total_seen=0 при реально сохранённых строках (audit #1871/#1926).

    Приоритет ключей:
      - total_seen ← 'total_seen' (если уже есть в counters) иначе 'lots_fetched'
      - new_count  ← 'new_count'  (если уже есть)            иначе 'lots_inserted'

    Возвращает (total_seen, new_count); None для ключа, которого нет в counters —
    тогда соответствующая колонка не перезаписывается (COALESCE-семантика в UPDATE).
    """

    def _pick(*keys: str) -> int | None:
        for key in keys:
            val = counters.get(key)
            if val is not None:
                try:
                    return int(val)
                except (TypeError, ValueError):
                    return None
        return None

    return _pick("total_seen", "lots_fetched"), _pick("new_count", "lots_inserted")


def _alert_if_consecutive_failures(db: Session, source: str) -> None:
    """Отправить Sentry alert если последние CONSECUTIVE_FAILURE_ALERT_THRESHOLD
    завершённых запусков для данного source имеют статус 'failed' или 'banned'.

    Anti-spam: алерт срабатывает ТОЛЬКО когда стрик РОВНО равен порогу — т.е. запрос
    возвращает ровно N последних (failed|banned) и (N+1)-й, если существует, НЕ является
    failed/banned. Это предотвращает повторный алерт на каждой ошибке сверх порога.

    Best-effort: весь блок обёрнут в try/except — сбой запроса или неинициализированный
    Sentry НЕ должен нарушать вызывающий mark_* путь.
    """
    if sentry_sdk is None:
        return
    n = CONSECUTIVE_FAILURE_ALERT_THRESHOLD
    try:
        # Берём последние N+1 завершённых (non-running) запусков по source.
        # Сортируем по finished_at DESC чтобы самые свежие шли первыми.
        rows = db.execute(
            text(
                """
                SELECT status FROM scrape_runs
                WHERE source = :source
                  AND status IN ('failed', 'banned', 'done', 'cancelled')
                ORDER BY finished_at DESC NULLS LAST
                LIMIT :limit
                """
            ),
            {"source": source, "limit": n + 1},
        ).fetchall()

        if len(rows) < n:
            # Ещё не набралось N завершённых запусков вообще — алерт не нужен.
            return

        # Первые N должны быть все failed/banned.
        first_n = rows[:n]
        if not all(r.status in ("failed", "banned") for r in first_n):
            return

        # (N+1)-й запуск, если есть, тоже должен НЕ быть failed/banned — иначе мы уже
        # должны были отправить алерт раньше и не стоит дублировать.
        if len(rows) > n and rows[n].status in ("failed", "banned"):
            return

        # Стрик ровно достиг порога — отправляем алерт.
        sentry_sdk.capture_message(
            f"Scraper source '{source}' has {n} consecutive failed/banned runs — "
            "manual intervention may be required (expired cookies / ban / broken parser).",
            level="error",
        )
        logger.error(
            "sentry alert sent: source=%s has %d consecutive failed/banned runs", source, n
        )
    except Exception:
        pass  # sentry_sdk not initialised in dev, or query failed — best-effort only


def _alert_on_run_id(db: Session, run_id: int) -> None:
    """Вспомогательная обёртка: извлекает source по run_id и вызывает
    _alert_if_consecutive_failures. Best-effort — не бросает исключений.
    """
    try:
        row = db.execute(
            text("SELECT source FROM scrape_runs WHERE id = :run_id"),
            {"run_id": run_id},
        ).fetchone()
        if row is None:
            return
        _alert_if_consecutive_failures(db, str(row.source))
    except Exception:
        pass


def create_run(db: Session, *, source: str, params: dict[str, Any]) -> int:
    """INSERT scrape_runs(source, status='running', params, started_at=NOW()).

    run_type DEFAULT 'city_sweep' (из 051 миграции).
    Returns run_id (bigint).
    """
    row = db.execute(
        text(
            """
            INSERT INTO scrape_runs (source, status, params, started_at, heartbeat_at)
            VALUES (:source, 'running', CAST(:params AS jsonb), NOW(), NOW())
            RETURNING id
            """
        ),
        {"source": source, "params": json.dumps(params, ensure_ascii=False)},
    ).fetchone()
    db.commit()
    assert row is not None, "scrape_runs INSERT returned no id"
    return int(row.id)


def update_heartbeat(db: Session, run_id: int, counters: dict[str, int]) -> None:
    """UPDATE heartbeat_at=NOW(), counters=:counters + total_seen/new_count колонки.

    total_seen/new_count извлекаются из counters (lots_fetched/lots_inserted) и
    пишутся в выделенные колонки, чтобы observability не показывала 0 (audit #1926).
    COALESCE: если ключа нет в counters — старое значение колонки сохраняется.
    """
    total_seen, new_count = _column_counts(counters)
    db.execute(
        text(
            """
            UPDATE scrape_runs
            SET heartbeat_at = NOW(),
                counters = CAST(:counters AS jsonb),
                total_seen = COALESCE(CAST(:total_seen AS int), total_seen),
                new_count = COALESCE(CAST(:new_count AS int), new_count)
            WHERE id = :run_id
            """
        ),
        {
            "run_id": run_id,
            "counters": json.dumps(counters),
            "total_seen": total_seen,
            "new_count": new_count,
        },
    )
    db.commit()


def is_cancelled(db: Session, run_id: int) -> bool:
    """Проверить status='cancelled' (cooperative cancel в long-running pipeline)."""
    row = db.execute(
        text("SELECT status FROM scrape_runs WHERE id = :id"),
        {"id": run_id},
    ).fetchone()
    return row is not None and row.status == "cancelled"


def mark_done(db: Session, run_id: int, counters: dict[str, int]) -> None:
    """Финализация run: status='done', finished_at=NOW(), counters + total_seen/new_count.

    total_seen/new_count извлекаются из counters (lots_fetched/lots_inserted) и пишутся
    в выделенные колонки — иначе admin/observability показывает 0 (audit #1926).
    """
    total_seen, new_count = _column_counts(counters)
    row = db.execute(
        text(
            """
            UPDATE scrape_runs
            SET status = 'done', finished_at = NOW(), heartbeat_at = NOW(),
                counters = CAST(:counters AS jsonb),
                total_seen = COALESCE(CAST(:total_seen AS int), total_seen),
                new_count = COALESCE(CAST(:new_count AS int), new_count)
            WHERE id = :run_id AND status = 'running'
            RETURNING id
            """
        ),
        {
            "run_id": run_id,
            "counters": json.dumps(counters),
            "total_seen": total_seen,
            "new_count": new_count,
        },
    ).first()
    if row is None:
        logger.warning("mark_done no-op: run_id=%d not in 'running' state", run_id)
    db.commit()


def mark_failed(db: Session, run_id: int, error: str, counters: dict[str, int]) -> None:
    """Финализация run: status='failed', error (первые 1000 символов).

    Defensive rollback: если до этого вызова в той же транзакции был ошибочный UPDATE,
    он мог оставить сессию в error state — rollback сбрасывает состояние.
    """
    try:
        db.rollback()  # defensive: cascade-safe if prior UPDATE left txn in error state
    except Exception:
        pass
    total_seen, new_count = _column_counts(counters)
    row = db.execute(
        text(
            """
            UPDATE scrape_runs
            SET status = 'failed', finished_at = NOW(), heartbeat_at = NOW(),
                error = :error, counters = CAST(:counters AS jsonb),
                total_seen = COALESCE(CAST(:total_seen AS int), total_seen),
                new_count = COALESCE(CAST(:new_count AS int), new_count)
            WHERE id = :run_id AND status = 'running'
            RETURNING id
            """
        ),
        {
            "run_id": run_id,
            "error": error[:1000],
            "counters": json.dumps(counters),
            "total_seen": total_seen,
            "new_count": new_count,
        },
    ).first()
    if row is None:
        logger.warning("mark_failed no-op: run_id=%d not in 'running' state", run_id)
    db.commit()
    # Алерт после коммита — статус уже персистирован в БД; best-effort (не бросает).
    _alert_on_run_id(db, run_id)


def mark_banned(db: Session, run_id: int, error: str, counters: dict[str, int]) -> None:
    """Финализация run: status='banned' (IP заблокирован Avito — 403/captcha).

    Per migration 015 — 'banned' задокументирован как 'Avito вернул 403/captcha'.
    Отличается от 'failed': это external constraint, не наш bug. Cooldown 2-4 часа.

    Defensive rollback: если до этого вызова в той же транзакции был ошибочный UPDATE,
    он мог оставить сессию в error state — rollback сбрасывает состояние.
    """
    try:
        db.rollback()  # defensive: cascade-safe if prior UPDATE left txn in error state
    except Exception:
        pass
    total_seen, new_count = _column_counts(counters)
    row = db.execute(
        text(
            """
            UPDATE scrape_runs
            SET status = 'banned', finished_at = NOW(), heartbeat_at = NOW(),
                error = :error, counters = CAST(:counters AS jsonb),
                total_seen = COALESCE(CAST(:total_seen AS int), total_seen),
                new_count = COALESCE(CAST(:new_count AS int), new_count)
            WHERE id = :run_id AND status = 'running'
            RETURNING id
            """
        ),
        {
            "run_id": run_id,
            "error": error[:1000],
            "counters": json.dumps(counters),
            "total_seen": total_seen,
            "new_count": new_count,
        },
    ).first()
    if row is None:
        logger.warning("mark_banned no-op: run_id=%d not in 'running' state", run_id)
    db.commit()
    # Алерт после коммита — статус уже персистирован в БД; best-effort (не бросает).
    _alert_on_run_id(db, run_id)


def mark_cancelled(db: Session, run_id: int) -> bool:
    """Set status='cancelled' если currently 'running'. Returns True если cancelled."""
    result = db.execute(
        text(
            """
            UPDATE scrape_runs
            SET status = 'cancelled', finished_at = NOW()
            WHERE id = :run_id AND status = 'running'
            RETURNING id
            """
        ),
        {"run_id": run_id},
    ).fetchone()
    db.commit()
    return result is not None


def list_recent(db: Session, *, source: str, limit: int = 10) -> list[dict[str, Any]]:
    """Список последних N runs по source, в порядке убывания started_at."""
    rows = (
        db.execute(
            text(
                """
            SELECT id AS run_id, source, status, params, counters, error,
                   started_at, finished_at, heartbeat_at
            FROM scrape_runs
            WHERE source = :source
            ORDER BY started_at DESC NULLS LAST
            LIMIT :limit
            """
            ),
            {"source": source, "limit": limit},
        )
        .mappings()
        .all()
    )
    return [dict(r) for r in rows]


def list_all(
    db: Session,
    *,
    source: str | None = None,
    status: str | None = None,
    limit: int = 50,
    offset: int = 0,
) -> tuple[int, list[dict[str, Any]]]:
    """Unified-список runs по всем source'ам с опц. фильтрами source/status.

    Возвращает (total, rows): total — кол-во строк под фильтром (без limit/offset),
    rows — текущая страница (ORDER BY started_at DESC, LIMIT/OFFSET).

    Используется единой scrapers-страницей вместо 3 per-source /runs.
    """
    where = ["1=1"]
    params: dict[str, Any] = {"limit": limit, "offset": offset}
    if source is not None:
        where.append("source = :source")
        params["source"] = source
    if status is not None:
        where.append("status = :status")
        params["status"] = status
    where_sql = " AND ".join(where)

    total_row = db.execute(
        text(f"SELECT COUNT(*) AS n FROM scrape_runs WHERE {where_sql}"),
        params,
    ).fetchone()
    total = int(total_row.n) if total_row is not None else 0

    rows = (
        db.execute(
            text(
                f"""
            SELECT id AS run_id, source, run_type, status, params, counters,
                   total_seen, new_count, started_at, finished_at,
                   heartbeat_at, error AS error_text
            FROM scrape_runs
            WHERE {where_sql}
            ORDER BY started_at DESC NULLS LAST
            LIMIT CAST(:limit AS int) OFFSET CAST(:offset AS int)
            """
            ),
            params,
        )
        .mappings()
        .all()
    )
    return total, [dict(r) for r in rows]
