"""
Module: test_session_heartbeat
Description: Tests for dev session heartbeat module.

Implements:
    - docs/architecture/dev-sessions.md#heartbeat-protocol

Tests cover:
    - Register: success returns session_id, typed exceptions on failure
    - Register: all optional params sent correctly
    - Start: no-op without registration
    - Start: spawns daemon thread
    - Heartbeat loop: sends periodic POSTs, auto-refreshes on 401
    - Stop: sends /stop request, joins thread, closes client
    - Stop: graceful when backend is unreachable
    - Stop: closes client even without session
    - Token refresh: 401 triggers refresh, freeze event on failure
"""

from __future__ import annotations

import threading
import time
from unittest.mock import MagicMock, call, patch

import httpx

from huitzo_cli.errors import AuthenticationError, NetworkError, ServerError

# ---------------------------------------------------------------------------
# Register
# ---------------------------------------------------------------------------


class TestRegister:
    """Tests for SessionHeartbeat.register()."""

    def test_success_returns_session_id(self) -> None:
        with patch("huitzo_cli.session_heartbeat.httpx.Client") as mock_cls:
            mock_client = MagicMock()
            mock_cls.return_value = mock_client
            mock_resp = MagicMock()
            mock_resp.status_code = 200
            mock_resp.json.return_value = {"data": {"session_id": "abc-123"}}
            mock_client.post.return_value = mock_resp

            from huitzo_cli.session_heartbeat import SessionHeartbeat

            hb = SessionHeartbeat(api_url="http://localhost:8000", auth_token="tok")
            result = hb.register(pack_namespace="@test/pack", port=8080)

            assert result == "abc-123"
            assert hb._session_id == "abc-123"
            mock_client.post.assert_called_once()

    def test_network_error_raises(self) -> None:
        with patch("huitzo_cli.session_heartbeat.httpx.Client") as mock_cls:
            mock_client = MagicMock()
            mock_cls.return_value = mock_client
            mock_client.post.side_effect = httpx.ConnectError("Connection refused")

            from huitzo_cli.session_heartbeat import SessionHeartbeat

            hb = SessionHeartbeat(api_url="http://localhost:8000", auth_token="tok")
            try:
                hb.register(pack_namespace="@test/pack")
                assert False, "Should have raised NetworkError"
            except NetworkError:
                pass

    def test_401_raises_auth_error(self) -> None:
        """401 response raises AuthenticationError."""
        with patch("huitzo_cli.session_heartbeat.httpx.Client") as mock_cls:
            mock_client = MagicMock()
            mock_cls.return_value = mock_client
            mock_resp = MagicMock()
            mock_resp.status_code = 401
            mock_client.post.return_value = mock_resp

            from huitzo_cli.session_heartbeat import SessionHeartbeat

            hb = SessionHeartbeat(api_url="http://localhost:8000", auth_token="tok")
            try:
                hb.register(pack_namespace="@test/pack")
                assert False, "Should have raised AuthenticationError"
            except AuthenticationError:
                pass

    def test_5xx_raises_server_error(self) -> None:
        """5xx response raises ServerError."""
        with patch("huitzo_cli.session_heartbeat.httpx.Client") as mock_cls:
            mock_client = MagicMock()
            mock_cls.return_value = mock_client
            mock_resp = MagicMock()
            mock_resp.status_code = 500
            mock_client.post.return_value = mock_resp

            from huitzo_cli.session_heartbeat import SessionHeartbeat

            hb = SessionHeartbeat(api_url="http://localhost:8000", auth_token="tok")
            try:
                hb.register(pack_namespace="@test/pack")
                assert False, "Should have raised ServerError"
            except ServerError:
                pass

    def test_sends_all_optional_params(self) -> None:
        with patch("huitzo_cli.session_heartbeat.httpx.Client") as mock_cls:
            mock_client = MagicMock()
            mock_cls.return_value = mock_client
            mock_resp = MagicMock()
            mock_resp.status_code = 200
            mock_resp.json.return_value = {"data": {"session_id": "x"}}
            mock_client.post.return_value = mock_resp

            from huitzo_cli.session_heartbeat import SessionHeartbeat

            hb = SessionHeartbeat(api_url="http://localhost:8000", auth_token="tok")
            hb.register(
                pack_namespace="@org/pack",
                port=9090,
                host="myhost",
                command_count=5,
                metadata={"os": "linux", "cli": "1.0"},
            )

            posted_json = mock_client.post.call_args[1]["json"]
            assert posted_json["pack_namespace"] == "@org/pack"
            assert posted_json["local_port"] == 9090
            assert posted_json["local_host"] == "myhost"
            assert posted_json["command_count"] == 5
            assert posted_json["metadata"] == {"os": "linux", "cli": "1.0"}


# ---------------------------------------------------------------------------
# Start / heartbeat loop
# ---------------------------------------------------------------------------


class TestHeartbeatLoop:
    """Tests for start() and the background heartbeat thread."""

    def test_start_without_register_is_noop(self) -> None:
        with patch("huitzo_cli.session_heartbeat.httpx.Client"):
            from huitzo_cli.session_heartbeat import SessionHeartbeat

            hb = SessionHeartbeat(api_url="http://localhost:8000", auth_token="tok")
            hb.start()
            assert hb._thread is None

    def test_start_spawns_daemon_thread(self) -> None:
        with patch("huitzo_cli.session_heartbeat.httpx.Client"):
            from huitzo_cli.session_heartbeat import SessionHeartbeat

            hb = SessionHeartbeat(api_url="http://localhost:8000", auth_token="tok")
            hb._session_id = "test-session"
            hb.start()

            assert hb._thread is not None
            assert hb._thread.daemon is True
            assert hb._thread.name == "huitzo-heartbeat"

            # Clean up
            hb._stop_event.set()
            hb._thread.join(timeout=2)

    def test_heartbeat_sends_post(self) -> None:
        """Verify the loop sends a POST to /{session_id}/heartbeat."""
        with patch("huitzo_cli.session_heartbeat.httpx.Client") as mock_cls:
            mock_client = MagicMock()
            mock_cls.return_value = mock_client
            mock_resp = MagicMock()
            mock_resp.status_code = 200
            mock_client.post.return_value = mock_resp

            from huitzo_cli.session_heartbeat import SessionHeartbeat

            hb = SessionHeartbeat(api_url="http://localhost:8000", auth_token="tok")
            hb._session_id = "sess-42"

            # Monkey-patch interval to make test fast
            import huitzo_cli.session_heartbeat as mod

            original = mod.HEARTBEAT_INTERVAL
            mod.HEARTBEAT_INTERVAL = 0.05  # 50ms
            try:
                hb.start()
                time.sleep(0.2)  # enough for ~3 heartbeats
                hb._stop_event.set()
                hb._thread.join(timeout=2)
            finally:
                mod.HEARTBEAT_INTERVAL = original

            # Should have sent heartbeat POSTs
            heartbeat_calls = [
                c
                for c in mock_client.post.call_args_list
                if c == call("/api/v1/dev-sessions/sess-42/heartbeat")
            ]
            assert len(heartbeat_calls) >= 1

    def test_heartbeat_failure_does_not_stop_loop(self) -> None:
        """HTTP errors in heartbeat should not crash the loop."""
        with patch("huitzo_cli.session_heartbeat.httpx.Client") as mock_cls:
            mock_client = MagicMock()
            mock_cls.return_value = mock_client
            mock_client.post.side_effect = httpx.ConnectError("gone")

            from huitzo_cli.session_heartbeat import SessionHeartbeat

            hb = SessionHeartbeat(api_url="http://localhost:8000", auth_token="tok")
            hb._session_id = "sess-err"

            import huitzo_cli.session_heartbeat as mod

            original = mod.HEARTBEAT_INTERVAL
            mod.HEARTBEAT_INTERVAL = 0.05
            try:
                hb.start()
                time.sleep(0.2)
                # Thread should still be alive despite errors
                assert hb._thread is not None
                assert hb._thread.is_alive()
                hb._stop_event.set()
                hb._thread.join(timeout=2)
            finally:
                mod.HEARTBEAT_INTERVAL = original


# ---------------------------------------------------------------------------
# Stop / cleanup
# ---------------------------------------------------------------------------


class TestStop:
    """Tests for stop() and cleanup."""

    def test_sends_stop_request(self) -> None:
        with patch("huitzo_cli.session_heartbeat.httpx.Client") as mock_cls:
            mock_client = MagicMock()
            mock_cls.return_value = mock_client

            from huitzo_cli.session_heartbeat import SessionHeartbeat

            hb = SessionHeartbeat(api_url="http://localhost:8000", auth_token="tok")
            hb._session_id = "abc-123"
            hb.stop()

            mock_client.post.assert_called_with("/api/v1/dev-sessions/abc-123/stop")
            mock_client.close.assert_called_once()

    def test_stop_graceful_on_network_error(self) -> None:
        """Stop should not raise even if /stop POST fails."""
        with patch("huitzo_cli.session_heartbeat.httpx.Client") as mock_cls:
            mock_client = MagicMock()
            mock_cls.return_value = mock_client
            mock_client.post.side_effect = httpx.ConnectError("gone")

            from huitzo_cli.session_heartbeat import SessionHeartbeat

            hb = SessionHeartbeat(api_url="http://localhost:8000", auth_token="tok")
            hb._session_id = "abc-123"
            # Should not raise
            hb.stop()
            mock_client.close.assert_called_once()

    def test_stop_without_session_closes_client(self) -> None:
        with patch("huitzo_cli.session_heartbeat.httpx.Client") as mock_cls:
            mock_client = MagicMock()
            mock_cls.return_value = mock_client

            from huitzo_cli.session_heartbeat import SessionHeartbeat

            hb = SessionHeartbeat(api_url="http://localhost:8000", auth_token="tok")
            hb.stop()

            # Should not attempt /stop POST
            mock_client.post.assert_not_called()
            mock_client.close.assert_called_once()

    def test_stop_joins_running_thread(self) -> None:
        """Stop should join() the heartbeat thread."""
        with patch("huitzo_cli.session_heartbeat.httpx.Client") as mock_cls:
            mock_client = MagicMock()
            mock_cls.return_value = mock_client
            mock_resp = MagicMock()
            mock_resp.status_code = 200
            mock_client.post.return_value = mock_resp

            from huitzo_cli.session_heartbeat import SessionHeartbeat

            hb = SessionHeartbeat(api_url="http://localhost:8000", auth_token="tok")
            hb._session_id = "sess-join"

            import huitzo_cli.session_heartbeat as mod

            original = mod.HEARTBEAT_INTERVAL
            mod.HEARTBEAT_INTERVAL = 0.05
            try:
                hb.start()
                assert hb._thread is not None
                assert hb._thread.is_alive()
                hb.stop()
                assert hb._thread is None  # Thread reference cleared
            finally:
                mod.HEARTBEAT_INTERVAL = original


# ---------------------------------------------------------------------------
# Constructor
# ---------------------------------------------------------------------------


class TestConstructor:
    """Tests for __init__ configuration."""

    def test_api_url_trailing_slash_stripped(self) -> None:
        with patch("huitzo_cli.session_heartbeat.httpx.Client"):
            from huitzo_cli.session_heartbeat import SessionHeartbeat

            hb = SessionHeartbeat(api_url="http://localhost:8000/", auth_token="tok")
            assert hb._api_url == "http://localhost:8000"

    def test_client_configured_with_auth(self) -> None:
        with patch("huitzo_cli.session_heartbeat.httpx.Client") as mock_cls:
            from huitzo_cli.session_heartbeat import SessionHeartbeat

            SessionHeartbeat(api_url="http://localhost:8000", auth_token="my-token", timeout=15)

            mock_cls.assert_called_once()
            kwargs = mock_cls.call_args[1]
            assert kwargs["base_url"] == "http://localhost:8000"
            assert kwargs["headers"]["Authorization"] == "Bearer my-token"
            assert kwargs["timeout"] == 15

    def test_accepts_auth_client_and_freeze_event(self) -> None:
        with patch("huitzo_cli.session_heartbeat.httpx.Client"):
            from huitzo_cli.session_heartbeat import SessionHeartbeat

            mock_auth = MagicMock()
            freeze = threading.Event()
            hb = SessionHeartbeat(
                api_url="http://localhost:8000",
                auth_token="tok",
                auth_client=mock_auth,
                freeze_event=freeze,
            )
            assert hb._auth_client is mock_auth
            assert hb._freeze_event is freeze


# ---------------------------------------------------------------------------
# Token refresh
# ---------------------------------------------------------------------------


class TestTokenRefresh:
    """Tests for token refresh in heartbeat loop."""

    def test_401_heartbeat_triggers_refresh(self) -> None:
        """A 401 heartbeat response should call auth_client.refresh_tokens()."""
        with patch("huitzo_cli.session_heartbeat.httpx.Client") as mock_cls:
            mock_client = MagicMock()
            mock_cls.return_value = mock_client

            # First heartbeat returns 401, retry returns 200
            resp_401 = MagicMock()
            resp_401.status_code = 401
            resp_200 = MagicMock()
            resp_200.status_code = 200
            mock_client.post.side_effect = [resp_401, resp_200]

            mock_auth = MagicMock()
            mock_auth.refresh_tokens.return_value = "new-access-token"

            from huitzo_cli.session_heartbeat import SessionHeartbeat

            hb = SessionHeartbeat(
                api_url="http://localhost:8000",
                auth_token="tok",
                auth_client=mock_auth,
            )
            hb._session_id = "sess-refresh"

            # Call _heartbeat_loop iteration directly
            hb._stop_event.set()  # Will exit after one iteration
            # Manually invoke one heartbeat cycle
            response = hb._client.post(f"/api/v1/dev-sessions/{hb._session_id}/heartbeat")
            if response.status_code == 401:
                hb._try_refresh_token()

            mock_auth.refresh_tokens.assert_called_once()

    def test_refresh_updates_auth_header(self) -> None:
        """After successful refresh, the client Authorization header is updated."""
        with patch("huitzo_cli.session_heartbeat.httpx.Client") as mock_cls:
            mock_client = MagicMock()
            mock_client.headers = {"Authorization": "Bearer old-token"}
            mock_cls.return_value = mock_client

            mock_auth = MagicMock()
            mock_auth.refresh_tokens.return_value = "fresh-token"

            from huitzo_cli.session_heartbeat import SessionHeartbeat

            hb = SessionHeartbeat(
                api_url="http://localhost:8000",
                auth_token="old-token",
                auth_client=mock_auth,
            )
            hb._try_refresh_token()

            assert hb._client.headers["Authorization"] == "Bearer fresh-token"

    def test_refresh_failure_sets_freeze_event(self) -> None:
        """When refresh_tokens() raises, freeze_event should be set."""
        with patch("huitzo_cli.session_heartbeat.httpx.Client") as mock_cls:
            mock_client = MagicMock()
            mock_cls.return_value = mock_client

            mock_auth = MagicMock()
            mock_auth.refresh_tokens.side_effect = AuthenticationError("expired")

            freeze = threading.Event()

            from huitzo_cli.session_heartbeat import SessionHeartbeat

            hb = SessionHeartbeat(
                api_url="http://localhost:8000",
                auth_token="tok",
                auth_client=mock_auth,
                freeze_event=freeze,
            )
            hb._try_refresh_token()

            assert freeze.is_set()

    def test_no_auth_client_sets_freeze_event(self) -> None:
        """Without an auth_client, refresh should set freeze_event."""
        with patch("huitzo_cli.session_heartbeat.httpx.Client"):
            from huitzo_cli.session_heartbeat import SessionHeartbeat

            freeze = threading.Event()
            hb = SessionHeartbeat(
                api_url="http://localhost:8000",
                auth_token="tok",
                freeze_event=freeze,
            )
            hb._try_refresh_token()

            assert freeze.is_set()

    def test_concurrent_refresh_uses_lock(self) -> None:
        """Only one refresh should run at a time."""
        with patch("huitzo_cli.session_heartbeat.httpx.Client") as mock_cls:
            mock_client = MagicMock()
            mock_client.headers = {"Authorization": "Bearer old"}
            mock_cls.return_value = mock_client

            call_count = 0

            def slow_refresh() -> str:
                nonlocal call_count
                call_count += 1
                time.sleep(0.1)
                return "new-token"

            mock_auth = MagicMock()
            mock_auth.refresh_tokens.side_effect = slow_refresh

            from huitzo_cli.session_heartbeat import SessionHeartbeat

            hb = SessionHeartbeat(
                api_url="http://localhost:8000",
                auth_token="tok",
                auth_client=mock_auth,
            )

            # Launch two refresh attempts concurrently
            t1 = threading.Thread(target=hb._try_refresh_token)
            t2 = threading.Thread(target=hb._try_refresh_token)
            t1.start()
            t2.start()
            t1.join(timeout=2)
            t2.join(timeout=2)

            # Both threads call refresh (lock serializes, doesn't skip)
            # but they should not corrupt state
            assert call_count == 2
            assert hb._client.headers["Authorization"] == "Bearer new-token"
