"""
Story 9.01 — Test Suite
========================
NightlyEpochScheduler: APScheduler Cron + Redis Distributed Lock

File under test: core/epoch/nightly_epoch_scheduler.py

BB Tests (4):
  BB1: _acquire_lock() on fresh Redis mock returns True
  BB2: _acquire_lock() called twice — second call returns False (lock held)
  BB3: Lock key has TTL set to 7200
  BB4: _run_with_lock() releases lock even when epoch callback raises

WB Tests (4):
  WB1: Redis.set is called with nx=True and ex=7200
  WB2: AEST timezone (Australia/Sydney) is used in scheduler construction
  WB3: scheduler.add_job() uses 'cron' trigger with day_of_week='sun', hour=2
  WB4: stop() delegates to scheduler.shutdown(wait=False)

All tests use MagicMock — zero live Redis or APScheduler I/O.
"""
from __future__ import annotations

import asyncio
import sys
from unittest.mock import MagicMock, patch

import pytest

sys.path.insert(0, "/mnt/e/genesis-system")

from core.epoch.nightly_epoch_scheduler import (
    EPOCH_LOCK_KEY,
    EPOCH_LOCK_TTL,
    NightlyEpochScheduler,
)

# The patch target must match where AsyncIOScheduler is looked up at runtime.
_PATCH_TARGET = "core.epoch.nightly_epoch_scheduler.AsyncIOScheduler"


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


def _make_fresh_redis() -> MagicMock:
    """Return a Redis mock where set() returns True (lock available)."""
    redis_mock = MagicMock()
    redis_mock.set.return_value = True
    redis_mock.delete.return_value = 1
    redis_mock.get.return_value = None
    return redis_mock


def _make_locked_redis() -> MagicMock:
    """Return a Redis mock where set() returns None (lock already held)."""
    redis_mock = MagicMock()
    redis_mock.set.return_value = None   # SET NX returns None when key exists
    redis_mock.delete.return_value = 0
    redis_mock.get.return_value = b"locked"
    return redis_mock


def _make_scheduler(redis_mock: MagicMock, **kwargs) -> NightlyEpochScheduler:
    """
    Construct a NightlyEpochScheduler with a mocked AsyncIOScheduler so no
    real cron threads are started during tests.
    """
    mock_scheduler_instance = MagicMock()
    with patch(_PATCH_TARGET, return_value=mock_scheduler_instance):
        sched = NightlyEpochScheduler(redis_mock, **kwargs)
    return sched


def _arun(coro):
    """Run a coroutine synchronously using asyncio.run()."""
    return asyncio.run(coro)


# ---------------------------------------------------------------------------
# BB1 — _acquire_lock() on fresh Redis returns True
# ---------------------------------------------------------------------------


class TestBB1_AcquireLockFreshRedis:
    """BB1: _acquire_lock() must return True when Redis SET NX succeeds."""

    def test_acquire_lock_returns_true_on_fresh_redis(self):
        """BB1: Redis.set() returns True → _acquire_lock returns True."""
        redis_mock = _make_fresh_redis()
        sched = _make_scheduler(redis_mock)

        result = _arun(sched._acquire_lock())

        assert result is True, (
            f"Expected _acquire_lock() to return True on fresh Redis, got {result!r}"
        )

    def test_acquire_lock_returns_true_when_set_returns_1(self):
        """BB1: Some Redis clients return 1 (truthy int) for SET NX — must still be True."""
        redis_mock = MagicMock()
        redis_mock.set.return_value = 1
        sched = _make_scheduler(redis_mock)

        result = _arun(sched._acquire_lock())

        assert result is True, (
            f"Expected _acquire_lock() to return True when set() returns 1, got {result!r}"
        )

    def test_acquire_lock_calls_set_on_redis(self):
        """BB1: _acquire_lock() must call redis.set() exactly once."""
        redis_mock = _make_fresh_redis()
        sched = _make_scheduler(redis_mock)

        _arun(sched._acquire_lock())

        redis_mock.set.assert_called_once()


# ---------------------------------------------------------------------------
# BB2 — Second _acquire_lock() call returns False (key already exists)
# ---------------------------------------------------------------------------


class TestBB2_AcquireLockAlreadyHeld:
    """BB2: Second _acquire_lock() call must return False when lock is held."""

    def test_second_acquire_returns_false_when_redis_returns_none(self):
        """BB2: Redis.set() returns None (NX blocked) → _acquire_lock returns False."""
        redis_mock = _make_locked_redis()
        sched = _make_scheduler(redis_mock)

        result = _arun(sched._acquire_lock())

        assert result is False, (
            f"Expected _acquire_lock() to return False when SET NX blocked (returns None), "
            f"got {result!r}"
        )

    def test_second_acquire_returns_false_when_redis_returns_false(self):
        """BB2: Redis.set() returns False (some clients use False instead of None) → False."""
        redis_mock = MagicMock()
        redis_mock.set.return_value = False
        sched = _make_scheduler(redis_mock)

        result = _arun(sched._acquire_lock())

        assert result is False, (
            f"Expected False when redis.set returns False, got {result!r}"
        )

    def test_two_successive_calls_one_fresh_one_locked(self):
        """BB2: Simulate two instances — first acquires, second is blocked."""
        # First instance: lock available
        redis_free = _make_fresh_redis()
        sched1 = _make_scheduler(redis_free)
        result1 = _arun(sched1._acquire_lock())
        assert result1 is True, "First acquire must return True"

        # Second instance: lock already held (SET NX returns None)
        redis_held = _make_locked_redis()
        sched2 = _make_scheduler(redis_held)
        result2 = _arun(sched2._acquire_lock())
        assert result2 is False, "Second acquire must return False"


# ---------------------------------------------------------------------------
# BB3 — Lock key TTL is set to 7200
# ---------------------------------------------------------------------------


class TestBB3_LockTTL:
    """BB3: The Redis lock must be set with TTL = 7200 seconds."""

    def test_lock_ttl_constant_is_7200(self):
        """BB3: EPOCH_LOCK_TTL module constant must equal 7200."""
        assert EPOCH_LOCK_TTL == 7200, (
            f"Expected EPOCH_LOCK_TTL == 7200, got {EPOCH_LOCK_TTL}"
        )

    def test_acquire_lock_passes_ttl_7200_to_redis(self):
        """BB3: redis.set() must be called with ex=7200."""
        redis_mock = _make_fresh_redis()
        sched = _make_scheduler(redis_mock)

        _arun(sched._acquire_lock())

        # Verify ex=7200 was passed
        _, kwargs = redis_mock.set.call_args
        assert kwargs.get("ex") == 7200, (
            f"Expected redis.set(..., ex=7200), got ex={kwargs.get('ex')!r}"
        )

    def test_lock_key_is_correct(self):
        """BB3: EPOCH_LOCK_KEY constant must be 'epoch:lock:nightly'."""
        assert EPOCH_LOCK_KEY == "epoch:lock:nightly", (
            f"Expected EPOCH_LOCK_KEY == 'epoch:lock:nightly', got {EPOCH_LOCK_KEY!r}"
        )


# ---------------------------------------------------------------------------
# BB4 — _run_with_lock() releases lock even when callback raises
# ---------------------------------------------------------------------------


class TestBB4_LockReleasedOnCallbackException:
    """BB4: _run_with_lock() must always release the lock, even if callback raises."""

    def test_lock_released_after_callback_raises(self):
        """BB4: _release_lock() (redis.delete) must be called even when callback raises."""
        redis_mock = _make_fresh_redis()
        sched = _make_scheduler(redis_mock)

        # Override _epoch_callback to raise an exception
        async def _failing_callback():
            raise RuntimeError("Simulated epoch failure")

        sched._epoch_callback = _failing_callback  # type: ignore[method-assign]

        # _run_with_lock must NOT re-raise — it swallows exceptions and still releases
        _arun(sched._run_with_lock())

        # redis.delete must have been called (lock released)
        redis_mock.delete.assert_called_once_with(EPOCH_LOCK_KEY)

    def test_lock_released_after_successful_callback(self):
        """BB4: _release_lock() must also be called when callback succeeds."""
        redis_mock = _make_fresh_redis()
        sched = _make_scheduler(redis_mock)

        callback_called = []

        async def _ok_callback():
            callback_called.append(True)

        sched._epoch_callback = _ok_callback  # type: ignore[method-assign]

        _arun(sched._run_with_lock())

        assert callback_called == [True], "Callback was not called"
        redis_mock.delete.assert_called_once_with(EPOCH_LOCK_KEY)

    def test_run_with_lock_skips_callback_when_lock_held(self):
        """BB4: If lock is held, _epoch_callback must NOT be called."""
        redis_mock = _make_locked_redis()
        sched = _make_scheduler(redis_mock)

        callback_called = []

        async def _should_not_run():
            callback_called.append(True)

        sched._epoch_callback = _should_not_run  # type: ignore[method-assign]

        _arun(sched._run_with_lock())

        assert callback_called == [], (
            "_epoch_callback must not be called when lock is already held"
        )
        # Lock was never acquired, so delete should NOT have been called
        redis_mock.delete.assert_not_called()


# ---------------------------------------------------------------------------
# WB1 — Redis.set is called with nx=True and ex=7200
# ---------------------------------------------------------------------------


class TestWB1_RedisSetNXEX:
    """WB1: The Redis SET NX EX pattern must be used correctly."""

    def test_acquire_lock_uses_nx_true(self):
        """WB1: redis.set() must be called with nx=True."""
        redis_mock = _make_fresh_redis()
        sched = _make_scheduler(redis_mock)

        _arun(sched._acquire_lock())

        _, kwargs = redis_mock.set.call_args
        assert kwargs.get("nx") is True, (
            f"Expected redis.set(..., nx=True), got nx={kwargs.get('nx')!r}"
        )

    def test_acquire_lock_uses_ex_7200(self):
        """WB1: redis.set() must be called with ex=7200."""
        redis_mock = _make_fresh_redis()
        sched = _make_scheduler(redis_mock)

        _arun(sched._acquire_lock())

        _, kwargs = redis_mock.set.call_args
        assert kwargs.get("ex") == 7200, (
            f"Expected redis.set(..., ex=7200), got ex={kwargs.get('ex')!r}"
        )

    def test_acquire_lock_sets_correct_key(self):
        """WB1: redis.set() must be called with EPOCH_LOCK_KEY as first positional arg."""
        redis_mock = _make_fresh_redis()
        sched = _make_scheduler(redis_mock)

        _arun(sched._acquire_lock())

        args, _ = redis_mock.set.call_args
        assert args[0] == EPOCH_LOCK_KEY, (
            f"Expected first arg to redis.set() to be {EPOCH_LOCK_KEY!r}, got {args[0]!r}"
        )

    def test_release_lock_deletes_correct_key(self):
        """WB1: _release_lock() must call redis.delete(EPOCH_LOCK_KEY)."""
        redis_mock = _make_fresh_redis()
        sched = _make_scheduler(redis_mock)

        _arun(sched._release_lock())

        redis_mock.delete.assert_called_once_with(EPOCH_LOCK_KEY)


# ---------------------------------------------------------------------------
# WB2 — AEST timezone (Australia/Sydney) is used
# ---------------------------------------------------------------------------


class TestWB2_AESTTimezone:
    """WB2: The scheduler must be constructed with the Australia/Sydney timezone."""

    def test_aest_constant_is_australia_sydney(self):
        """WB2: AEST module constant must be the Australia/Sydney pytz timezone."""
        import pytz
        from core.epoch.nightly_epoch_scheduler import AEST
        assert AEST is not None, "AEST timezone constant must not be None"
        expected = pytz.timezone("Australia/Sydney")
        assert str(AEST) == str(expected), (
            f"Expected AEST = 'Australia/Sydney', got {AEST!r}"
        )

    def test_scheduler_constructed_with_aest_timezone(self):
        """WB2: AsyncIOScheduler must be instantiated with timezone=AEST."""
        redis_mock = _make_fresh_redis()
        from core.epoch.nightly_epoch_scheduler import AEST

        mock_instance = MagicMock()
        with patch(_PATCH_TARGET, return_value=mock_instance) as mock_cls:
            NightlyEpochScheduler(redis_mock)

        mock_cls.assert_called_once_with(timezone=AEST)


# ---------------------------------------------------------------------------
# WB3 — scheduler.add_job() uses 'cron' with day_of_week='sun', hour=2
# ---------------------------------------------------------------------------


class TestWB3_CronTriggerParams:
    """WB3: start() must register a cron job for Sunday at 2 AM."""

    def test_start_registers_cron_job(self):
        """WB3: start() must call scheduler.add_job() with 'cron' trigger."""
        redis_mock = _make_fresh_redis()
        sched = _make_scheduler(redis_mock)

        sched.start()

        sched.scheduler.add_job.assert_called_once()
        args, kwargs = sched.scheduler.add_job.call_args
        # Second positional arg (index 1) is the trigger type string
        assert args[1] == "cron", (
            f"Expected trigger='cron', got {args[1]!r}"
        )

    def test_start_cron_day_of_week_is_sun(self):
        """WB3: Cron must fire on Sunday (day_of_week='sun')."""
        redis_mock = _make_fresh_redis()
        sched = _make_scheduler(redis_mock)

        sched.start()

        _, kwargs = sched.scheduler.add_job.call_args
        assert kwargs.get("day_of_week") == "sun", (
            f"Expected day_of_week='sun', got {kwargs.get('day_of_week')!r}"
        )

    def test_start_cron_hour_is_2(self):
        """WB3: Cron must fire at hour=2 (2 AM AEST)."""
        redis_mock = _make_fresh_redis()
        sched = _make_scheduler(redis_mock)

        sched.start()

        _, kwargs = sched.scheduler.add_job.call_args
        assert kwargs.get("hour") == 2, (
            f"Expected hour=2, got {kwargs.get('hour')!r}"
        )

    def test_start_cron_job_id_is_nightly_epoch(self):
        """WB3: Cron job must have id='nightly_epoch' for deduplication."""
        redis_mock = _make_fresh_redis()
        sched = _make_scheduler(redis_mock)

        sched.start()

        _, kwargs = sched.scheduler.add_job.call_args
        assert kwargs.get("id") == "nightly_epoch", (
            f"Expected id='nightly_epoch', got {kwargs.get('id')!r}"
        )

    def test_start_calls_scheduler_start(self):
        """WB3: start() must also call scheduler.start() to activate the scheduler."""
        redis_mock = _make_fresh_redis()
        sched = _make_scheduler(redis_mock)

        sched.start()

        sched.scheduler.start.assert_called_once()


# ---------------------------------------------------------------------------
# WB4 — stop() calls scheduler.shutdown(wait=False)
# ---------------------------------------------------------------------------


class TestWB4_StopCallsShutdown:
    """WB4: stop() must delegate to scheduler.shutdown(wait=False)."""

    def test_stop_calls_shutdown(self):
        """WB4: stop() must call scheduler.shutdown()."""
        redis_mock = _make_fresh_redis()
        sched = _make_scheduler(redis_mock)

        sched.stop()

        sched.scheduler.shutdown.assert_called_once()

    def test_stop_calls_shutdown_with_wait_false(self):
        """WB4: stop() must pass wait=False to shutdown (non-blocking)."""
        redis_mock = _make_fresh_redis()
        sched = _make_scheduler(redis_mock)

        sched.stop()

        _, kwargs = sched.scheduler.shutdown.call_args
        assert kwargs.get("wait") is False, (
            f"Expected shutdown(wait=False), got wait={kwargs.get('wait')!r}"
        )


# ---------------------------------------------------------------------------
# Integration — injectable callback is wired correctly
# ---------------------------------------------------------------------------


class TestIntegration_InjectableCallback:
    """Verify the injectable callback mechanism works end-to-end."""

    def test_custom_callback_is_called_when_injected(self):
        """Custom epoch_callback is invoked when lock is acquired."""
        redis_mock = _make_fresh_redis()
        called = []

        async def custom_cb():
            called.append("custom")

        sched = _make_scheduler(redis_mock, epoch_callback=custom_cb)
        _arun(sched._run_with_lock())

        assert called == ["custom"], (
            f"Expected custom_callback to be called once, got called={called!r}"
        )

    def test_no_custom_callback_does_not_raise(self):
        """_run_with_lock() with no custom callback must not raise."""
        redis_mock = _make_fresh_redis()
        sched = _make_scheduler(redis_mock)  # no callback injected

        # Should complete without raising
        _arun(sched._run_with_lock())

        # Lock must still be released
        redis_mock.delete.assert_called_once_with(EPOCH_LOCK_KEY)


# ---------------------------------------------------------------------------
# Run summary
# ---------------------------------------------------------------------------

if __name__ == "__main__":
    result = pytest.main([__file__, "-v", "--tb=short"])
    sys.exit(result)
