"""Owner-approved billing delegation: a member with developer access but no billing permission
may ask the owner; an approved member may put billing.read / credits.checkout (never
credits.purchase) on agent connections; it is enforced at consent AND re-checked on every call,
so a revoke, or the approving owner losing ownership, stops it at once."""

from __future__ import annotations

from datetime import timedelta
from types import SimpleNamespace
from unittest.mock import AsyncMock
from uuid import uuid4

import pytest
from src.core.exceptions import ConflictError, ForbiddenError, NotFoundError, ValidationError
from src.domain.agent_access.billing_delegation import (
    REREQUEST_COOLDOWN,
    BillingDelegationService,
)
from src.domain.agent_access.schemas import GrantCreate, WorkspaceAccess
from src.domain.agent_access.service import AgentAccessService, utcnow

WS, OWNER, MEMBER = uuid4(), uuid4(), uuid4()


def perms(*granted):
    async def check(workspace_id, user_id, permission):
        return permission in granted

    return check


DEVELOPER = ("projects.read", "codegen.run", "projects.write")
BILLING_HOLDER = (*DEVELOPER, "billing.manage", "invoices.read")


def row(status="pending", **overrides):
    return SimpleNamespace(
        **{
            "id": uuid4(),
            "workspace_id": WS,
            "member_id": MEMBER,
            "status": status,
            "requested_at": utcnow(),
            "resolved_at": None,
            "resolved_by": None,
            **overrides,
        }
    )


@pytest.fixture
def delegation():
    repo = AsyncMock()
    repo.add.side_effect = lambda r: r
    repo.billing_delegation.return_value = None
    workspaces = AsyncMock()
    workspaces.get_workspace_summary.return_value = SimpleNamespace(
        id=WS, owner_id=OWNER, name="Acme"
    )
    workspaces.check_permission.side_effect = perms(*DEVELOPER)
    users = AsyncMock()
    users.get_by_id.side_effect = lambda uid: SimpleNamespace(
        id=uid, email="owner@acme.test" if uid == OWNER else "sam@acme.test", display_name="Sam"
    )
    email = AsyncMock()
    service = BillingDelegationService(
        repo, workspaces, users, email_provider=email, app_url="https://dash.test"
    )
    service.email = email
    return service


# ---- requesting ---------------------------------------------------------------------------


async def test_a_developer_can_ask_and_the_owner_is_emailed_once(delegation):
    result = await delegation.request(WS, MEMBER)
    assert result["status"] == "pending"
    delegation.email.send.assert_awaited_once()
    to, subject, html, text = delegation.email.send.await_args.args
    assert to == "owner@acme.test"
    assert "https://dash.test/settings/agents" in html and "Sam" in subject


async def test_asking_again_while_pending_is_idempotent_and_does_not_nag(delegation):
    delegation.repo.billing_delegation.return_value = row("pending")
    result = await delegation.request(WS, MEMBER)
    assert result["status"] == "pending"
    delegation.email.send.assert_not_awaited()
    delegation.repo.add.assert_not_awaited()


async def test_someone_who_could_not_share_the_workspace_anyway_cannot_ask(delegation):
    delegation.workspaces.check_permission.side_effect = perms("projects.read")  # a viewer
    with pytest.raises(ForbiddenError, match="Developer access"):
        await delegation.request(WS, MEMBER)
    delegation.repo.add.assert_not_awaited()


async def test_someone_who_already_has_billing_is_told_so(delegation):
    delegation.workspaces.check_permission.side_effect = perms(*BILLING_HOLDER)
    with pytest.raises(ValidationError, match="already have billing access"):
        await delegation.request(WS, MEMBER)


async def test_a_declined_member_must_wait_before_asking_again(delegation):
    just_now = utcnow() - timedelta(hours=1)
    delegation.repo.billing_delegation.return_value = row(
        "declined", resolved_at=just_now, resolved_by=OWNER
    )
    with pytest.raises(ConflictError, match="wait"):
        await delegation.request(WS, MEMBER)
    delegation.email.send.assert_not_awaited()


async def test_after_the_cooldown_a_declined_member_can_ask_again(delegation):
    old = row(
        "declined",
        resolved_at=utcnow() - REREQUEST_COOLDOWN - timedelta(minutes=1),
        resolved_by=OWNER,
    )
    delegation.repo.billing_delegation.return_value = old
    result = await delegation.request(WS, MEMBER)
    assert result["status"] == "pending" and old.resolved_by is None and old.resolved_at is None
    delegation.email.send.assert_awaited_once()


async def test_an_email_failure_never_fails_the_request(delegation):
    delegation.email.send.side_effect = RuntimeError("smtp down")
    result = await delegation.request(WS, MEMBER)
    assert result["status"] == "pending"  # recorded; visible in Settings regardless


# ---- the owner decides ----------------------------------------------------------------------


async def test_only_the_owner_can_review_or_decide(delegation):
    pending = row("pending")
    delegation.repo.billing_delegation_by_id.return_value = pending
    for method, args in (
        (delegation.list_for_owner, (WS, MEMBER)),
        (delegation.resolve, (WS, pending.id, MEMBER, "approve")),
    ):
        with pytest.raises(ForbiddenError):
            await method(*args)
    assert pending.status == "pending"


async def test_the_owner_approves_declines_and_revokes(delegation):
    pending = row("pending")
    delegation.repo.billing_delegation_by_id.return_value = pending
    approved = await delegation.resolve(WS, pending.id, OWNER, "approve")
    assert approved["status"] == "approved"
    assert pending.resolved_by == OWNER and pending.resolved_at
    revoked = await delegation.resolve(WS, pending.id, OWNER, "revoke")
    assert revoked["status"] == "revoked"

    other = row("pending")
    delegation.repo.billing_delegation_by_id.return_value = other
    assert (await delegation.resolve(WS, other.id, OWNER, "decline"))["status"] == "declined"


@pytest.mark.parametrize(
    "status,decision",
    [
        ("approved", "approve"),
        ("declined", "approve"),
        ("revoked", "approve"),
        ("pending", "revoke"),
        ("declined", "decline"),
        ("declined", "revoke"),
    ],
)
async def test_invalid_transitions_are_refused(delegation, status, decision):
    target = row(status)
    delegation.repo.billing_delegation_by_id.return_value = target
    with pytest.raises(ConflictError):
        await delegation.resolve(WS, target.id, OWNER, decision)
    assert target.status == status


async def test_a_member_who_lost_developer_access_cannot_be_approved(delegation):
    delegation.workspaces.check_permission.side_effect = perms("projects.read")
    pending = row("pending")
    delegation.repo.billing_delegation_by_id.return_value = pending
    with pytest.raises(ValidationError, match="no longer has developer access"):
        await delegation.resolve(WS, pending.id, OWNER, "approve")
    assert pending.status == "pending"


async def test_an_unknown_request_is_not_found(delegation):
    delegation.repo.billing_delegation_by_id.return_value = None
    with pytest.raises(NotFoundError):
        await delegation.resolve(WS, uuid4(), OWNER, "approve")


async def test_the_owner_sees_who_asked(delegation):
    delegation.repo.billing_delegations_for_workspace.return_value = [
        (row("pending"), "sam@acme.test", "Sam")
    ]
    [item] = await delegation.list_for_owner(WS, OWNER)
    assert item["member_email"] == "sam@acme.test" and item["status"] == "pending"


# ---- enforcement in agent access ---------------------------------------------------------------


@pytest.fixture
def access():
    repo = AsyncMock()
    repo.add.side_effect = lambda r: r
    workspaces = AsyncMock()
    workspaces.check_permission.side_effect = perms(*DEVELOPER)
    workspaces.get_workspace_summary.return_value = SimpleNamespace(id=WS, owner_id=OWNER, name="A")
    return AgentAccessService(repo, workspaces, AsyncMock(), AsyncMock())


def approved_by(owner=OWNER):
    return row("approved", resolved_by=owner, resolved_at=utcnow())


async def test_an_approved_delegation_lets_a_developer_delegate_the_billing_scopes(access):
    access.repo.billing_delegation.return_value = approved_by()
    assert await access.can_delegate_scope(WS, MEMBER, "billing.read")
    assert await access.can_delegate_scope(WS, MEMBER, "credits.checkout")


async def test_delegation_never_covers_unattended_spending(access):
    access.repo.billing_delegation.return_value = approved_by()
    assert not await access.can_delegate_scope(WS, MEMBER, "credits.purchase")


@pytest.mark.parametrize("status", ["pending", "declined", "revoked"])
async def test_only_an_approved_delegation_counts(access, status):
    access.repo.billing_delegation.return_value = row(status, resolved_by=OWNER)
    assert not await access.can_delegate_scope(WS, MEMBER, "credits.checkout")


async def test_an_approval_stops_standing_when_the_approving_owner_no_longer_owns_it(access):
    access.repo.billing_delegation.return_value = approved_by(owner=uuid4())
    assert not await access.can_delegate_scope(WS, MEMBER, "credits.checkout")


async def test_a_person_with_the_permission_needs_no_delegation(access):
    access.workspaces.check_permission.side_effect = perms(*BILLING_HOLDER)
    assert await access.can_delegate_scope(WS, MEMBER, "credits.checkout")
    access.repo.billing_delegation.assert_not_awaited()


def billing_body():
    return GrantCreate(
        name="Agent",
        workspaces=[WorkspaceAccess(workspace_id=WS, scopes=["projects.read", "credits.checkout"])],
        expires_at=utcnow() + timedelta(days=1),
    )


async def test_consent_accepts_billing_scopes_only_with_a_delegation(access):
    access.repo.billing_delegation.return_value = None
    with pytest.raises(ForbiddenError, match="Cannot delegate"):
        await access.create_grant(billing_body(), MEMBER, client_id="client")
    access.repo.add.assert_not_awaited()

    access.repo.billing_delegation.return_value = approved_by()
    grant = await access.create_grant(billing_body(), MEMBER, client_id="client")
    assert "credits.checkout" in grant.scopes


async def test_a_revoked_delegation_stops_an_existing_connection_on_the_next_call(access):
    """Enforcement is per call, not just at consent."""
    delegation = approved_by()
    access.repo.billing_delegation.return_value = delegation
    grant = SimpleNamespace(
        id=uuid4(),
        created_by=MEMBER,
        scopes=["credits.checkout"],
        revoked_at=None,
        expires_at=utcnow() + timedelta(days=1),
    )
    access.repo.grant_workspaces.return_value = [
        SimpleNamespace(
            workspace_id=WS,
            scopes=["credits.checkout"],
            workspace_wide=True,
            project_ids=[],
            purchase_policy={},
        )
    ]
    assert await access.require_scope(grant, "credits.checkout") == WS

    delegation.status = "revoked"  # the owner revokes
    with pytest.raises(ForbiddenError, match="no longer permit"):
        await access.require_scope(grant, "credits.checkout")


async def test_workspace_options_report_the_request_state_and_unlock_billing_when_approved(access):
    access.workspaces.list_workspace_summaries_for_user.return_value = [
        SimpleNamespace(id=WS, name="Acme", owner_id=OWNER)
    ]
    cases = [
        (None, "none", False),
        (row("pending"), "pending", False),
        (row("declined", resolved_by=OWNER), "declined", False),
        (approved_by(), "approved", True),
        (approved_by(owner=uuid4()), "none", False),  # stale approval
    ]
    for delegation_row, expected, unlocked in cases:
        access.repo.billing_delegation.return_value = delegation_row
        [option] = await access.workspace_options(MEMBER)
        assert option["billing_delegation"] == expected, delegation_row
        assert option["billing_unlocked"] is unlocked
        assert option["can_manage_billing"] is False
        assert ("credits.checkout" in option["scopes"]) is unlocked
        assert "credits.purchase" not in option["scopes"]  # owner-only, always
