"""Tests for heartbeat.py — HeartbeatLoop tracking and HTTP calls."""

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

import pytest

from heartbeat import HeartbeatLoop


def test_init():
    """HeartbeatLoop stores platform_url, workspace_id, and zeroes counters."""
    hb = HeartbeatLoop("http://localhost:8080", "ws-123")
    assert hb.platform_url == "http://localhost:8080"
    assert hb.workspace_id == "ws-123"
    assert hb.error_count == 0
    assert hb.request_count == 0
    assert hb.active_tasks == 0
    assert hb.sample_error == ""
    assert hb._task is None


def test_record_success():
    """record_success increments request_count only."""
    hb = HeartbeatLoop("http://localhost:8080", "ws-1")
    hb.record_success()
    hb.record_success()
    assert hb.request_count == 2
    assert hb.error_count == 0


def test_record_error():
    """record_error increments both counts and stores sample error."""
    hb = HeartbeatLoop("http://localhost:8080", "ws-1")
    hb.record_error("timeout")
    assert hb.request_count == 1
    assert hb.error_count == 1
    assert hb.sample_error == "timeout"


def test_error_rate_zero_requests():
    """error_rate is 0.0 when no requests have been recorded."""
    hb = HeartbeatLoop("http://localhost:8080", "ws-1")
    assert hb.error_rate == 0.0


def test_error_rate_calculation():
    """error_rate correctly computes error_count / request_count."""
    hb = HeartbeatLoop("http://localhost:8080", "ws-1")
    hb.record_success()
    hb.record_success()
    hb.record_error("fail")
    hb.record_success()
    # 1 error / 4 requests = 0.25
    assert hb.error_rate == 0.25


def test_error_rate_all_errors():
    """error_rate is 1.0 when all requests are errors."""
    hb = HeartbeatLoop("http://localhost:8080", "ws-1")
    hb.record_error("e1")
    hb.record_error("e2")
    assert hb.error_rate == 1.0


def test_sample_error_updated():
    """sample_error always reflects the most recent error."""
    hb = HeartbeatLoop("http://localhost:8080", "ws-1")
    hb.record_error("first")
    hb.record_error("second")
    assert hb.sample_error == "second"


@pytest.mark.asyncio
async def test_heartbeat_loop_posts():
    """The _loop sends a POST to /registry/heartbeat with the correct payload."""
    hb = HeartbeatLoop("http://platform:8080", "ws-abc")
    hb.record_error("some error")
    hb.active_tasks = 2

    mock_response = MagicMock()
    mock_client = AsyncMock()
    mock_client.post = AsyncMock(return_value=mock_response)
    mock_client.__aenter__ = AsyncMock(return_value=mock_client)
    mock_client.__aexit__ = AsyncMock(return_value=False)

    with patch("heartbeat.httpx.AsyncClient", return_value=mock_client):
        # Run the loop but cancel after one iteration
        async def run_one_iteration():
            task = asyncio.create_task(hb._loop())
            await asyncio.sleep(0.05)
            task.cancel()
            try:
                await task
            except asyncio.CancelledError:
                pass

        await run_one_iteration()

    mock_client.post.assert_called_once()
    call_args = mock_client.post.call_args
    assert call_args[0][0] == "http://platform:8080/registry/heartbeat"
    payload = call_args[1]["json"]
    assert payload["workspace_id"] == "ws-abc"
    assert payload["error_rate"] == 1.0  # 1 error / 1 request
    assert payload["sample_error"] == "some error"
    assert payload["active_tasks"] == 2
    assert "uptime_seconds" in payload


@pytest.mark.asyncio
async def test_stop_cancels_task():
    """stop() cancels the running heartbeat task."""
    hb = HeartbeatLoop("http://localhost:8080", "ws-1")

    mock_client = AsyncMock()
    mock_client.post = AsyncMock()
    mock_client.__aenter__ = AsyncMock(return_value=mock_client)
    mock_client.__aexit__ = AsyncMock(return_value=False)

    with patch("heartbeat.httpx.AsyncClient", return_value=mock_client):
        hb.start()
        assert hb._task is not None
        await asyncio.sleep(0.01)
        await hb.stop()
        assert hb._task.cancelled() or hb._task.done()


@pytest.mark.asyncio
async def test_heartbeat_loop_continues_after_exception(capsys):
    """When the POST raises an exception, the loop prints a message and continues."""
    hb = HeartbeatLoop("http://platform:8080", "ws-err")

    call_count = 0

    async def fake_post(*args, **kwargs):
        nonlocal call_count
        call_count += 1
        if call_count == 1:
            raise Exception("connection refused")
        # Second call succeeds — return a mock response
        return MagicMock()

    mock_client = AsyncMock()
    mock_client.post = fake_post
    mock_client.__aenter__ = AsyncMock(return_value=mock_client)
    mock_client.__aexit__ = AsyncMock(return_value=False)

    with patch("heartbeat.httpx.AsyncClient", return_value=mock_client):
        with patch("asyncio.sleep", new_callable=AsyncMock) as mock_sleep:
            # Allow two iterations then cancel
            iteration = 0

            async def controlled_sleep(delay):
                nonlocal iteration
                iteration += 1
                if iteration >= 2:
                    raise asyncio.CancelledError()

            mock_sleep.side_effect = controlled_sleep

            task = asyncio.create_task(hb._loop())
            try:
                await task
            except asyncio.CancelledError:
                pass

    # The loop ran at least once and logged the failure (via logger, not print)
    # The loop continued (call_count reached at least 1)
    assert call_count >= 1


# ---------------------------------------------------------------------------
# Delegation checking tests
# ---------------------------------------------------------------------------

@pytest.mark.asyncio
async def test_check_delegations_writes_results_file(tmp_path):
    """When completed delegations are found, results are written to file."""
    import json
    results_file = tmp_path / "delegation_results.jsonl"

    hb = HeartbeatLoop("http://platform:8080", "ws-abc")

    delegations = [
        {"delegation_id": "d-1", "status": "completed", "target_id": "ws-t",
         "source_id": "ws-abc",  # must match workspace_id for Fix B source validation
         "summary": "Done", "response_preview": "Result here", "error": ""},
    ]

    mock_client = AsyncMock()
    # GET /delegations returns completed delegation
    get_resp = MagicMock()
    get_resp.status_code = 200
    get_resp.json = MagicMock(return_value=delegations)
    mock_client.get = AsyncMock(return_value=get_resp)
    # POST for self-message and notify — just succeed
    post_resp = MagicMock()
    post_resp.status_code = 200
    mock_client.post = AsyncMock(return_value=post_resp)

    with patch("heartbeat.DELEGATION_RESULTS_FILE", str(results_file)):
        await hb._check_delegations(mock_client)

    # Verify file was written
    assert results_file.exists()
    lines = results_file.read_text().strip().split("\n")
    assert len(lines) == 1
    data = json.loads(lines[0])
    assert data["delegation_id"] == "d-1"
    assert data["status"] == "completed"
    assert data["response_preview"] == "Result here"


@pytest.mark.asyncio
async def test_check_delegations_deduplicates():
    """Same delegation_id is not processed twice."""
    hb = HeartbeatLoop("http://platform:8080", "ws-abc")
    hb._seen_delegation_ids.add("d-1")  # Already seen

    delegations = [
        {"delegation_id": "d-1", "status": "completed", "target_id": "ws-t",
         "summary": "Done", "response_preview": "old"},
    ]

    mock_client = AsyncMock()
    get_resp = MagicMock()
    get_resp.status_code = 200
    get_resp.json = MagicMock(return_value=delegations)
    mock_client.get = AsyncMock(return_value=get_resp)
    mock_client.post = AsyncMock()

    with patch("heartbeat.DELEGATION_RESULTS_FILE", "/tmp/test_dedup.jsonl"):
        await hb._check_delegations(mock_client)

    # No self-message should be sent (delegation already seen)
    # Only the GET call, no POST
    mock_client.post.assert_not_called()


@pytest.mark.asyncio
async def test_check_delegations_sends_self_message(tmp_path):
    """Self-message A2A is sent when new completed delegations found."""
    results_file = tmp_path / "results.jsonl"
    hb = HeartbeatLoop("http://platform:8080", "ws-abc")

    delegations = [
        {"delegation_id": "d-new", "status": "completed", "target_id": "ws-t",
         "source_id": "ws-abc",  # must match workspace_id for Fix B source validation
         "summary": "Task done", "response_preview": "All good", "error": ""},
    ]

    mock_client = AsyncMock()
    get_resp = MagicMock()
    get_resp.status_code = 200
    get_resp.json = MagicMock(return_value=delegations)
    mock_client.get = AsyncMock(return_value=get_resp)
    post_resp = MagicMock()
    post_resp.status_code = 200
    mock_client.post = AsyncMock(return_value=post_resp)

    with patch("heartbeat.DELEGATION_RESULTS_FILE", str(results_file)):
        await hb._check_delegations(mock_client)

    # Should have sent self-message (A2A to own workspace) + notify
    post_calls = mock_client.post.call_args_list
    assert len(post_calls) >= 1
    # First POST should be the self-message A2A
    a2a_call = post_calls[0]
    assert "/a2a" in str(a2a_call)


@pytest.mark.asyncio
async def test_check_delegations_cooldown():
    """Self-message respects cooldown — no second message within 5 min."""
    import time
    hb = HeartbeatLoop("http://platform:8080", "ws-abc")
    hb._last_self_message_time = time.time()  # Just sent one

    delegations = [
        {"delegation_id": "d-cool", "status": "completed", "target_id": "ws-t",
         "summary": "Done", "response_preview": "ok", "error": ""},
    ]

    mock_client = AsyncMock()
    get_resp = MagicMock()
    get_resp.status_code = 200
    get_resp.json = MagicMock(return_value=delegations)
    mock_client.get = AsyncMock(return_value=get_resp)
    mock_client.post = AsyncMock()

    with patch("heartbeat.DELEGATION_RESULTS_FILE", "/tmp/test_cooldown.jsonl"):
        await hb._check_delegations(mock_client)

    # File should still be written (results stored)
    # But self-message should NOT be sent (cooldown active)
    # Only notify POST, no A2A self-message
    for call in mock_client.post.call_args_list:
        assert "/a2a" not in str(call[0][0]), "Self-message should be blocked by cooldown"


@pytest.mark.asyncio
async def test_seen_ids_eviction():
    """Seen delegation IDs are evicted when over MAX limit."""
    from heartbeat import MAX_SEEN_DELEGATION_IDS
    hb = HeartbeatLoop("http://platform:8080", "ws-abc")

    # Fill beyond max
    for i in range(MAX_SEEN_DELEGATION_IDS + 50):
        hb._seen_delegation_ids.add(f"d-{i}")

    assert len(hb._seen_delegation_ids) > MAX_SEEN_DELEGATION_IDS

    # Trigger eviction via _check_delegations with empty results
    mock_client = AsyncMock()
    get_resp = MagicMock()
    get_resp.status_code = 200
    get_resp.json = MagicMock(return_value=[])
    mock_client.get = AsyncMock(return_value=get_resp)

    await hb._check_delegations(mock_client)

    # Should have been trimmed
    assert len(hb._seen_delegation_ids) <= MAX_SEEN_DELEGATION_IDS


def test_on_done_restarts_loop():
    """_on_done restarts the loop when task has an exception."""
    hb = HeartbeatLoop("http://platform:8080", "ws-abc")

    # Create a mock failed task
    mock_task = MagicMock()
    mock_task.cancelled.return_value = False
    mock_task.exception.return_value = RuntimeError("boom")

    with patch("asyncio.create_task") as mock_create:
        mock_new_task = MagicMock()
        mock_create.return_value = mock_new_task
        hb._on_done(mock_task)

    # Should have created a new task
    mock_create.assert_called_once()
    # New task should have done callback
    mock_new_task.add_done_callback.assert_called_once()
