"""
tests/test_circadian_concurrency.py

Issue #477 — Concurrent dream/sleep guard.

Verifies that when two dream() (or sleep()) calls fire simultaneously,
only one executes and the second is rejected with a "concurrent dream rejected"
journal entry.
"""
from __future__ import annotations

import asyncio
from unittest.mock import AsyncMock, MagicMock, patch

import pytest


# ---------------------------------------------------------------------------
# Minimal stubs so CircadianEngine can be instantiated without a real DB
# ---------------------------------------------------------------------------

def _make_storage():
    storage = MagicMock()
    storage.get_session = AsyncMock(return_value=None)
    storage.update_session = AsyncMock()
    storage.list_sessions = AsyncMock(return_value=[])
    storage._db = MagicMock()
    storage._db.execute = AsyncMock(return_value=None)
    return storage


def _make_config():
    cfg = MagicMock()
    cfg.session_fatigue_nap_threshold = 0.7
    cfg.session_fatigue_sleep_threshold = 0.9
    cfg.dream_cycle_idle_timeout_sec = 3600
    cfg.dream_cycle_hour_utc = 3
    cfg.dream_cycle_enabled = False  # disable background scheduler
    cfg.owner_id = "test-owner"
    cfg.fatigue_interval_sec = 300
    return cfg


def _make_llm():
    llm = MagicMock()
    return llm


def _make_cornerstone_guardian():
    cg = MagicMock()
    cg.load_cornerstones = AsyncMock(return_value=[])
    return cg


def _make_engine():
    from cortex.circadian.engine import CircadianEngine

    return CircadianEngine(
        storage=_make_storage(),
        config=_make_config(),
        llm=_make_llm(),
        cornerstone_guardian=_make_cornerstone_guardian(),
    )


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

async def _slow_return_0(*args, **kwargs):
    """Async helper that yields to the event loop before returning 0.

    This ensures Task 2 gets CPU time while Task 1 holds the dream lock,
    making the concurrency guard observable in tests.
    """
    await asyncio.sleep(0)
    return 0


@pytest.mark.asyncio
async def test_concurrent_dream_only_one_executes():
    """Fire two dream() calls concurrently; assert only one runs.

    _recover_orphans is replaced with a slow mock that yields so the
    event loop can schedule Task 2 while Task 1 holds the lock.
    """
    engine = _make_engine()

    # Must yield to the event loop so Task 2 sees the lock as held.
    engine._recover_orphans = _slow_return_0
    engine._recover_interrupted_sessions = _slow_return_0

    results = await asyncio.gather(
        engine.dream(owner_id="test-owner"),
        engine.dream(owner_id="test-owner"),
    )

    journals = [r.journal for r in results]
    rejected = [j for j in journals if j and "concurrent dream rejected" in j]
    executed = [j for j in journals if j and "concurrent dream rejected" not in j]

    assert len(rejected) == 1, (
        f"Expected exactly one rejected dream, got {len(rejected)}. Journals: {journals}"
    )
    assert len(executed) == 1, (
        f"Expected exactly one executed dream, got {len(executed)}. Journals: {journals}"
    )


@pytest.mark.asyncio
async def test_concurrent_dream_rejected_report_has_owner():
    """Rejected dream report should carry the correct owner_id."""
    engine = _make_engine()

    engine._recover_orphans = _slow_return_0
    engine._recover_interrupted_sessions = _slow_return_0

    results = await asyncio.gather(
        engine.dream(owner_id="owner-42"),
        engine.dream(owner_id="owner-42"),
    )

    rejected = [r for r in results if r.journal and "concurrent dream rejected" in r.journal]
    assert rejected, "No rejected dream found"
    assert rejected[0].owner_id == "owner-42"


@pytest.mark.asyncio
async def test_sequential_dreams_both_execute():
    """Sequential dream() calls (not concurrent) should both complete normally."""
    engine = _make_engine()

    engine._recover_orphans = _slow_return_0
    engine._recover_interrupted_sessions = _slow_return_0

    r1 = await engine.dream(owner_id="test-owner")
    r2 = await engine.dream(owner_id="test-owner")

    assert r1.journal is not None
    assert r2.journal is not None
    assert "concurrent dream rejected" not in (r1.journal or "")
    assert "concurrent dream rejected" not in (r2.journal or "")


@pytest.mark.asyncio
async def test_concurrent_sleep_only_one_executes():
    """Fire two sleep() calls concurrently for the same session; only one should run.

    _get_session is replaced with a slow mock that yields so Task 2 can see
    the lock as held while Task 1 is inside the guarded body.
    """
    engine = _make_engine()

    # Slow mock that yields before returning None (no session in DB)
    async def slow_get_session(session_id):
        await asyncio.sleep(0)
        return None

    engine._get_session = slow_get_session

    results = await asyncio.gather(
        engine.sleep("session-abc", triggered_by="manual"),
        engine.sleep("session-abc", triggered_by="manual"),
    )

    # Both return SleepReport — one executes (hits _get_session), one is rejected
    assert len(results) == 2
    assert all(r.session_id == "session-abc" for r in results)

    # Exactly one call should have reached _get_session (the one that held the lock)
    # We can't directly observe which was rejected vs ran, but we can assert that
    # asyncio.gather returned without exception and the guard didn't deadlock.
    # The concurrency guard means _get_session was called at most once.
    # (Rejected call returns before acquiring lock — no _get_session call.)
