"""Тесты для backend/app/workers/tasks/scrape_kn.py — Redis-lock owner-token
и resume_kn_run retry on lock_held (P2 issue #1216).

Покрытие:
- `_region_lock` value содержит уникальный uuid (а не литерал "1");
- два последовательных вызова получают разные токены (uniqueness);
- `r.eval` вызывается с Lua check-and-delete: правильный key + наш token;
- если в Redis под key лежит ЧУЖОЙ token — Lua возвращает 0 (mock-симуляция);
- `_LOCK_TTL_SECONDS` поднят до 45 мин (полтора max sweep duration);
- `resume_kn_run` при lock_held → `self.retry(countdown=300, max_retries=12)`,
  а НЕ silent skip (issue #1216 fix #2).
"""

from __future__ import annotations

from typing import Any
from unittest.mock import MagicMock, patch

# ── _region_lock owner-token ──────────────────────────────────────────────────


def test_region_lock_value_is_uuid_token_not_literal_one() -> None:
    """`r.set` должен получить uuid4-hex (32 hex chars) — НЕ литерал '1'.

    Это фикс fix #1 из issue #1216: без owner-токена finally безусловно
    делал `r.delete(key)` → можно было снести ЧУЖОЙ lock после TTL-expiry.
    """
    from app.workers.tasks import scrape_kn as mod

    mock_redis = MagicMock()
    mock_redis.set.return_value = True  # acquired

    with patch.object(mod.redis.Redis, "from_url", return_value=mock_redis):
        with mod._region_lock(66, None) as got_lock:
            assert got_lock is True

    # `r.set(key, token, nx=True, ex=...)` — token positional[1]
    set_args, _set_kwargs = mock_redis.set.call_args
    token = set_args[1]
    assert token != "1", "lock value must be unique owner-token, not literal '1'"
    assert isinstance(token, str)
    assert len(token) == 32, f"uuid4.hex is 32 chars, got len={len(token)}"
    int(token, 16)  # raises if not hex


def test_region_lock_tokens_are_unique_across_calls() -> None:
    """Два последовательных context-manager call → два РАЗНЫХ токена.

    Иначе owner-check (Lua GET == ARGV[1]) станет бессмысленным —
    оба worker'а получили бы одинаковый token, и второй снёс бы lock первого.
    """
    from app.workers.tasks import scrape_kn as mod

    mock_redis = MagicMock()
    mock_redis.set.return_value = True

    captured_tokens: list[str] = []
    with patch.object(mod.redis.Redis, "from_url", return_value=mock_redis):
        with mod._region_lock(66, None):
            captured_tokens.append(mock_redis.set.call_args[0][1])
        with mod._region_lock(66, None):
            captured_tokens.append(mock_redis.set.call_args[0][1])

    assert len(captured_tokens) == 2
    assert captured_tokens[0] != captured_tokens[1], "tokens must be unique per call"


def test_region_lock_release_uses_lua_eval_with_own_token() -> None:
    """В finally вызывается `r.eval(LUA, 1, key, token)` — НЕ `r.delete(key)`.

    Это фикс fix #1 из issue #1216 (check-and-delete семантика).
    """
    from app.workers.tasks import scrape_kn as mod

    mock_redis = MagicMock()
    mock_redis.set.return_value = True
    mock_redis.eval.return_value = 1  # пусть Lua говорит «удалил»

    with patch.object(mod.redis.Redis, "from_url", return_value=mock_redis):
        with mod._region_lock(66, ["devA"]):
            set_token = mock_redis.set.call_args[0][1]

    # eval должен быть вызван ровно один раз с правильными аргументами.
    mock_redis.eval.assert_called_once()
    eval_args = mock_redis.eval.call_args[0]
    assert eval_args[0] == mod._RELEASE_LOCK_LUA
    assert eval_args[1] == 1  # numkeys
    assert eval_args[2] == mod._lock_key(66, ["devA"])  # KEYS[1]
    assert eval_args[3] == set_token  # ARGV[1] — наш token

    # delete (без guard) НЕ должен вызываться — был бы регрессией к старому багу.
    mock_redis.delete.assert_not_called()


def test_region_lock_release_skipped_when_not_acquired() -> None:
    """Если acquire не удался — finally НЕ должен делать eval/delete.

    Иначе worker, получивший skipped, снёс бы lock владельца.
    """
    from app.workers.tasks import scrape_kn as mod

    mock_redis = MagicMock()
    mock_redis.set.return_value = None  # nx=True + key exists → None

    with patch.object(mod.redis.Redis, "from_url", return_value=mock_redis):
        with mod._region_lock(66, None) as got_lock:
            assert got_lock is False

    mock_redis.eval.assert_not_called()
    mock_redis.delete.assert_not_called()


def test_release_lock_lua_script_contract() -> None:
    """Smoke: Lua script содержит check-and-delete семантику.

    Если script отредактируют наугад — этот guard упадёт.
    """
    from app.workers.tasks.scrape_kn import _RELEASE_LOCK_LUA

    assert 'redis.call("GET", KEYS[1])' in _RELEASE_LOCK_LUA
    assert "ARGV[1]" in _RELEASE_LOCK_LUA
    assert 'redis.call("DEL", KEYS[1])' in _RELEASE_LOCK_LUA
    assert "else return 0 end" in _RELEASE_LOCK_LUA


def test_region_lock_uses_full_ttl_45_min() -> None:
    """`r.set` получает `ex=_LOCK_TTL_SECONDS == 45*60 == 2700`.

    Issue #1216 fix: было 30 мин (== max sweep duration → нулевой запас);
    поднимаем до 45 мин = полтора sweep duration.
    """
    from app.workers.tasks import scrape_kn as mod

    assert mod._LOCK_TTL_SECONDS == 45 * 60

    mock_redis = MagicMock()
    mock_redis.set.return_value = True

    with patch.object(mod.redis.Redis, "from_url", return_value=mock_redis):
        with mod._region_lock(66, None):
            pass

    _, set_kwargs = mock_redis.set.call_args
    assert set_kwargs["ex"] == 2700
    assert set_kwargs["nx"] is True


def test_lua_script_simulation_rejects_foreign_token() -> None:
    """Симуляция Lua check-and-delete на python-уровне:
    если value под key != нашему token → 0 (не удалили), иначе → 1.

    Гарантирует что контракт скрипта корректен; реальная атомарность —
    забота Redis (тестируется на integration-уровне).
    """
    from app.workers.tasks.scrape_kn import _RELEASE_LOCK_LUA

    def simulate(stored: str | None, our_token: str) -> int:
        # Грубая модель: parse-free assertion на ключевые ветки скрипта.
        assert 'if redis.call("GET", KEYS[1]) == ARGV[1]' in _RELEASE_LOCK_LUA
        assert "else return 0 end" in _RELEASE_LOCK_LUA
        if stored == our_token:
            return 1
        return 0

    own = "a" * 32
    foreign = "b" * 32
    assert simulate(stored=own, our_token=own) == 1
    assert simulate(stored=foreign, our_token=own) == 0
    assert simulate(stored=None, our_token=own) == 0


# ── resume_kn_run retry on lock_held ──────────────────────────────────────────


def _patch_session_local(mock_db: MagicMock) -> Any:
    """Helper: SessionLocal импортируется внутри resume_kn_run из `app.core.db`,
    поэтому патчить надо именно там, а не на module-level scrape_kn."""
    return patch("app.core.db.SessionLocal", return_value=mock_db)


def test_resume_kn_run_retries_on_lock_held_not_silent_skip() -> None:
    """`resume_kn_run` при lock_held → `self.retry(countdown=300, max_retries=12)`.

    Старое поведение (silent skip → return {'skipped': True}) теряло run до
    недельного beat — это и есть P2 issue #1216 fix #2.

    Patch'им `mod.resume_kn_run.retry` напрямую: при bind=True Celery передаёт
    task-instance как `self`, и self.retry == task.retry. Подменяем его на
    side_effect-exception, ловим, проверяем kwargs.
    """
    from app.workers.tasks import scrape_kn as mod

    row = {"region_codes": [66], "developer_ids": None}
    mock_result = MagicMock()
    mock_result.mappings.return_value.first.return_value = row
    mock_db = MagicMock()
    mock_db.execute.return_value = mock_result

    mock_redis = MagicMock()
    mock_redis.set.return_value = None  # lock_held

    class FakeRetryError(Exception):
        pass

    with (
        _patch_session_local(mock_db),
        patch.object(mod.redis.Redis, "from_url", return_value=mock_redis),
        patch.object(mod, "_log_task_received"),
        patch.object(mod, "_log_task_skipped"),
        patch.object(
            mod.resume_kn_run, "retry", side_effect=FakeRetryError("re-queue")
        ) as mock_retry,
    ):
        raised = False
        try:
            mod.resume_kn_run.run(42)
        except FakeRetryError:
            raised = True

    assert raised, "resume_kn_run must raise (via self.retry), not return"
    mock_retry.assert_called_once()
    retry_kwargs = mock_retry.call_args.kwargs
    assert retry_kwargs["countdown"] == 300
    assert retry_kwargs["max_retries"] == 12
    assert "exc" in retry_kwargs


def test_resume_kn_run_lock_held_does_not_silently_return_skipped() -> None:
    """Регрессия-guard: при lock_held НЕ возвращаем dict — поднимаем Retry.

    Подменяем self.retry на sentinel-exception; если код пошёл бы по
    старой ветке (silent skip → return), exception не возник бы.
    """
    from app.workers.tasks import scrape_kn as mod

    row = {"region_codes": [66], "developer_ids": None}
    mock_result = MagicMock()
    mock_result.mappings.return_value.first.return_value = row
    mock_db = MagicMock()
    mock_db.execute.return_value = mock_result

    mock_redis = MagicMock()
    mock_redis.set.return_value = None  # lock_held

    class SentinelRetryError(Exception):
        pass

    result_returned: Any = None
    raised = False
    with (
        _patch_session_local(mock_db),
        patch.object(mod.redis.Redis, "from_url", return_value=mock_redis),
        patch.object(mod, "_log_task_received"),
        patch.object(mod, "_log_task_skipped"),
        patch.object(mod.resume_kn_run, "retry", side_effect=SentinelRetryError()),
    ):
        try:
            result_returned = mod.resume_kn_run.run(42)
        except SentinelRetryError:
            raised = True

    assert raised, "resume_kn_run must call self.retry (raise Retry), not return"
    assert result_returned is None


def test_resume_kn_run_run_not_found_still_returns_skipped() -> None:
    """Если row не найден — возвращаем `{'skipped': True, 'reason': 'run_not_found'}`.

    Это легитимный skip (run уже почистили), retry бесполезен. Проверяем что
    fix #2 не сломал валидные ранние выходы.
    """
    from app.workers.tasks import scrape_kn as mod

    mock_result = MagicMock()
    mock_result.mappings.return_value.first.return_value = None
    mock_db = MagicMock()
    mock_db.execute.return_value = mock_result

    with (
        _patch_session_local(mock_db),
        patch.object(mod.resume_kn_run, "retry") as mock_retry,
    ):
        result = mod.resume_kn_run.run(999)

    assert result == {"skipped": True, "reason": "run_not_found", "run_id": 999}
    mock_retry.assert_not_called()


# ── scrape_kn_region остаётся skipped (а не retry) — это by-design ────────────


def test_scrape_kn_region_lock_held_returns_skipped() -> None:
    """`scrape_kn_region` (scheduled beat) при lock_held → return skipped.

    В отличие от resume_kn_run, scheduled run крутится по weekly beat —
    пропустить разовый запуск ок, beat поднимет в следующий раз. Retry'ить
    тут было бы спамом задач.
    """
    from app.workers.tasks import scrape_kn as mod

    mock_redis = MagicMock()
    mock_redis.set.return_value = None  # lock_held

    with (
        patch.object(mod.redis.Redis, "from_url", return_value=mock_redis),
        patch.object(mod, "_log_task_received"),
        patch.object(mod, "_log_task_skipped"),
        patch.object(mod.settings, "scrape_kn_jitter_seconds", 0),
        patch.object(mod.settings, "scrape_kn_state_path", ""),
        patch.object(mod.scrape_kn_region, "retry") as mock_retry,
    ):
        result = mod.scrape_kn_region.run(66, None, True)

    assert result["skipped"] is True
    assert result["reason"] == "lock_held"
    assert result["region_code"] == 66
    mock_retry.assert_not_called()
