"""Offline smoke tests for city sweep utilities + endpoints.

Тесты НЕ требуют реального DB — мокируем SessionLocal и scrape_runs модуль.
"""

from __future__ import annotations

import os

os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")

from datetime import UTC
from unittest.mock import AsyncMock, MagicMock, patch

import pytest

# ── EKB_ANCHORS ────────────────────────────────────────────────────────────


def test_ekb_anchors_count() -> None:
    from scraper_kit.orchestration.pipeline import EKB_ANCHORS

    assert len(EKB_ANCHORS) == 5
    for lat, lon, name in EKB_ANCHORS:
        assert 56.5 < lat < 57.0, f"lat={lat} out of EKB range for anchor {name}"
        assert 60.3 < lon < 60.8, f"lon={lon} out of EKB range for anchor {name}"
        assert isinstance(name, str) and name


# ── CitySweepCounters ───────────────────────────────────────────────────────


def test_city_sweep_counters_defaults() -> None:
    from scraper_kit.orchestration.pipeline import CitySweepCounters

    c = CitySweepCounters()
    assert c.anchors_total == 0
    assert c.lots_fetched == 0
    assert c.errors_count == 0


def test_city_sweep_counters_to_dict() -> None:
    from scraper_kit.orchestration.pipeline import CitySweepCounters

    c = CitySweepCounters(anchors_total=5, anchors_done=2, lots_fetched=150)
    d = c.to_dict()
    assert d["anchors_total"] == 5
    assert d["anchors_done"] == 2
    assert d["lots_fetched"] == 150
    assert "errors_count" in d
    assert "houses_enriched" in d
    assert "detail_enriched" in d


def test_city_sweep_counters_to_dict_all_keys() -> None:
    from dataclasses import fields

    from scraper_kit.orchestration.pipeline import CitySweepCounters

    c = CitySweepCounters()
    d = c.to_dict()
    expected_keys = {f.name for f in fields(c)}
    assert set(d.keys()) == expected_keys


# ── scrape_runs utilities (unit, no DB) ────────────────────────────────────


def test_scrape_runs_create_run_returns_id() -> None:
    from app.services.scrape_runs import create_run

    mock_db = MagicMock()
    mock_row = MagicMock()
    mock_row.id = 42
    mock_db.execute.return_value.fetchone.return_value = mock_row

    run_id = create_run(mock_db, source="avito_city_sweep", params={"pages_per_anchor": 3})

    assert run_id == 42
    mock_db.commit.assert_called()


def test_scrape_runs_is_cancelled_true() -> None:
    from app.services.scrape_runs import is_cancelled

    mock_db = MagicMock()
    mock_row = MagicMock()
    mock_row.status = "cancelled"
    mock_db.execute.return_value.fetchone.return_value = mock_row

    assert is_cancelled(mock_db, 99) is True


def test_scrape_runs_is_cancelled_false_when_running() -> None:
    from app.services.scrape_runs import is_cancelled

    mock_db = MagicMock()
    mock_row = MagicMock()
    mock_row.status = "running"
    mock_db.execute.return_value.fetchone.return_value = mock_row

    assert is_cancelled(mock_db, 99) is False


def test_scrape_runs_is_cancelled_false_when_not_found() -> None:
    from app.services.scrape_runs import is_cancelled

    mock_db = MagicMock()
    mock_db.execute.return_value.fetchone.return_value = None

    assert is_cancelled(mock_db, 999) is False


def test_scrape_runs_mark_cancelled_returns_bool() -> None:
    from app.services.scrape_runs import mark_cancelled

    mock_db = MagicMock()
    mock_db.execute.return_value.fetchone.return_value = MagicMock()  # row found

    result = mark_cancelled(mock_db, 10)
    assert result is True
    mock_db.commit.assert_called()


def test_scrape_runs_mark_cancelled_returns_false_when_not_running() -> None:
    from app.services.scrape_runs import mark_cancelled

    mock_db = MagicMock()
    mock_db.execute.return_value.fetchone.return_value = None  # no row (not running)

    result = mark_cancelled(mock_db, 10)
    assert result is False


# ── total_seen / new_count column population (audit #1926) ──────────────────


def _captured_params(mock_db: MagicMock) -> dict:
    """Извлечь dict bind-параметров из последнего db.execute(text(...), params)."""
    assert mock_db.execute.call_args is not None, "db.execute was not called"
    return mock_db.execute.call_args.args[1]


def test_column_counts_maps_lots_fetched_inserted() -> None:
    """_column_counts: lots_fetched→total_seen, lots_inserted→new_count."""
    from app.services.scrape_runs import _column_counts

    total_seen, new_count = _column_counts(
        {"lots_fetched": 150, "lots_inserted": 12, "anchors_done": 5}
    )
    assert total_seen == 150
    assert new_count == 12


def test_column_counts_prefers_explicit_total_seen_new_count() -> None:
    """Если в counters уже есть total_seen/new_count — они приоритетнее lots_*."""
    from app.services.scrape_runs import _column_counts

    total_seen, new_count = _column_counts(
        {"total_seen": 99, "new_count": 7, "lots_fetched": 1, "lots_inserted": 1}
    )
    assert total_seen == 99
    assert new_count == 7


def test_column_counts_returns_none_when_absent() -> None:
    """Нет ни одного из ключей → None (COALESCE сохранит старое значение колонки)."""
    from app.services.scrape_runs import _column_counts

    total_seen, new_count = _column_counts({"anchors_done": 3})
    assert total_seen is None
    assert new_count is None


def test_mark_done_persists_total_seen_new_count_columns() -> None:
    """mark_done пишет total_seen/new_count из lots_fetched/lots_inserted (audit #1926)."""
    from app.services.scrape_runs import mark_done

    mock_db = MagicMock()
    mock_db.execute.return_value.first.return_value = MagicMock(id=1)  # row updated

    mark_done(mock_db, 1, {"lots_fetched": 200, "lots_inserted": 18, "anchors_done": 5})

    params = _captured_params(mock_db)
    assert params["total_seen"] == 200
    assert params["new_count"] == 18
    # SQL must SET the dedicated columns, not only the jsonb blob
    sql = str(mock_db.execute.call_args.args[0])
    assert "total_seen" in sql
    assert "new_count" in sql
    mock_db.commit.assert_called()


def test_update_heartbeat_persists_total_seen_new_count_columns() -> None:
    """update_heartbeat также обновляет колонки (live progress, не только финал)."""
    from app.services.scrape_runs import update_heartbeat

    mock_db = MagicMock()

    update_heartbeat(mock_db, 1, {"lots_fetched": 75, "lots_inserted": 4})

    params = _captured_params(mock_db)
    assert params["total_seen"] == 75
    assert params["new_count"] == 4
    mock_db.commit.assert_called()


def test_mark_done_yandex_counters_populate_columns() -> None:
    """End-to-end shape: YandexCitySweepCounters.to_dict() → mark_done → columns set."""
    from scraper_kit.orchestration.pipeline import YandexCitySweepCounters

    from app.services.scrape_runs import mark_done

    counters = YandexCitySweepCounters(
        anchors_total=1, anchors_done=1, lots_fetched=540, lots_inserted=42
    )
    mock_db = MagicMock()
    mock_db.execute.return_value.first.return_value = MagicMock(id=1)

    mark_done(mock_db, 1, counters.to_dict())

    params = _captured_params(mock_db)
    assert params["total_seen"] == 540
    assert params["new_count"] == 42


def test_mark_done_avito_cian_counters_populate_columns() -> None:
    """End-to-end shape: CitySweepCounters.to_dict() (avito/cian) → mark_done → columns set.

    CitySweepCounters обслуживает avito + cian city-sweep'ы и проходит тот же путь
    counters.to_dict() → mark_done, что и Yandex — фикс #1926 должен заполнять
    total_seen/new_count и для них (issue: «то же касается CitySweepCounters»).
    """
    from scraper_kit.orchestration.pipeline import CitySweepCounters

    from app.services.scrape_runs import mark_done

    counters = CitySweepCounters(
        anchors_total=4, anchors_done=4, lots_fetched=1234, lots_inserted=56
    )
    mock_db = MagicMock()
    mock_db.execute.return_value.first.return_value = MagicMock(id=1)

    mark_done(mock_db, 1, counters.to_dict())

    params = _captured_params(mock_db)
    assert params["total_seen"] == 1234
    assert params["new_count"] == 56


def test_mark_failed_persists_total_seen_new_count_columns() -> None:
    """Даже при mark_failed колонки заполняются из частично собранных counters (#1926).

    Sweep может упасть на середине, успев собрать тысячи лотов — observability должна
    видеть total_seen>0, а не 0 для failed-прогона.
    """
    from app.services.scrape_runs import mark_failed

    mock_db = MagicMock()
    mock_db.execute.return_value.first.return_value = MagicMock(id=1)

    mark_failed(mock_db, 1, "boom", {"lots_fetched": 300, "lots_inserted": 9})

    # mark_failed после UPDATE дёргает _alert_on_run_id (SELECT source ...), поэтому
    # последний execute — это alert-SELECT, а не наш UPDATE. Ищем UPDATE-вызов явно.
    update_call = next(
        c for c in mock_db.execute.call_args_list if "UPDATE scrape_runs" in str(c.args[0])
    )
    params = update_call.args[1]
    assert params["total_seen"] == 300
    assert params["new_count"] == 9
    sql = str(update_call.args[0])
    assert "total_seen" in sql
    assert "new_count" in sql


# ── FastAPI endpoints (offline) ─────────────────────────────────────────────


@pytest.fixture
def app_with_admin():
    from fastapi import FastAPI
    from fastapi.testclient import TestClient

    # Lazy import to avoid side effects before env is set
    from app.api.v1 import admin as admin_module
    from app.core.db import get_db

    app = FastAPI()
    app.include_router(admin_module.router, prefix="/api/v1/admin")

    def fake_db():
        yield MagicMock()

    app.dependency_overrides[get_db] = fake_db
    return TestClient(app)


def test_start_endpoint_validates_pages_per_anchor(app_with_admin) -> None:
    """pages_per_anchor=99 превышает max=10 → 422."""
    r = app_with_admin.post(
        "/api/v1/admin/scrape/avito-city-sweep",
        json={"pages_per_anchor": 99},
    )
    assert r.status_code == 422


def test_start_endpoint_validates_request_delay_too_low(app_with_admin) -> None:
    """request_delay_sec=1.0 меньше min=3.0 → 422."""
    r = app_with_admin.post(
        "/api/v1/admin/scrape/avito-city-sweep",
        json={"request_delay_sec": 1.0},
    )
    assert r.status_code == 422


def test_start_endpoint_ok(app_with_admin) -> None:
    """Valid request → 200, run_id в ответе; kit sweep вызван с DI (config/matcher/enrichment)."""
    from app.services.scraper_adapters import (
        RealEnrichmentJobs,
        RealMatcherAdapter,
        RealScraperConfig,
    )

    with (
        patch("app.api.v1.admin.has_running_run", return_value=False),
        patch("app.services.scrape_runs.create_run", return_value=7),
        patch("app.api.v1.admin.run_avito_city_sweep", new_callable=AsyncMock) as mock_sweep,
    ):
        r = app_with_admin.post(
            "/api/v1/admin/scrape/avito-city-sweep",
            json={"pages_per_anchor": 2, "detail_top_n": 10},
        )
    assert r.status_code == 200
    body = r.json()
    assert body["run_id"] == 7
    assert body["status"] == "running"
    assert body["pages_per_anchor"] == 2

    mock_sweep.assert_called_once()
    kw = mock_sweep.call_args.kwargs
    assert isinstance(kw["config"], RealScraperConfig)
    assert isinstance(kw["matcher"], RealMatcherAdapter)
    assert isinstance(kw["enrichment"], RealEnrichmentJobs)
    assert "proxy_provider" in kw


def test_cancel_endpoint_exists(app_with_admin) -> None:
    """Cancel endpoint returns run_id + cancelled flag."""
    with patch("app.services.scrape_runs.mark_cancelled", return_value=True):
        r = app_with_admin.post("/api/v1/admin/scrape/avito-city-sweep/123/cancel")
    assert r.status_code == 200
    body = r.json()
    assert body["run_id"] == 123
    assert body["cancelled"] is True
    assert body["ok"] is True


def test_cancel_endpoint_not_running(app_with_admin) -> None:
    """Cancel на уже завершённый run → cancelled=False."""
    with patch("app.services.scrape_runs.mark_cancelled", return_value=False):
        r = app_with_admin.post("/api/v1/admin/scrape/avito-city-sweep/456/cancel")
    assert r.status_code == 200
    assert r.json()["cancelled"] is False


def test_list_runs_endpoint(app_with_admin) -> None:
    """List runs endpoint возвращает список."""
    from datetime import datetime

    fake_rows = [
        {
            "run_id": 1,
            "source": "avito_city_sweep",
            "status": "done",
            "params": {"pages_per_anchor": 3},
            "counters": {"lots_fetched": 300},
            "error": None,
            "started_at": datetime(2025, 1, 1, tzinfo=UTC),
            "finished_at": datetime(2025, 1, 1, 1, tzinfo=UTC),
            "heartbeat_at": datetime(2025, 1, 1, 1, tzinfo=UTC),
        }
    ]
    with patch("app.services.scrape_runs.list_recent", return_value=fake_rows):
        r = app_with_admin.get("/api/v1/admin/scrape/avito-city-sweep/runs")
    assert r.status_code == 200
    body = r.json()
    assert len(body) == 1
    assert body[0]["run_id"] == 1
    assert body[0]["status"] == "done"
    assert "2025-01-01" in body[0]["started_at"]
