"""Tests for RBAC and Team Management (F-024).

Covers: TeamManager, Role, TeamMember, Team, SpendingAlert, AccessDecision,
model allowlists, budgets, spending alerts, persistence.
"""

from __future__ import annotations

from typing import TYPE_CHECKING

import pytest

from llmhosts.rbac.manager import TeamManager, _hash_api_key, _model_matches_allowlist
from llmhosts.rbac.models import AccessDecision, Role

if TYPE_CHECKING:
    from pathlib import Path


# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------


@pytest.fixture()
def rbac_dir(tmp_path: Path) -> Path:
    """Temporary directory for team JSON storage."""
    d = tmp_path / ".llmhosts"
    d.mkdir()
    return d


@pytest.fixture()
def manager(rbac_dir: Path) -> TeamManager:
    """TeamManager pointing at a temporary directory."""
    return TeamManager(rbac_dir)


# ---------------------------------------------------------------------------
# TeamManager: create_team
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
@pytest.mark.unit
async def test_create_team(manager: TeamManager) -> None:
    """Creating a team should add owner as admin."""
    await manager.initialize()
    team = await manager.create_team("Acme Corp", "owner@acme.com")

    assert team.id
    assert team.name == "Acme Corp"
    assert team.owner_id
    assert len(team.members) == 1
    owner = team.members[0]
    assert owner.email == "owner@acme.com"
    assert owner.role == Role.ADMIN
    assert owner.model_allowlist == []
    assert owner.monthly_budget is None
    assert owner.current_spend == 0.0


# ---------------------------------------------------------------------------
# TeamManager: add_member
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
@pytest.mark.unit
async def test_add_member(manager: TeamManager) -> None:
    """Adding a member should append to team with default role."""
    await manager.initialize()
    await manager.create_team("Test Team", "owner@test.com")
    member = await manager.add_member("dev@test.com", "Jane Dev", Role.DEVELOPER)

    assert member.email == "dev@test.com"
    assert member.name == "Jane Dev"
    assert member.role == Role.DEVELOPER
    assert member.model_allowlist == []
    assert member.monthly_budget is None
    assert member.current_spend == 0.0

    members = await manager.list_members()
    assert len(members) == 2  # owner + dev


@pytest.mark.asyncio
@pytest.mark.unit
async def test_add_member_duplicate_email(manager: TeamManager) -> None:
    """Adding a member with duplicate email should raise ValueError."""
    await manager.initialize()
    await manager.create_team("Test Team", "owner@test.com")
    await manager.add_member("dev@test.com", "Jane", Role.DEVELOPER)

    with pytest.raises(ValueError, match="already exists"):
        await manager.add_member("dev@test.com", "Jane Other", Role.DEVELOPER)


# ---------------------------------------------------------------------------
# TeamManager: remove_member
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
@pytest.mark.unit
async def test_remove_member(manager: TeamManager) -> None:
    """Removing a member should remove them from the team."""
    await manager.initialize()
    await manager.create_team("Test Team", "owner@test.com")
    member = await manager.add_member("dev@test.com", "Jane", Role.DEVELOPER)
    member_id = member.id

    removed = await manager.remove_member(member_id)
    assert removed is True

    members = await manager.list_members()
    assert len(members) == 1
    assert members[0].email == "owner@test.com"


@pytest.mark.asyncio
@pytest.mark.unit
async def test_remove_member_by_email(manager: TeamManager) -> None:
    """Removing by email should work."""
    await manager.initialize()
    await manager.create_team("Test Team", "owner@test.com")
    await manager.add_member("dev@test.com", "Jane", Role.DEVELOPER)

    removed = await manager.remove_member("dev@test.com")
    assert removed is True


@pytest.mark.asyncio
@pytest.mark.unit
async def test_remove_owner_fails(manager: TeamManager) -> None:
    """Cannot remove the team owner."""
    await manager.initialize()
    team = await manager.create_team("Test Team", "owner@test.com")
    await manager.add_member("dev@test.com", "Jane", Role.DEVELOPER)

    with pytest.raises(ValueError, match="Cannot remove the team owner"):
        await manager.remove_member(team.owner_id)


@pytest.mark.asyncio
@pytest.mark.unit
async def test_remove_nonexistent_member(manager: TeamManager) -> None:
    """Removing nonexistent member should return False."""
    await manager.initialize()
    await manager.create_team("Test Team", "owner@test.com")
    removed = await manager.remove_member("nobody@test.com")
    assert removed is False


# ---------------------------------------------------------------------------
# TeamManager: update_role
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
@pytest.mark.unit
async def test_update_role(manager: TeamManager) -> None:
    """Updating role should change the member's role."""
    await manager.initialize()
    await manager.create_team("Test Team", "owner@test.com")
    member = await manager.add_member("dev@test.com", "Jane", Role.DEVELOPER)
    assert member.role == Role.DEVELOPER

    updated = await manager.update_role(member.id, Role.ADMIN)
    assert updated is not None
    assert updated.role == Role.ADMIN

    members = await manager.list_members()
    dev = next(m for m in members if m.email == "dev@test.com")
    assert dev.role == Role.ADMIN


# ---------------------------------------------------------------------------
# TeamManager: set_model_allowlist
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
@pytest.mark.unit
async def test_set_model_allowlist(manager: TeamManager) -> None:
    """Setting model allowlist should restrict model access."""
    await manager.initialize()
    await manager.create_team("Test Team", "owner@test.com")
    member = await manager.add_member("dev@test.com", "Jane", Role.DEVELOPER)

    updated = await manager.set_model_allowlist(member.id, ["llama*", "gpt-4*"])
    assert updated is not None
    assert updated.model_allowlist == ["llama*", "gpt-4*"]


# ---------------------------------------------------------------------------
# TeamManager: set_budget
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
@pytest.mark.unit
async def test_set_budget(manager: TeamManager) -> None:
    """Setting budget should cap spending."""
    await manager.initialize()
    await manager.create_team("Test Team", "owner@test.com")
    member = await manager.add_member("dev@test.com", "Jane", Role.DEVELOPER)

    updated = await manager.set_budget(member.id, 100.0)
    assert updated is not None
    assert updated.monthly_budget == 100.0


# ---------------------------------------------------------------------------
# TeamManager: check_access
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
@pytest.mark.unit
async def test_check_access_allowed(manager: TeamManager) -> None:
    """Admin/Developer with empty allowlist and under budget should be allowed."""
    await manager.initialize()
    await manager.create_team("Test Team", "owner@test.com")
    member = await manager.add_member("dev@test.com", "Jane", Role.DEVELOPER)

    decision = await manager.check_access(member.id, "llama3.2")
    assert decision.allowed is True
    assert "granted" in decision.reason.lower()
    assert decision.role == Role.DEVELOPER
    assert decision.model_requested == "llama3.2"


@pytest.mark.asyncio
@pytest.mark.unit
async def test_check_access_denied_viewer(manager: TeamManager) -> None:
    """Viewer role should be denied model access."""
    await manager.initialize()
    await manager.create_team("Test Team", "owner@test.com")
    member = await manager.add_member("viewer@test.com", "Bob", Role.VIEWER)

    decision = await manager.check_access(member.id, "llama3.2")
    assert decision.allowed is False
    assert "viewer" in decision.reason.lower()
    assert decision.role == Role.VIEWER


@pytest.mark.asyncio
@pytest.mark.unit
async def test_check_access_denied_model_not_allowed(manager: TeamManager) -> None:
    """Model not in allowlist should be denied."""
    await manager.initialize()
    await manager.create_team("Test Team", "owner@test.com")
    member = await manager.add_member("dev@test.com", "Jane", Role.DEVELOPER)
    await manager.set_model_allowlist(member.id, ["llama*"])  # Only llama models

    decision = await manager.check_access(member.id, "gpt-4")
    assert decision.allowed is False
    assert "allowlist" in decision.reason.lower()


@pytest.mark.asyncio
@pytest.mark.unit
async def test_check_access_denied_budget_exceeded(manager: TeamManager) -> None:
    """Budget exceeded should deny access."""
    await manager.initialize()
    await manager.create_team("Test Team", "owner@test.com")
    member = await manager.add_member("dev@test.com", "Jane", Role.DEVELOPER)
    await manager.set_budget(member.id, 10.0)
    await manager.record_spend(member.id, 15.0)  # Over budget

    decision = await manager.check_access(member.id, "llama3.2")
    assert decision.allowed is False
    assert "budget" in decision.reason.lower()
    assert decision.budget_remaining == 0.0


@pytest.mark.asyncio
@pytest.mark.unit
async def test_check_access_allowlist_pattern(manager: TeamManager) -> None:
    """Model allowlist patterns (llama*, gpt-4*) should match correctly."""
    await manager.initialize()
    await manager.create_team("Test Team", "owner@test.com")
    member = await manager.add_member("dev@test.com", "Jane", Role.DEVELOPER)
    await manager.set_model_allowlist(member.id, ["llama*", "gpt-4*"])

    d1 = await manager.check_access(member.id, "llama3.2")
    assert d1.allowed is True
    d2 = await manager.check_access(member.id, "llama3.2:1b")
    assert d2.allowed is True
    d3 = await manager.check_access(member.id, "gpt-4-turbo")
    assert d3.allowed is True
    d4 = await manager.check_access(member.id, "mistral-7b")
    assert d4.allowed is False


# ---------------------------------------------------------------------------
# TeamManager: record_spend
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
@pytest.mark.unit
async def test_record_spend(manager: TeamManager) -> None:
    """Recording spend should update member's current_spend."""
    await manager.initialize()
    await manager.create_team("Test Team", "owner@test.com")
    member = await manager.add_member("dev@test.com", "Jane", Role.DEVELOPER)

    await manager.record_spend(member.id, 5.50)
    await manager.record_spend(member.id, 2.25)

    members = await manager.list_members()
    dev = next(m for m in members if m.email == "dev@test.com")
    assert dev.current_spend == 7.75


# ---------------------------------------------------------------------------
# TeamManager: spending alerts
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
@pytest.mark.unit
async def test_spending_alert_triggered(manager: TeamManager) -> None:
    """Spending over threshold should trigger alert."""
    await manager.initialize()
    await manager.create_team("Test Team", "owner@test.com")
    member = await manager.add_member("dev@test.com", "Jane", Role.DEVELOPER)
    await manager.add_alert(threshold=10.0, member_id=member.id, period="month")

    await manager.record_spend(member.id, 15.0)

    alerts = await manager.get_alerts()
    assert len(alerts) == 1
    assert alerts[0].triggered is True
    assert alerts[0].triggered_at is not None
    assert alerts[0].threshold == 10.0


# ---------------------------------------------------------------------------
# TeamManager: persistence
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
@pytest.mark.unit
async def test_team_persistence(rbac_dir: Path) -> None:
    """Team data should survive save/load round-trip."""
    mgr1 = TeamManager(rbac_dir)
    await mgr1.initialize()
    await mgr1.create_team("Persistent Team", "owner@persist.com")
    member = await mgr1.add_member("dev@persist.com", "Jane", Role.DEVELOPER)
    await mgr1.set_model_allowlist(member.id, ["llama*"])
    await mgr1.set_budget(member.id, 50.0)

    # Load in fresh manager
    mgr2 = TeamManager(rbac_dir)
    await mgr2.initialize()

    assert mgr2.has_team() is True
    team = mgr2._get_team()
    assert team.name == "Persistent Team"
    assert len(team.members) == 2
    dev = next(m for m in team.members if m.email == "dev@persist.com")
    assert dev.model_allowlist == ["llama*"]
    assert dev.monthly_budget == 50.0


# ---------------------------------------------------------------------------
# Model: Role enum
# ---------------------------------------------------------------------------


@pytest.mark.unit
def test_role_enum_values() -> None:
    """Role enum should have correct string values."""
    assert Role.ADMIN.value == "admin"
    assert Role.DEVELOPER.value == "developer"
    assert Role.VIEWER.value == "viewer"


# ---------------------------------------------------------------------------
# Model: AccessDecision
# ---------------------------------------------------------------------------


@pytest.mark.unit
def test_access_decision_model() -> None:
    """AccessDecision should serialize correctly."""
    dec = AccessDecision(
        allowed=False,
        reason="Budget exceeded",
        role=Role.DEVELOPER,
        model_requested="gpt-4",
        budget_remaining=0.0,
    )
    data = dec.model_dump()
    assert data["allowed"] is False
    assert data["reason"] == "Budget exceeded"
    assert data["role"] == "developer"
    assert data["model_requested"] == "gpt-4"
    assert data["budget_remaining"] == 0.0


# ---------------------------------------------------------------------------
# Helpers: _model_matches_allowlist, _hash_api_key
# ---------------------------------------------------------------------------


@pytest.mark.unit
def test_model_matches_allowlist_empty() -> None:
    """Empty allowlist = allow all."""
    assert _model_matches_allowlist("llama3.2", []) is True
    assert _model_matches_allowlist("gpt-4", []) is True


@pytest.mark.unit
def test_model_matches_allowlist_patterns() -> None:
    """Allowlist patterns should match via fnmatch."""
    assert _model_matches_allowlist("llama3.2", ["llama*"]) is True
    assert _model_matches_allowlist("llama3.2:1b", ["llama*"]) is True
    assert _model_matches_allowlist("gpt-4-turbo", ["gpt-4*"]) is True
    assert _model_matches_allowlist("mistral-7b", ["llama*", "gpt-4*"]) is False


@pytest.mark.unit
def test_hash_api_key_deterministic() -> None:
    """API key hash should be deterministic for same team+key."""
    h1 = _hash_api_key("team-1", "sk-abc123")
    h2 = _hash_api_key("team-1", "sk-abc123")
    assert h1 == h2


@pytest.mark.unit
def test_hash_api_key_different_per_team() -> None:
    """Same key in different teams should produce different hashes."""
    h1 = _hash_api_key("team-1", "sk-abc123")
    h2 = _hash_api_key("team-2", "sk-abc123")
    assert h1 != h2


# ---------------------------------------------------------------------------
# RBACMiddleware
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
@pytest.mark.unit
async def test_middleware_no_team_allows(rbac_dir: Path) -> None:
    """When no team exists, middleware should allow all requests."""
    from llmhosts.rbac.middleware import RBACMiddleware

    mgr = TeamManager(rbac_dir)
    await mgr.initialize()
    middleware = RBACMiddleware(mgr)

    decision = await middleware.check_request("any-key", "llama3.2")
    assert decision.allowed is True
    assert "RBAC disabled" in decision.reason
