"""Tests для DomClick detail-backfill orchestrator (issue #2000 Layer B).

Зеркалит конвенции test_avito_detail_backfill.py: module-level patch-target строки,
_mock_db(snapshot) helper, runs = MagicMock() assertions. DomClick-специфика:
единственный BrowserFetcher (нет curl-fallback), cookies из domclick_session_svc
(мокается целиком как модуль), эксепшн-триада DomClickBlockedError/DomClickParseError
вместо Avito's Blocked/RateLimited/ListingGone.
"""

from __future__ import annotations

import os
import sys
from unittest.mock import AsyncMock, MagicMock, patch

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

_wp_mock = MagicMock()
sys.modules.setdefault("weasyprint", _wp_mock)

import pytest  # noqa: E402
from scraper_kit.domclick_exceptions import DomClickBlockedError, DomClickParseError  # noqa: E402

from app.core import shutdown as _sd  # noqa: E402
from app.tasks.domclick_detail_backfill import (  # noqa: E402
    DomClickDetailBackfillResult,
    run_domclick_detail_backfill,
)


@pytest.fixture(autouse=True)
def _reset_shutdown() -> None:
    """shutdown — module-global Event: чистим вокруг каждого теста (изоляция #1182)."""
    _sd.reset_shutdown()
    yield
    _sd.reset_shutdown()


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


def _make_snapshot(n: int) -> list[dict]:
    return [
        {
            "id": i + 1,
            "source_url": f"https://ekaterinburg.domclick.ru/card/sale__flat__{i + 1}",
        }
        for i in range(n)
    ]


def _mock_db(snapshot: list[dict]) -> MagicMock:
    """Fake Session: first execute() returns snapshot via .mappings().all()."""
    db = MagicMock()
    sel = MagicMock()
    sel.mappings.return_value.all.return_value = snapshot
    db.execute.return_value = sel
    return db


def _mock_browser_fetcher_cls() -> MagicMock:
    """MagicMock class whose instance is a working async context manager."""
    instance = AsyncMock()
    instance.__aenter__ = AsyncMock(return_value=instance)
    instance.__aexit__ = AsyncMock(return_value=False)
    return MagicMock(return_value=instance)


_FETCH = "app.tasks.domclick_detail_backfill.fetch_detail"
_SAVE = "app.tasks.domclick_detail_backfill.save_detail_enrichment"
_RUNS = "app.tasks.domclick_detail_backfill.runs_mod"
_SLEEP = "app.tasks.domclick_detail_backfill.asyncio.sleep"
_SETTINGS = "app.tasks.domclick_detail_backfill.settings"
_SHUTDOWN = "app.tasks.domclick_detail_backfill.shutdown_requested"
_BROWSER_FETCHER = "app.tasks.domclick_detail_backfill.BrowserFetcher"
_SESSION_SVC = "app.tasks.domclick_detail_backfill.domclick_session_svc"

# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_backfill_empty_snapshot_marks_done() -> None:
    """Empty snapshot -> mark_done immediately, no fetch calls, no BrowserFetcher open."""
    db = _mock_db([])
    runs = MagicMock()
    fake_settings = MagicMock(browser_http_endpoint="http://browser:9000")
    mock_svc = MagicMock()
    mock_svc.load_session.return_value = {"CAS_ID": "123"}
    mock_bf_cls = _mock_browser_fetcher_cls()
    with (
        patch(_SETTINGS, fake_settings),
        patch(_SESSION_SVC, mock_svc),
        patch(_RUNS, runs),
        patch(_BROWSER_FETCHER, mock_bf_cls),
        patch(_FETCH) as mock_fetch,
    ):
        result = await run_domclick_detail_backfill(
            db, run_id=1, params={"batch_size": 10, "budget_sec": 60}
        )

    assert isinstance(result, DomClickDetailBackfillResult)
    assert result.attempted == 0
    assert result.enriched == 0
    mock_fetch.assert_not_called()
    mock_bf_cls.assert_not_called()
    runs.mark_done.assert_called_once()
    runs.mark_failed.assert_not_called()


@pytest.mark.asyncio
async def test_backfill_processes_snapshot_with_cookies_threaded() -> None:
    """3 listings -> all fetched+enriched, cookies from load_session() threaded into
    every fetch_detail() call kwargs, mark_done called."""
    snapshot = _make_snapshot(3)
    db = _mock_db(snapshot)
    runs = MagicMock()
    mock_enrichment = MagicMock()
    mock_fetch = AsyncMock(return_value=mock_enrichment)
    mock_save = MagicMock(return_value=True)
    fake_settings = MagicMock(browser_http_endpoint="http://browser:9000")
    fake_cookies = {"CAS_ID": "999", "qrator_jsid2": "abc"}
    mock_svc = MagicMock()
    mock_svc.load_session.return_value = fake_cookies
    mock_bf_cls = _mock_browser_fetcher_cls()
    with (
        patch(_SETTINGS, fake_settings),
        patch(_SESSION_SVC, mock_svc),
        patch(_RUNS, runs),
        patch(_BROWSER_FETCHER, mock_bf_cls),
        patch(_FETCH, mock_fetch),
        patch(_SAVE, mock_save),
        patch(_SLEEP, new_callable=AsyncMock),
    ):
        result = await run_domclick_detail_backfill(
            db, run_id=2, params={"batch_size": 10, "budget_sec": 3600}
        )

    assert result.attempted == 3
    assert result.enriched == 3
    assert result.blocked == 0
    assert result.failed == 0
    assert mock_fetch.call_count == 3
    mock_bf_cls.assert_called_once_with(
        source="domclick", endpoint=fake_settings.browser_http_endpoint
    )
    for call in mock_fetch.call_args_list:
        _, kwargs = call
        assert kwargs.get("cookies") == fake_cookies
        assert kwargs.get("browser_fetcher") is not None
    runs.mark_done.assert_called_once()
    runs.mark_failed.assert_not_called()


@pytest.mark.asyncio
async def test_backfill_cookies_none_still_proceeds_with_warning(caplog) -> None:
    """No valid session (load_session()->None) -> run still proceeds (fetch_detail
    called with cookies=None), but a warning is logged so operators refresh the session.
    """
    snapshot = _make_snapshot(1)
    db = _mock_db(snapshot)
    runs = MagicMock()
    mock_enrichment = MagicMock()
    mock_fetch = AsyncMock(return_value=mock_enrichment)
    mock_save = MagicMock(return_value=True)
    fake_settings = MagicMock(browser_http_endpoint="http://browser:9000")
    mock_svc = MagicMock()
    mock_svc.load_session.return_value = None
    mock_bf_cls = _mock_browser_fetcher_cls()
    with (
        caplog.at_level("WARNING"),
        patch(_SETTINGS, fake_settings),
        patch(_SESSION_SVC, mock_svc),
        patch(_RUNS, runs),
        patch(_BROWSER_FETCHER, mock_bf_cls),
        patch(_FETCH, mock_fetch),
        patch(_SAVE, mock_save),
        patch(_SLEEP, new_callable=AsyncMock),
    ):
        result = await run_domclick_detail_backfill(
            db, run_id=3, params={"batch_size": 10, "budget_sec": 3600}
        )

    assert result.enriched == 1
    _, kwargs = mock_fetch.call_args
    assert kwargs.get("cookies") is None
    assert "no valid DomClick session cookies" in caplog.text
    runs.mark_done.assert_called_once()
    runs.mark_failed.assert_not_called()


@pytest.mark.asyncio
async def test_backfill_blocked_abort_after_max_consecutive() -> None:
    """3 consecutive DomClickBlockedError -> abort, mark_done (NOT mark_failed).

    No IP-rotation recovery step exists for DomClick (single dedicated proxy) --
    abort happens on the SAME iteration the threshold is hit, no extra recovery calls.
    """
    snapshot = _make_snapshot(10)
    db = _mock_db(snapshot)
    runs = MagicMock()
    blocked_exc = DomClickBlockedError("QRATOR challenge page detected")
    mock_fetch = AsyncMock(side_effect=blocked_exc)
    fake_settings = MagicMock(browser_http_endpoint="http://browser:9000")
    mock_svc = MagicMock()
    mock_svc.load_session.return_value = {"CAS_ID": "123"}
    mock_bf_cls = _mock_browser_fetcher_cls()
    with (
        patch(_SETTINGS, fake_settings),
        patch(_SESSION_SVC, mock_svc),
        patch(_RUNS, runs),
        patch(_BROWSER_FETCHER, mock_bf_cls),
        patch(_FETCH, mock_fetch),
        patch(_SLEEP, new_callable=AsyncMock),
    ):
        result = await run_domclick_detail_backfill(
            db,
            run_id=4,
            params={"batch_size": 10, "budget_sec": 3600, "max_consecutive_blocks": 3},
        )

    assert result.blocked == 3
    assert result.attempted == 3
    assert result.enriched == 0
    runs.mark_done.assert_called_once()
    runs.mark_failed.assert_not_called()


@pytest.mark.asyncio
async def test_backfill_parse_error_counts_failed_no_abort() -> None:
    """DomClickParseError (schema drift, not a block) -> failed++, consecutive-block
    breaker NOT touched, run does NOT abort, continues to next listing.
    """
    snapshot = _make_snapshot(2)
    db = _mock_db(snapshot)
    runs = MagicMock()
    mock_enrichment = MagicMock()
    mock_fetch = AsyncMock(
        side_effect=[DomClickParseError("__SSR_STATE__ not found"), mock_enrichment]
    )
    mock_save = MagicMock(return_value=True)
    fake_settings = MagicMock(browser_http_endpoint="http://browser:9000")
    mock_svc = MagicMock()
    mock_svc.load_session.return_value = {"CAS_ID": "123"}
    mock_bf_cls = _mock_browser_fetcher_cls()
    with (
        patch(_SETTINGS, fake_settings),
        patch(_SESSION_SVC, mock_svc),
        patch(_RUNS, runs),
        patch(_BROWSER_FETCHER, mock_bf_cls),
        patch(_FETCH, mock_fetch),
        patch(_SAVE, mock_save),
        patch(_SLEEP, new_callable=AsyncMock),
    ):
        result = await run_domclick_detail_backfill(
            db,
            run_id=5,
            params={"batch_size": 10, "budget_sec": 3600, "max_consecutive_blocks": 3},
        )

    assert result.failed == 1
    assert result.enriched == 1
    assert result.attempted == 2
    assert result.blocked == 0
    runs.mark_done.assert_called_once()
    runs.mark_failed.assert_not_called()


@pytest.mark.asyncio
async def test_backfill_sigterm_drain_breaks_and_marks_done_partial() -> None:
    """#1182 Phase 2: shutdown_requested() True -> loop выходит на границе карточки,
    mark_done вызывается с ЧАСТИЧНЫМИ счётчиками (не mark_failed).
    """
    snapshot = _make_snapshot(3)
    db = _mock_db(snapshot)
    runs = MagicMock()
    mock_enrichment = MagicMock()
    mock_fetch = AsyncMock(return_value=mock_enrichment)
    mock_save = MagicMock(return_value=True)
    fake_settings = MagicMock(browser_http_endpoint="http://browser:9000")
    mock_svc = MagicMock()
    mock_svc.load_session.return_value = {"CAS_ID": "123"}
    mock_bf_cls = _mock_browser_fetcher_cls()
    with (
        patch(_SETTINGS, fake_settings),
        patch(_SESSION_SVC, mock_svc),
        patch(_RUNS, runs),
        patch(_BROWSER_FETCHER, mock_bf_cls),
        patch(_FETCH, mock_fetch),
        patch(_SAVE, mock_save),
        patch(_SLEEP, new_callable=AsyncMock),
        patch(_SHUTDOWN, side_effect=[False, True]),
    ):
        result = await run_domclick_detail_backfill(
            db, run_id=6, params={"batch_size": 10, "budget_sec": 3600}
        )

    assert result.attempted == 1
    assert result.enriched == 1
    assert mock_fetch.call_count == 1
    runs.mark_done.assert_called_once()
    runs.mark_failed.assert_not_called()
    runs.mark_cancelled.assert_not_called()
    done_counters = runs.mark_done.call_args.args[2]
    assert done_counters["attempted"] == 1


@pytest.mark.asyncio
async def test_backfill_budget_guard_stops_loop() -> None:
    """Budget expired before first listing -> fetch_detail not called."""
    snapshot = _make_snapshot(5)
    db = _mock_db(snapshot)
    runs = MagicMock()
    mono_values = iter([0.0, 999.0, 999.0])
    fake_settings = MagicMock(browser_http_endpoint="http://browser:9000")
    mock_svc = MagicMock()
    mock_svc.load_session.return_value = {"CAS_ID": "123"}
    mock_bf_cls = _mock_browser_fetcher_cls()
    with (
        patch(_SETTINGS, fake_settings),
        patch(_SESSION_SVC, mock_svc),
        patch(_RUNS, runs),
        patch(_BROWSER_FETCHER, mock_bf_cls),
        patch("app.tasks.domclick_detail_backfill.time.monotonic", side_effect=mono_values),
        patch(_FETCH) as mock_fetch,
    ):
        await run_domclick_detail_backfill(db, run_id=7, params={"batch_size": 5, "budget_sec": 1})

    mock_fetch.assert_not_called()
    runs.mark_done.assert_called_once()


@pytest.mark.asyncio
async def test_backfill_top_level_exception_marks_failed() -> None:
    """db.execute raises -> mark_failed called, exception re-raised."""
    db = MagicMock()
    db.execute.side_effect = RuntimeError("DB connection lost")
    runs = MagicMock()
    fake_settings = MagicMock(browser_http_endpoint="http://browser:9000")
    mock_svc = MagicMock()
    mock_svc.load_session.return_value = {"CAS_ID": "123"}
    mock_bf_cls = _mock_browser_fetcher_cls()
    with (
        patch(_SETTINGS, fake_settings),
        patch(_SESSION_SVC, mock_svc),
        patch(_RUNS, runs),
        patch(_BROWSER_FETCHER, mock_bf_cls),
    ):
        with pytest.raises(RuntimeError, match="DB connection lost"):
            await run_domclick_detail_backfill(
                db, run_id=8, params={"batch_size": 5, "budget_sec": 60}
            )

    runs.mark_failed.assert_called_once()
    runs.mark_done.assert_not_called()


@pytest.mark.asyncio
async def test_backfill_generic_exception_continues_and_rolls_back() -> None:
    """Unexpected exception on one listing -> failed++, db.rollback(), loop continues."""
    snapshot = _make_snapshot(2)
    db = _mock_db(snapshot)
    runs = MagicMock()
    mock_enrichment = MagicMock()
    mock_fetch = AsyncMock(side_effect=[RuntimeError("unexpected"), mock_enrichment])
    fake_settings = MagicMock(browser_http_endpoint="http://browser:9000")
    mock_svc = MagicMock()
    mock_svc.load_session.return_value = {"CAS_ID": "123"}
    mock_bf_cls = _mock_browser_fetcher_cls()
    with (
        patch(_SETTINGS, fake_settings),
        patch(_SESSION_SVC, mock_svc),
        patch(_RUNS, runs),
        patch(_BROWSER_FETCHER, mock_bf_cls),
        patch(_FETCH, mock_fetch),
        patch(_SAVE, return_value=True),
        patch(_SLEEP, new_callable=AsyncMock),
    ):
        result = await run_domclick_detail_backfill(
            db, run_id=9, params={"batch_size": 10, "budget_sec": 3600}
        )

    assert result.failed == 1
    assert result.enriched == 1
    assert result.attempted == 2
    db.rollback.assert_called()
    runs.mark_done.assert_called_once()
