"""Wave C2 (2026-05-12): 30s TTL cache on ensure_workspace refresh fetch.

Smoke run 3 fired 'ensure_workspace: refresh fetch returned non-zero'
9 times per run because each evidence(task_id) call triggered
ensure_workspace, which fetches even when the workspace was just
fetched seconds ago. The TTL prevents redundant work.
"""

from __future__ import annotations

from typing import TYPE_CHECKING
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import uuid4

import pytest
from roboco.services.workspace import WorkspaceService

if TYPE_CHECKING:
    from collections.abc import Iterator
    from pathlib import Path


def _service() -> WorkspaceService:
    """Build a WorkspaceService over a MagicMock session."""
    session = MagicMock()
    session.execute = AsyncMock()
    return WorkspaceService(session)


def _bind(svc: WorkspaceService, name: str, value: object) -> None:
    """Stub `name` on `svc` without tripping mypy's method-assign check."""
    object.__setattr__(svc, name, value)


def _fake_agent(slug: str = "be-pm") -> MagicMock:
    """Build a MagicMock that satisfies the AgentTable surface used here."""
    agent = MagicMock()
    agent.id = uuid4()
    agent.slug = slug
    agent.team = None
    return agent


@pytest.fixture
def healthy_workspace(tmp_path: Path) -> Iterator[Path]:
    """Materialize a directory that passes `_is_workspace_healthy`."""
    workspace = tmp_path / "roboco" / "backend" / "be-pm"
    git_dir = workspace / ".git"
    (git_dir / "objects").mkdir(parents=True)
    (git_dir / "HEAD").write_text("ref: refs/heads/main\n")
    yield workspace


# Named constants to satisfy ruff PLR2004 (magic value in comparison).
_FETCH_TTL_SECONDS = 30.0
_EXPECTED_ONE_FETCH = 1
_EXPECTED_TWO_FETCHES = 2


@pytest.mark.asyncio
async def test_second_fetch_within_ttl_is_skipped(
    healthy_workspace: Path,
) -> None:
    """ensure_workspace within 30s of a successful fetch skips the second fetch."""
    svc = _service()
    agent = _fake_agent()
    _bind(svc, "_lookup_agent_or_raise", AsyncMock(return_value=agent))
    _bind(svc, "get_workspace_path", MagicMock(return_value=healthy_workspace))

    fetch_call_count = 0

    async def fake_fetch(_workspace: Path, _project_slug: str) -> None:
        nonlocal fetch_call_count
        fetch_call_count += 1

    with (
        patch.object(
            WorkspaceService,
            "_fetch_origin_best_effort",
            side_effect=fake_fetch,
        ),
        patch("roboco.services.workspace._ensure_agent_owned"),
    ):
        await svc.ensure_workspace(project_slug="roboco", agent_id=agent.id)
        await svc.ensure_workspace(project_slug="roboco", agent_id=agent.id)

    assert fetch_call_count == _EXPECTED_ONE_FETCH, (
        f"second ensure_workspace within TTL should skip fetch; "
        f"got {fetch_call_count} fetches"
    )


@pytest.mark.asyncio
async def test_fetch_after_ttl_runs_again(
    healthy_workspace: Path,
) -> None:
    """After 30s, the cache expires and the next ensure_workspace fetches again."""
    svc = _service()
    agent = _fake_agent()
    _bind(svc, "_lookup_agent_or_raise", AsyncMock(return_value=agent))
    _bind(svc, "get_workspace_path", MagicMock(return_value=healthy_workspace))

    fetch_call_count = 0

    async def fake_fetch(_workspace: Path, _project_slug: str) -> None:
        nonlocal fetch_call_count
        fetch_call_count += 1

    # Simulate time advancing past the TTL between the two calls.
    # _monotonic() is called twice per ensure_workspace invocation when a
    # fetch runs: once to read "now" and once to stamp the cache after the
    # fetch. Sequence:
    #   call 1 (now, 1st ensure_workspace): 0.0   -> fetch runs
    #   call 2 (stamp, 1st ensure_workspace): 0.0  -> cache set to 0.0
    #   call 3 (now, 2nd ensure_workspace): 31.0   -> TTL expired -> fetch runs
    #   call 4 (stamp, 2nd ensure_workspace): 31.0 -> cache set to 31.0
    monotonic_calls = [0.0, 0.0, _FETCH_TTL_SECONDS + 1, _FETCH_TTL_SECONDS + 1]

    with (
        patch.object(
            WorkspaceService,
            "_fetch_origin_best_effort",
            side_effect=fake_fetch,
        ),
        patch("roboco.services.workspace._ensure_agent_owned"),
        patch(
            "roboco.services.workspace._monotonic",
            side_effect=monotonic_calls,
        ),
    ):
        await svc.ensure_workspace(project_slug="roboco", agent_id=agent.id)
        await svc.ensure_workspace(project_slug="roboco", agent_id=agent.id)

    assert fetch_call_count == _EXPECTED_TWO_FETCHES, (
        f"after TTL expiry the second ensure_workspace should fetch again; "
        f"got {fetch_call_count} fetches"
    )


@pytest.mark.asyncio
async def test_force_true_bypasses_cache(
    healthy_workspace: Path,
) -> None:
    """ensure_workspace(force=True) fetches even if the cache is fresh."""
    svc = _service()
    agent = _fake_agent()
    _bind(svc, "_lookup_agent_or_raise", AsyncMock(return_value=agent))
    _bind(svc, "get_workspace_path", MagicMock(return_value=healthy_workspace))

    fetch_call_count = 0

    async def fake_fetch(_workspace: Path, _project_slug: str) -> None:
        nonlocal fetch_call_count
        fetch_call_count += 1

    with (
        patch.object(
            WorkspaceService,
            "_fetch_origin_best_effort",
            side_effect=fake_fetch,
        ),
        patch("roboco.services.workspace._ensure_agent_owned"),
    ):
        # First call populates the cache.
        await svc.ensure_workspace(project_slug="roboco", agent_id=agent.id)
        # Second call with force=True must bypass the cache and fetch again.
        await svc.ensure_workspace(project_slug="roboco", agent_id=agent.id, force=True)

    assert fetch_call_count == _EXPECTED_TWO_FETCHES, (
        f"force=True should bypass cache and fetch again; "
        f"got {fetch_call_count} fetches"
    )


@pytest.mark.asyncio
async def test_different_workspaces_have_independent_caches(
    tmp_path: Path,
) -> None:
    """The TTL cache is per workspace path — fetching workspace A does not
    suppress the fetch for workspace B even within 30s."""

    def _make_healthy(slug: str) -> Path:
        workspace = tmp_path / "roboco" / "backend" / slug
        git_dir = workspace / ".git"
        (git_dir / "objects").mkdir(parents=True)
        (git_dir / "HEAD").write_text("ref: refs/heads/main\n")
        return workspace

    workspace_a = _make_healthy("agent-a")
    workspace_b = _make_healthy("agent-b")

    svc = _service()

    fetched_paths: list[str] = []

    async def fake_fetch(workspace: Path, _project_slug: str) -> None:
        fetched_paths.append(str(workspace))

    agent_a = _fake_agent("agent-a")
    agent_b = _fake_agent("agent-b")

    with (
        patch.object(
            WorkspaceService,
            "_fetch_origin_best_effort",
            side_effect=fake_fetch,
        ),
        patch("roboco.services.workspace._ensure_agent_owned"),
    ):
        _bind(svc, "_lookup_agent_or_raise", AsyncMock(return_value=agent_a))
        _bind(svc, "get_workspace_path", MagicMock(return_value=workspace_a))
        await svc.ensure_workspace(project_slug="roboco", agent_id=agent_a.id)

        _bind(svc, "_lookup_agent_or_raise", AsyncMock(return_value=agent_b))
        _bind(svc, "get_workspace_path", MagicMock(return_value=workspace_b))
        await svc.ensure_workspace(project_slug="roboco", agent_id=agent_b.id)

    assert len(fetched_paths) == _EXPECTED_TWO_FETCHES, (
        f"both workspaces should be fetched independently; "
        f"got {len(fetched_paths)} fetches: {fetched_paths}"
    )
    assert str(workspace_a) in fetched_paths
    assert str(workspace_b) in fetched_paths
