"""Tests for Headscale API client and Hive ↔ Headscale integration.

Tests use respx to mock HTTP calls — no live Headscale needed.
"""

from __future__ import annotations

import json
from pathlib import Path  # noqa: TC003 -- used at runtime in fixtures
from unittest.mock import AsyncMock, MagicMock

import httpx
import pytest
import respx

from llmhosts.hive.headscale import HeadscaleClient, HeadscaleError, generate_acl_policy
from llmhosts.hive.manager import HiveManager, _headscale_user_name

# ---------------------------------------------------------------------------
# HeadscaleClient unit tests
# ---------------------------------------------------------------------------


@respx.mock
async def test_headscale_create_user() -> None:
    """create_user() posts to /api/v1/user and returns the user dict."""
    respx.post("http://headscale.test/api/v1/user").mock(
        return_value=httpx.Response(
            200,
            json={"user": {"id": "1", "name": "my-hive", "createdAt": "2026-02-19T00:00:00Z"}},
        )
    )

    client = HeadscaleClient("http://headscale.test", "test-key")
    user = await client.create_user("my-hive")

    assert user["name"] == "my-hive"
    assert user["id"] == "1"


@respx.mock
async def test_headscale_create_user_conflict_fetches_existing() -> None:
    """409 Conflict causes create_user() to fetch the existing user."""
    respx.post("http://headscale.test/api/v1/user").mock(return_value=httpx.Response(409, text="Conflict"))
    respx.get("http://headscale.test/api/v1/user/my-hive").mock(
        return_value=httpx.Response(
            200,
            json={"user": {"id": "5", "name": "my-hive", "createdAt": "2026-01-01T00:00:00Z"}},
        )
    )

    client = HeadscaleClient("http://headscale.test", "test-key")
    user = await client.create_user("my-hive")

    assert user["id"] == "5"


@respx.mock
async def test_headscale_create_preauth_key() -> None:
    """create_preauth_key() returns the key string from the response."""
    respx.post("http://headscale.test/api/v1/preauthkey").mock(
        return_value=httpx.Response(
            200,
            json={
                "preAuthKey": {
                    "user": "my-hive",
                    "id": "1",
                    "key": "abc123def456",
                    "reusable": True,
                    "ephemeral": False,
                    "used": False,
                    "expiration": "2026-08-01T00:00:00Z",
                    "createdAt": "2026-02-19T00:00:00Z",
                    "aclTags": [],
                }
            },
        )
    )

    client = HeadscaleClient("http://headscale.test", "test-key")
    key = await client.create_preauth_key("my-hive")

    assert key == "abc123def456"


@respx.mock
async def test_headscale_create_preauth_key_sends_correct_payload() -> None:
    """Payload includes user, reusable, ephemeral=False, expiration."""
    route = respx.post("http://headscale.test/api/v1/preauthkey").mock(
        return_value=httpx.Response(
            200,
            json={
                "preAuthKey": {
                    "key": "testkey",
                    "user": "u",
                    "id": "1",
                    "reusable": True,
                    "ephemeral": False,
                    "used": False,
                    "expiration": "",
                    "createdAt": "",
                    "aclTags": [],
                }
            },
        )
    )

    client = HeadscaleClient("http://headscale.test", "test-key")
    await client.create_preauth_key("my-hive", expiration_days=90)

    payload = json.loads(route.calls[0].request.content)
    assert payload["user"] == "my-hive"
    assert payload["reusable"] is True
    assert payload["ephemeral"] is False
    assert "expiration" in payload


@respx.mock
async def test_headscale_api_error_raises_headscale_error() -> None:
    """Non-2xx responses raise HeadscaleError."""
    respx.post("http://headscale.test/api/v1/user").mock(return_value=httpx.Response(500, text="Internal Server Error"))

    client = HeadscaleClient("http://headscale.test", "test-key")
    with pytest.raises(HeadscaleError, match="500"):
        await client.create_user("bad")


@respx.mock
async def test_headscale_list_nodes() -> None:
    """list_nodes() returns node list for a user."""
    respx.get("http://headscale.test/api/v1/node").mock(
        return_value=httpx.Response(
            200,
            json={"nodes": [{"id": "1", "name": "laptop", "user": {"name": "my-hive"}}]},
        )
    )

    client = HeadscaleClient("http://headscale.test", "test-key")
    nodes = await client.list_nodes("my-hive")

    assert len(nodes) == 1
    assert nodes[0]["name"] == "laptop"


@respx.mock
async def test_headscale_health_pass() -> None:
    """health() returns True when Headscale returns status pass."""
    respx.get("http://headscale.test/health").mock(return_value=httpx.Response(200, json={"status": "pass"}))

    client = HeadscaleClient("http://headscale.test", "test-key")
    assert await client.health() is True


@respx.mock
async def test_headscale_health_fail_on_connection_error() -> None:
    """health() returns False when Headscale is unreachable."""
    respx.get("http://headscale.test/health").mock(side_effect=httpx.ConnectError("no connection"))

    client = HeadscaleClient("http://headscale.test", "test-key")
    assert await client.health() is False


def test_headscale_from_env_with_key(monkeypatch: pytest.MonkeyPatch) -> None:
    """from_env() returns a client when HEADSCALE_API_KEY is set."""
    monkeypatch.setenv("HEADSCALE_URL", "http://hs.example")
    monkeypatch.setenv("HEADSCALE_API_KEY", "my-secret-key")

    client = HeadscaleClient.from_env()

    assert client is not None
    assert client._url == "http://hs.example"


def test_headscale_from_env_without_key(monkeypatch: pytest.MonkeyPatch) -> None:
    """from_env() returns None when HEADSCALE_API_KEY is not set."""
    monkeypatch.delenv("HEADSCALE_API_KEY", raising=False)

    client = HeadscaleClient.from_env()

    assert client is None


# ---------------------------------------------------------------------------
# _headscale_user_name helper
# ---------------------------------------------------------------------------


@pytest.mark.parametrize(
    ("hive_name", "expected"),
    [
        ("My Hive", "my-hive"),
        ("Alice's GPU Pool", "alice-s-gpu-pool"),
        ("test123", "test123"),
        ("  spaces  ", "spaces"),
        ("UPPER CASE", "upper-case"),
        ("multiple   spaces", "multiple-spaces"),
    ],
)
def test_headscale_user_name(hive_name: str, expected: str) -> None:
    assert _headscale_user_name(hive_name) == expected


# ---------------------------------------------------------------------------
# HiveManager ↔ Headscale integration
# ---------------------------------------------------------------------------


@pytest.fixture()
def tmp_data_dir(tmp_path: Path) -> Path:
    return tmp_path / "llmhosts"


async def test_create_hive_without_headscale(tmp_data_dir: Path) -> None:
    """create_hive() works fine without Headscale configured."""
    mgr = HiveManager(tmp_data_dir)
    await mgr.initialize()

    hive = await mgr.create_hive("Test Hive", "Alice")

    assert hive.name == "Test Hive"
    assert hive.headscale_user is None
    assert hive.headscale_preauth_key is None


async def test_create_hive_with_headscale(tmp_data_dir: Path) -> None:
    """create_hive() provisions Headscale user + pre-auth key when client is set."""
    mock_hs = AsyncMock()
    mock_hs.create_user.return_value = {"id": "1", "name": "test-hive"}
    mock_hs.create_preauth_key.return_value = "test-preauth-key-abc123"

    mgr = HiveManager(tmp_data_dir, headscale=mock_hs)
    await mgr.initialize()

    hive = await mgr.create_hive("Test Hive", "Alice")

    assert hive.headscale_user == "test-hive"
    assert hive.headscale_preauth_key == "test-preauth-key-abc123"
    mock_hs.create_user.assert_called_once_with("test-hive")
    mock_hs.create_preauth_key.assert_called_once_with("test-hive")


async def test_create_hive_headscale_failure_is_non_fatal(tmp_data_dir: Path) -> None:
    """Headscale API failure doesn't prevent Hive creation (local-only fallback)."""
    mock_hs = AsyncMock()
    mock_hs.create_user.side_effect = Exception("Headscale unreachable")

    mgr = HiveManager(tmp_data_dir, headscale=mock_hs)
    await mgr.initialize()

    hive = await mgr.create_hive("Test Hive", "Alice")

    # Hive created successfully despite Headscale failure
    assert hive.name == "Test Hive"
    assert hive.headscale_user is None
    assert hive.headscale_preauth_key is None


async def test_hive_headscale_state_persisted(tmp_data_dir: Path) -> None:
    """Headscale user + pre-auth key survive save/reload cycle."""
    mock_hs = AsyncMock()
    mock_hs.create_user.return_value = {"id": "42", "name": "my-hive"}
    mock_hs.create_preauth_key.return_value = "persistent-key-xyz"

    mgr = HiveManager(tmp_data_dir, headscale=mock_hs)
    await mgr.initialize()
    await mgr.create_hive("My Hive", "Bob")

    # Reload from disk
    mgr2 = HiveManager(tmp_data_dir)
    await mgr2.initialize()
    hive = await mgr2.get_hive()

    assert hive is not None
    assert hive.headscale_user == "my-hive"
    assert hive.headscale_preauth_key == "persistent-key-xyz"


async def test_join_hive_inherits_preauth_key(tmp_data_dir: Path) -> None:
    """Joining a Hive gives access to the headscale_preauth_key from registry."""
    mock_hs = AsyncMock()
    mock_hs.create_user.return_value = {"id": "1", "name": "shared-hive"}
    mock_hs.create_preauth_key.return_value = "shared-preauth-key"

    # Owner creates hive
    owner_dir = tmp_data_dir / "owner"
    registry_path = tmp_data_dir / "registry"
    owner_mgr = HiveManager(owner_dir, registry_path=registry_path, headscale=mock_hs)
    await owner_mgr.initialize()
    hive = await owner_mgr.create_hive("Shared Hive", "Alice")
    invite_code = hive.invite_code

    # Bob joins from different data dir but shared registry
    bob_dir = tmp_data_dir / "bob"
    bob_mgr = HiveManager(bob_dir, registry_path=registry_path)
    await bob_mgr.initialize()
    await bob_mgr.join_hive(invite_code, "Bob")

    bob_hive = await bob_mgr.get_hive()
    assert bob_hive is not None
    assert bob_hive.headscale_preauth_key == "shared-preauth-key"


# ---------------------------------------------------------------------------
# ACL policy generation (per-Hive, no cross-Hive)
# ---------------------------------------------------------------------------


def test_generate_acl_policy_one_hive() -> None:
    """generate_acl_policy produces one group and one same-Hive-only accept for a single Hive."""
    hives = [{"id": "hive-uuid-1", "headscale_user": "my-hive"}]
    policy = generate_acl_policy(hives)

    assert "groups" in policy
    assert "acls" in policy
    assert policy["groups"] == {"group:hive-hive-uuid-1": ["my-hive"]}
    assert policy["acls"] == [
        {"action": "accept", "src": ["group:hive-hive-uuid-1"], "dst": ["group:hive-hive-uuid-1:*"]}
    ]
    assert policy["tagOwners"] == {}


def test_generate_acl_policy_two_hives_no_cross() -> None:
    """generate_acl_policy produces separate groups and acls; no cross-Hive accept."""
    hives = [
        {"id": "hive-a", "headscale_user": "user-a"},
        {"id": "hive-b", "headscale_user": "user-b"},
    ]
    policy = generate_acl_policy(hives)

    assert policy["groups"] == {
        "group:hive-hive-a": ["user-a"],
        "group:hive-hive-b": ["user-b"],
    }
    acls = policy["acls"]
    assert len(acls) == 2
    by_src = {tuple(a["src"]): a for a in acls}
    assert by_src[("group:hive-hive-a",)]["dst"] == ["group:hive-hive-a:*"]
    assert by_src[("group:hive-hive-b",)]["dst"] == ["group:hive-hive-b:*"]
    # No rule allows group:hive-a -> group:hive-b or vice versa (each dst is same-Hive only)
    for a in acls:
        src, dst = a["src"][0], a["dst"][0]
        assert dst.startswith(src), "dst must be same-Hive (src group only)"


def test_generate_acl_policy_skips_missing_headscale_user() -> None:
    """Hives without headscale_user are omitted from groups and acls."""
    hives = [
        {"id": "h1", "headscale_user": "u1"},
        {"id": "h2", "headscale_user": None},
        {"id": "h3"},  # no headscale_user key
    ]
    policy = generate_acl_policy(hives)

    assert list(policy["groups"].keys()) == ["group:hive-h1"]
    assert len(policy["acls"]) == 1


def test_generate_acl_policy_admin_override() -> None:
    """With admin_users, group:admin can reach *:*."""
    hives = [{"id": "h1", "headscale_user": "u1"}]
    policy = generate_acl_policy(hives, admin_users=["admin-user"])

    assert "group:admin" in policy["groups"]
    assert policy["groups"]["group:admin"] == ["admin-user"]
    admin_acl = next(a for a in policy["acls"] if "group:admin" in a["src"])
    assert admin_acl["dst"] == ["*:*"]


async def test_sync_headscale_acls_writes_file(tmp_data_dir: Path) -> None:
    """sync_headscale_acls calls write_acl_policy_file with generated policy when path and client set."""
    mock_hs = AsyncMock()
    mock_hs.write_acl_policy_file = MagicMock()

    registry = tmp_data_dir / "registry"
    registry.mkdir(parents=True)
    # One Hive in registry with headscale_user
    hive_data = {
        "id": "sync-test-hive",
        "name": "Sync Hive",
        "invite_code": "INV12345",
        "owner_id": "owner-1",
        "members": [
            {
                "id": "owner-1",
                "name": "Alice",
                "role": "owner",
                "joined_at": "2026-02-19T00:00:00Z",
                "last_seen": None,
                "devices": [],
                "karma_score": 1.0,
                "compute_contributed_hours": 0.0,
                "compute_consumed_hours": 0.0,
                "active": True,
            }
        ],
        "created_at": "2026-02-19T00:00:00Z",
        "max_members": 10,
        "headscale_user": "sync-hive",
        "headscale_preauth_key": "key-xyz",
    }
    (registry / "INV12345.json").write_text(json.dumps(hive_data), encoding="utf-8")

    mgr = HiveManager(tmp_data_dir, registry_path=registry, headscale=mock_hs)
    await mgr.initialize()
    acl_path = tmp_data_dir / "acls.hujson"
    await mgr.sync_headscale_acls(acl_path)

    mock_hs.write_acl_policy_file.assert_called_once()
    call_args = mock_hs.write_acl_policy_file.call_args
    assert call_args[0][0] == acl_path
    policy = call_args[0][1]
    assert policy["groups"] == {"group:hive-sync-test-hive": ["sync-hive"]}
    assert len(policy["acls"]) == 1
    assert policy["acls"][0]["src"] == ["group:hive-sync-test-hive"] and policy["acls"][0]["dst"] == [
        "group:hive-sync-test-hive:*"
    ]


async def test_sync_headscale_acls_no_op_without_client(tmp_data_dir: Path) -> None:
    """sync_headscale_acls does nothing when Headscale client is not set."""
    mgr = HiveManager(tmp_data_dir)
    await mgr.initialize()
    await mgr.sync_headscale_acls(tmp_data_dir / "acls.hujson")
    # No exception, no file written (we have no client to call)


async def test_sync_headscale_acls_no_op_without_path(tmp_data_dir: Path) -> None:
    """sync_headscale_acls does not call write when acl_file_path is None."""
    mock_hs = AsyncMock()
    mock_hs.write_acl_policy_file = MagicMock()

    mgr = HiveManager(tmp_data_dir, headscale=mock_hs)
    await mgr.initialize()
    await mgr.sync_headscale_acls(None)

    mock_hs.write_acl_policy_file.assert_not_called()
