"""Tests for subscription plan enforcement in the CLI (up.py) and device flow."""

from __future__ import annotations

import json
from typing import TYPE_CHECKING
from unittest.mock import AsyncMock, MagicMock, patch

import pytest

from llmhosts.auth.device_flow import Credentials, DeviceCodeFlow

if TYPE_CHECKING:
    from pathlib import Path

# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


def _make_creds(plan: str = "pro", server_url: str = "https://llmhosts.com") -> Credentials:
    return Credentials(
        api_key="lh_sk_test",
        user_id="user-1",
        email="test@example.com",
        name="Test User",
        plan=plan,
        server_url=server_url,
    )


# ---------------------------------------------------------------------------
# DeviceCodeFlow.refresh_plan()
# ---------------------------------------------------------------------------


@pytest.mark.unit
@pytest.mark.asyncio
async def test_refresh_plan_updates_plan(tmp_path: Path) -> None:
    """refresh_plan() fetches live plan and returns updated Credentials."""
    flow = DeviceCodeFlow(config_dir=tmp_path)
    creds = _make_creds(plan="pro")

    mock_response = MagicMock()
    mock_response.status_code = 200
    mock_response.json.return_value = {
        "id": "user-1",
        "email": "test@example.com",
        "name": "Test User",
        "plan": "free",  # downgraded on server
    }

    with patch("httpx.AsyncClient") as mock_client_cls:
        mock_client = AsyncMock()
        mock_client.__aenter__ = AsyncMock(return_value=mock_client)
        mock_client.__aexit__ = AsyncMock(return_value=None)
        mock_client.get = AsyncMock(return_value=mock_response)
        mock_client_cls.return_value = mock_client

        updated = await flow.refresh_plan(creds)

    assert updated.plan == "free"
    assert updated.api_key == creds.api_key
    assert updated.email == creds.email


@pytest.mark.unit
@pytest.mark.asyncio
async def test_refresh_plan_persists_to_disk(tmp_path: Path) -> None:
    """refresh_plan() writes updated credentials to disk on plan change."""
    flow = DeviceCodeFlow(config_dir=tmp_path)
    creds = _make_creds(plan="pro")

    mock_response = MagicMock()
    mock_response.status_code = 200
    mock_response.json.return_value = {
        "id": "user-1",
        "email": "test@example.com",
        "name": "Test User",
        "plan": "team",
    }

    with patch("httpx.AsyncClient") as mock_client_cls:
        mock_client = AsyncMock()
        mock_client.__aenter__ = AsyncMock(return_value=mock_client)
        mock_client.__aexit__ = AsyncMock(return_value=None)
        mock_client.get = AsyncMock(return_value=mock_response)
        mock_client_cls.return_value = mock_client

        updated = await flow.refresh_plan(creds)

    creds_file = tmp_path / "credentials.json"
    assert creds_file.exists()
    saved = json.loads(creds_file.read_text())
    assert saved["plan"] == "team"
    assert updated.plan == "team"


@pytest.mark.unit
@pytest.mark.asyncio
async def test_refresh_plan_no_change_no_write(tmp_path: Path) -> None:
    """refresh_plan() does not write to disk if plan is unchanged."""
    flow = DeviceCodeFlow(config_dir=tmp_path)
    creds = _make_creds(plan="pro")

    mock_response = MagicMock()
    mock_response.status_code = 200
    mock_response.json.return_value = {
        "id": "user-1",
        "email": "test@example.com",
        "name": "Test User",
        "plan": "pro",  # same plan
    }

    with patch("httpx.AsyncClient") as mock_client_cls:
        mock_client = AsyncMock()
        mock_client.__aenter__ = AsyncMock(return_value=mock_client)
        mock_client.__aexit__ = AsyncMock(return_value=None)
        mock_client.get = AsyncMock(return_value=mock_response)
        mock_client_cls.return_value = mock_client

        updated = await flow.refresh_plan(creds)

    assert updated.plan == "pro"
    creds_file = tmp_path / "credentials.json"
    assert not creds_file.exists()  # no write on unchanged plan


@pytest.mark.unit
@pytest.mark.asyncio
async def test_refresh_plan_network_error_returns_original(tmp_path: Path) -> None:
    """refresh_plan() returns original creds unchanged on network error."""
    import httpx

    flow = DeviceCodeFlow(config_dir=tmp_path)
    creds = _make_creds(plan="pro")

    with patch("httpx.AsyncClient") as mock_client_cls:
        mock_client = AsyncMock()
        mock_client.__aenter__ = AsyncMock(return_value=mock_client)
        mock_client.__aexit__ = AsyncMock(return_value=None)
        mock_client.get = AsyncMock(side_effect=httpx.ConnectError("timeout"))
        mock_client_cls.return_value = mock_client

        updated = await flow.refresh_plan(creds)

    assert updated.plan == "pro"
    assert updated is creds  # same object returned unchanged


@pytest.mark.unit
@pytest.mark.asyncio
async def test_refresh_plan_auth_error_returns_original(tmp_path: Path) -> None:
    """refresh_plan() returns original creds on 401 (key revoked / expired)."""
    flow = DeviceCodeFlow(config_dir=tmp_path)
    creds = _make_creds(plan="pro")

    mock_response = MagicMock()
    mock_response.status_code = 401

    with patch("httpx.AsyncClient") as mock_client_cls:
        mock_client = AsyncMock()
        mock_client.__aenter__ = AsyncMock(return_value=mock_client)
        mock_client.__aexit__ = AsyncMock(return_value=None)
        mock_client.get = AsyncMock(return_value=mock_response)
        mock_client_cls.return_value = mock_client

        updated = await flow.refresh_plan(creds)

    assert updated.plan == "pro"
    assert updated is creds


# ---------------------------------------------------------------------------
# Tunnel plan gating (_start_tunnel)
# ---------------------------------------------------------------------------


@pytest.mark.unit
@pytest.mark.asyncio
async def test_tunnel_skipped_on_free_plan() -> None:
    """_start_tunnel() exits early with a message when plan=free."""
    from rich.console import Console

    from llmhosts.up import LLMHostUp

    up = LLMHostUp(console=Console(quiet=True))
    up._credentials = _make_creds(plan="free")

    # TunnelManager.start should NOT be called
    up._tunnel_mgr = MagicMock()
    up._tunnel_mgr.start = AsyncMock()

    await up._start_tunnel()

    up._tunnel_mgr.start.assert_not_called()
    assert up._tunnel_url is None


@pytest.mark.unit
@pytest.mark.asyncio
async def test_tunnel_starts_on_pro_plan() -> None:
    """_start_tunnel() proceeds normally when plan=pro."""
    from rich.console import Console

    from llmhosts.tunnel.models import TunnelProvider, TunnelStatus
    from llmhosts.up import LLMHostUp

    up = LLMHostUp(console=Console(quiet=True))
    up._credentials = _make_creds(plan="pro")

    mock_status = TunnelStatus(
        active=True,
        url="wss://llmhosts.com/api/relay/connect",
        provider=TunnelProvider.RELAY,
    )
    up._tunnel_mgr = MagicMock()
    up._tunnel_mgr.start = AsyncMock(return_value=mock_status)

    await up._start_tunnel()

    up._tunnel_mgr.start.assert_called_once()
    assert up._tunnel_url == "wss://llmhosts.com/api/relay/connect"


@pytest.mark.unit
@pytest.mark.asyncio
async def test_tunnel_skipped_when_no_credentials() -> None:
    """_start_tunnel() starts without plan check when no credentials (local-only mode)."""
    from rich.console import Console

    from llmhosts.tunnel.models import TunnelProvider, TunnelStatus
    from llmhosts.up import LLMHostUp

    up = LLMHostUp(console=Console(quiet=True))
    up._credentials = None  # not authenticated

    mock_status = TunnelStatus(
        active=True,
        url="wss://llmhosts.com/api/relay/connect",
        provider=TunnelProvider.RELAY,
    )
    up._tunnel_mgr = MagicMock()
    up._tunnel_mgr.start = AsyncMock(return_value=mock_status)

    await up._start_tunnel()

    # Should still try to start (non-cloud mode fallback)
    up._tunnel_mgr.start.assert_called_once()


# ---------------------------------------------------------------------------
# Heartbeat downgrade detection
# ---------------------------------------------------------------------------


@pytest.mark.unit
@pytest.mark.asyncio
async def test_heartbeat_detects_downgrade_and_stops_tunnel() -> None:
    """Heartbeat stops the tunnel when plan downgrade removes tunnel_public."""
    from rich.console import Console

    from llmhosts.up import LLMHostUp

    up = LLMHostUp(console=Console(quiet=True))
    up._credentials = _make_creds(plan="pro")
    up._tunnel_url = "wss://llmhosts.com/api/relay/connect"  # tunnel is active
    up._shutdown_event = MagicMock()
    # Loop: while-check=False → enter; inner-check=False → proceed; while-check=True → exit
    up._shutdown_event.is_set.side_effect = [False, False, True]
    up._watcher = None

    # Mock heartbeat response: plan downgraded to free
    mock_hb_response = MagicMock()
    mock_hb_response.status_code = 200
    mock_hb_response.json.return_value = {
        "id": "dev-1",
        "status": "online",
        "last_seen": "2026-02-24T00:00:00Z",
        "plan": "free",
    }

    # refresh_plan returns updated free creds
    up._auth_flow = MagicMock()
    updated_creds = _make_creds(plan="free")
    up._auth_flow.refresh_plan = AsyncMock(return_value=updated_creds)

    up._tunnel_mgr = MagicMock()
    up._tunnel_mgr.stop = AsyncMock()
    up._savings = MagicMock()
    up._savings.total_requests = 0

    with patch("asyncio.sleep", new_callable=AsyncMock), patch("httpx.AsyncClient") as mock_client_cls:
        mock_client = AsyncMock()
        mock_client.__aenter__ = AsyncMock(return_value=mock_client)
        mock_client.__aexit__ = AsyncMock(return_value=None)
        mock_client.post = AsyncMock(return_value=mock_hb_response)
        mock_client_cls.return_value = mock_client

        await up._heartbeat_loop()

    up._tunnel_mgr.stop.assert_called_once()
    assert up._tunnel_url is None
    assert up._credentials.plan == "free"


@pytest.mark.unit
@pytest.mark.asyncio
async def test_heartbeat_no_action_when_plan_unchanged() -> None:
    """Heartbeat does not stop tunnel when plan is unchanged."""
    from rich.console import Console

    from llmhosts.up import LLMHostUp

    up = LLMHostUp(console=Console(quiet=True))
    up._credentials = _make_creds(plan="pro")
    up._tunnel_url = "wss://llmhosts.com/api/relay/connect"
    up._shutdown_event = MagicMock()
    up._shutdown_event.is_set.side_effect = [False, False, True]
    up._watcher = None

    mock_hb_response = MagicMock()
    mock_hb_response.status_code = 200
    mock_hb_response.json.return_value = {
        "id": "dev-1",
        "status": "online",
        "last_seen": "2026-02-24T00:00:00Z",
        "plan": "pro",  # same plan
    }

    up._auth_flow = MagicMock()
    up._tunnel_mgr = MagicMock()
    up._tunnel_mgr.stop = AsyncMock()
    up._savings = MagicMock()
    up._savings.total_requests = 0

    with patch("asyncio.sleep", new_callable=AsyncMock), patch("httpx.AsyncClient") as mock_client_cls:
        mock_client = AsyncMock()
        mock_client.__aenter__ = AsyncMock(return_value=mock_client)
        mock_client.__aexit__ = AsyncMock(return_value=None)
        mock_client.post = AsyncMock(return_value=mock_hb_response)
        mock_client_cls.return_value = mock_client

        await up._heartbeat_loop()

    up._tunnel_mgr.stop.assert_not_called()
    assert up._tunnel_url == "wss://llmhosts.com/api/relay/connect"
