"""Token lifecycle invariants.

These pin the behaviours that only misbehave against a live Control Room:
pre-emptive renewal, serialized refresh, single re-auth on 401, session
accounting, and the cooldown that stops a bad credential becoming a lockout.
"""

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

import pytest
import requests

from a360_mcp.auth._session_budget import MAX_CONCURRENT_SESSIONS, SessionBudget
from a360_mcp.auth._token_store import Token, TokenStore
from a360_mcp.auth.auth_manager import A360AuthError, AuthManager


@pytest.fixture
def manager(server_config) -> AuthManager:
    mgr = AuthManager(server_config, owner="test-owner")
    mgr._session = MagicMock(spec=requests.Session)
    return mgr


def auth_body(token: str = "tok-1", ttl: str = "1200") -> dict:
    return {"token": token, "ttlSeconds": ttl}


def http(body: dict, status_code: int = 200) -> MagicMock:
    response = MagicMock(spec=requests.Response)
    response.status_code = status_code
    response.json.return_value = body
    response.text = str(body)
    return response


class TestGetHeaders:
    def test_authenticates_and_returns_bare_token(self, manager):
        manager._session.post.return_value = http(auth_body())
        headers = manager.get_headers()
        assert headers["X-Authorization"] == "tok-1"

    def test_never_sends_a_bearer_authorization_header(self, manager):
        # The Control Room rejects X-Authorization and Authorization together.
        manager._session.post.return_value = http(auth_body())
        assert "Authorization" not in manager.get_headers()

    def test_posts_api_key_body_to_v2_endpoint(self, manager):
        manager._session.post.return_value = http(auth_body())
        manager.get_headers()
        url = manager._session.post.call_args.args[0]
        body = manager._session.post.call_args.kwargs["json"]
        assert url == "https://cr.example.com/v2/authentication"
        assert body == {"username": "svc_mcp", "multipleLogin": False, "apiKey": "test-key"}

    def test_reuses_the_cached_token(self, manager):
        manager._session.post.return_value = http(auth_body())
        manager.get_headers()
        manager.get_headers()
        assert manager._session.post.call_count == 1

    def test_renews_before_expiry_rather_than_on_401(self, manager):
        manager._session.post.return_value = http(auth_body())
        manager.get_headers()
        # Nudge the cached token inside the skew window.
        manager._token = Token(value="tok-1", expires_at=time.time() + 1, username="svc_mcp")
        manager._store.clear()
        manager._session.post.return_value = http(auth_body("tok-2"))
        assert manager.get_headers()["X-Authorization"] == "tok-2"


class TestDiskSharing:
    def test_adopts_a_token_cached_by_a_sibling_process(self, manager):
        TokenStore(manager._key).save(
            Token(value="sibling-tok", expires_at=time.time() + 600, username="svc_mcp")
        )
        assert manager.get_headers()["X-Authorization"] == "sibling-tok"
        manager._session.post.assert_not_called()

    def test_ignores_an_expired_cached_token(self, manager):
        TokenStore(manager._key).save(
            Token(value="stale", expires_at=time.time() - 10, username="svc_mcp")
        )
        manager._session.post.return_value = http(auth_body("fresh"))
        assert manager.get_headers()["X-Authorization"] == "fresh"

    def test_publishes_its_token_for_siblings(self, manager):
        manager._session.post.return_value = http(auth_body())
        manager.get_headers()
        assert TokenStore(manager._key).load().value == "tok-1"


class TestRefreshSerialization:
    def test_concurrent_callers_authenticate_once(self, manager):
        # A second refresh would invalidate the first caller's fresh token, so
        # the losers must wait and adopt the winner's result — not call again.
        def slow_post(*_args, **_kwargs):
            time.sleep(0.05)
            return http(auth_body())

        manager._session.post.side_effect = slow_post
        threads = [threading.Thread(target=manager.get_headers) for _ in range(4)]
        for thread in threads:
            thread.start()
        for thread in threads:
            thread.join(timeout=10)
        assert manager._session.post.call_count == 1

    def test_expired_token_is_refreshed_via_refresh_endpoint(self, manager):
        TokenStore(manager._key).save(
            Token(value="old", expires_at=time.time() - 1, username="svc_mcp")
        )
        manager._session.post.return_value = http(auth_body("new"))
        manager.get_headers()
        assert manager._session.post.call_args.args[0].endswith("/v2/authentication/token")

    def test_refresh_sends_the_previous_token(self, manager):
        TokenStore(manager._key).save(
            Token(value="old", expires_at=time.time() - 1, username="svc_mcp")
        )
        manager._session.post.return_value = http(auth_body("new"))
        manager.get_headers()
        assert manager._session.post.call_args.kwargs["headers"]["X-Authorization"] == "old"

    def test_rejected_refresh_falls_back_to_full_authentication(self, manager):
        TokenStore(manager._key).save(
            Token(value="old", expires_at=time.time() - 1, username="svc_mcp")
        )
        manager._session.post.side_effect = [http({}, status_code=401), http(auth_body("new"))]
        assert manager.get_headers()["X-Authorization"] == "new"


class TestMakeRequest:
    def test_reauthenticates_once_on_401(self, manager):
        manager._session.post.return_value = http(auth_body())
        manager._session.request.side_effect = [http({}, 401), http({"ok": True}, 200)]
        response = manager.make_request("GET", "/v2/devices/list")
        assert response.status_code == 200
        assert manager._session.request.call_count == 2

    def test_does_not_loop_on_repeated_401(self, manager):
        # A rejected credential must not become an account lockout.
        manager._session.post.return_value = http(auth_body())
        manager._session.request.return_value = http({}, 401)
        assert manager.make_request("GET", "/v2/devices/list").status_code == 401
        assert manager._session.request.call_count == 2

    @patch("a360_mcp.auth.auth_manager.time.sleep")
    def test_retries_gateway_errors(self, _sleep, manager):
        manager._session.post.return_value = http(auth_body())
        manager._session.request.side_effect = [http({}, 503), http({"ok": True}, 200)]
        assert manager.make_request("GET", "/v2/devices/list").status_code == 200

    def test_does_not_retry_500(self, manager):
        # A real server bug should return its body, not be retried into a timeout.
        manager._session.post.return_value = http(auth_body())
        manager._session.request.return_value = http({"error": "boom"}, 500)
        assert manager.make_request("GET", "/v2/devices/list").status_code == 500
        assert manager._session.request.call_count == 1

    @patch("a360_mcp.auth.auth_manager.time.sleep")
    def test_gives_up_after_repeated_connection_errors(self, _sleep, manager):
        manager._session.post.return_value = http(auth_body())
        manager._session.request.side_effect = requests.ConnectionError("down")
        with pytest.raises(A360AuthError, match="unreachable after retries"):
            manager.make_request("GET", "/v2/devices/list")


class TestSessionBudget:
    def test_books_a_session_slot_on_authentication(self, manager):
        manager._session.post.return_value = http(auth_body())
        manager.get_headers()
        assert manager.session_summary()["owners"] == ["test-owner"]

    def test_refuses_to_authenticate_when_the_budget_is_full(self, manager):
        budget = SessionBudget(manager._key)
        for index in range(MAX_CONCURRENT_SESSIONS):
            budget.claim(f"other-{index}")
        with pytest.raises(A360AuthError, match="already held"):
            manager.get_headers()

    def test_logout_releases_the_slot(self, manager):
        manager._session.post.return_value = http(auth_body())
        manager.get_headers()
        manager.logout()
        assert manager.session_summary()["used"] == 0

    def test_logout_releases_the_slot_even_if_the_call_fails(self, manager):
        manager._session.post.side_effect = [http(auth_body()), requests.ConnectionError("down")]
        manager.get_headers()
        manager.logout()
        assert manager.session_summary()["used"] == 0


class TestCooldown:
    def test_failure_starts_a_cooldown(self, manager):
        manager._session.post.return_value = http({"message": "bad key"}, 401)
        with pytest.raises(A360AuthError, match="rejected authentication"):
            manager.get_headers()
        with pytest.raises(A360AuthError, match="cooling down"):
            manager.get_headers()

    def test_cooldown_message_tells_the_caller_not_to_retry(self, manager):
        manager._session.post.return_value = http({}, 401)
        with pytest.raises(A360AuthError):
            manager.get_headers()
        with pytest.raises(A360AuthError, match="locks accounts"):
            manager.get_headers()

    def test_cooldown_grows_with_consecutive_failures(self, manager):
        manager._session.post.return_value = http({}, 401)
        for _ in range(2):
            with pytest.raises(A360AuthError):
                manager.get_headers()
            manager._cooldown_until = 0.0
        assert manager._failure_count == 2

    def test_success_clears_the_cooldown(self, manager):
        manager._session.post.return_value = http({}, 401)
        with pytest.raises(A360AuthError):
            manager.get_headers()
        manager._cooldown_until = 0.0
        manager._session.post.return_value = http(auth_body())
        manager.get_headers()
        assert manager._failure_count == 0


class TestInvalidate:
    def test_invalidate_clears_memory_and_disk(self, manager):
        manager._session.post.return_value = http(auth_body())
        manager.get_headers()
        manager.invalidate()
        assert manager._token is None
        assert TokenStore(manager._key).load() is None


class TestSharedTokenTeardown:
    """The on-disk token is shared; teardown must not be a blast radius.

    ``_token_store`` caches per *account*, not per process, so a second MCP
    host adopts the same token instead of burning one of the account's five
    sessions. Unconditional teardown turns one host's exit into a forced
    re-authentication for every other host.
    """

    def test_logout_without_a_token_leaves_the_shared_cache_alone(self, manager):
        # A host that connected and disconnected without ever calling a tool.
        sibling = Token(value="sibling-tok", expires_at=time.time() + 600, username="svc_mcp")
        TokenStore(manager._key).save(sibling)
        manager.logout()
        assert TokenStore(manager._key).load().value == "sibling-tok"
        manager._session.post.assert_not_called()

    def test_logout_does_not_end_a_session_it_only_adopted(self, manager):
        # Adopting a sibling's token must not give us the right to kill it.
        TokenStore(manager._key).save(
            Token(value="sibling-tok", expires_at=time.time() + 600, username="svc_mcp")
        )
        manager.get_headers()
        manager.logout()
        manager._session.post.assert_not_called()
        assert TokenStore(manager._key).load().value == "sibling-tok"

    def test_logout_still_releases_our_own_ledger_slot_when_adopting(self, manager):
        TokenStore(manager._key).save(
            Token(value="sibling-tok", expires_at=time.time() + 600, username="svc_mcp")
        )
        manager.get_headers()
        manager.logout()
        assert manager.session_summary()["used"] == 0

    def test_logout_ends_a_session_we_opened_ourselves(self, manager):
        manager._session.post.return_value = http(auth_body())
        manager.get_headers()
        manager.logout()
        # The last POST is the Control Room logout for our own session.
        assert "logout" in manager._session.post.call_args[0][0]
        assert TokenStore(manager._key).load() is None

    def test_invalidate_leaves_a_newer_sibling_token_in_place(self, manager):
        manager._session.post.return_value = http(auth_body("ours"))
        manager.get_headers()
        # A sibling re-authenticated in the meantime and published a new token.
        TokenStore(manager._key).save(
            Token(value="newer-sibling-tok", expires_at=time.time() + 600, username="svc_mcp")
        )
        manager.invalidate()
        assert manager._token is None
        assert TokenStore(manager._key).load().value == "newer-sibling-tok"
