"""Tests for auth signup, bootstrap, billing access, invite limits."""
from __future__ import annotations

from datetime import datetime, timezone
from types import SimpleNamespace
from typing import Optional
from unittest.mock import AsyncMock, MagicMock
from uuid import uuid4

import pytest

from src.domain.auth.exceptions import SignupRequiredError
from src.domain.auth.service import AuthService
from src.domain.auth.signup_service import SignupService
from src.domain.auth.repository import MagicLinkRepository
from src.domain.auth.bootstrap_service import AuthBootstrapService
from src.domain.auth.totp_service import TotpService
from src.domain.billing.normalization import is_paid_billing_status, requires_payment_flag
from src.domain.billing.access_policy import BillingAccessPolicy, workspace_has_active_billing
from src.core.exceptions import PaymentRequiredError, PlanLimitError


# --- Billing helpers ---
def test_workspace_has_active_billing():
    assert workspace_has_active_billing("active") is True
    assert workspace_has_active_billing("trialing") is True
    assert workspace_has_active_billing("past_due") is False
    assert workspace_has_active_billing("none") is False
    assert workspace_has_active_billing(None) is False


def test_requires_payment():
    assert requires_payment_flag("past_due", None) is True
    assert requires_payment_flag("incomplete", None) is True
    assert requires_payment_flag("canceled", None) is True
    assert requires_payment_flag("none", None) is True
    assert requires_payment_flag("none", "pro") is True
    assert requires_payment_flag("none", "free") is False  # Free plan bypass
    assert requires_payment_flag("active", None) is False
    assert requires_payment_flag("trialing", None) is False


def test_is_paid_active():
    assert is_paid_billing_status("active") is True
    assert is_paid_billing_status("trialing") is True
    assert is_paid_billing_status("past_due") is False
    assert is_paid_billing_status("none") is False


@pytest.mark.asyncio
async def test_magic_link_lookup_does_not_reject_a_previously_used_link():
    db = AsyncMock()
    db.execute = AsyncMock(return_value=MagicMock())
    repo = MagicLinkRepository(db)

    await repo.get_valid_token("token-digest")

    statement = db.execute.await_args.args[0]
    criteria = " ".join(str(item) for item in statement._where_criteria).lower()
    assert "used_at" not in criteria
    assert "expires_at" in criteria


@pytest.mark.asyncio
async def test_unknown_login_email_requires_signup_without_sending_email(monkeypatch):
    user_repo = AsyncMock()
    user_repo.get_by_email = AsyncMock(return_value=None)

    pending_repo = AsyncMock()
    pending_repo.get_valid_by_email = AsyncMock(return_value=None)

    magic_link_repo = AsyncMock()
    email_provider = AsyncMock()

    monkeypatch.setattr("src.domain.auth.service.settings.home_url", "https://p2hs.com")

    svc = AuthService(
        user_repo=user_repo,
        session_repo=AsyncMock(),
        magic_link_repo=magic_link_repo,
        email_provider=email_provider,
        pending_repo=pending_repo,
    )

    with pytest.raises(SignupRequiredError) as exc_info:
        await svc.request_magic_link("newperson@example.com")

    assert exc_info.value.signup_url == "https://p2hs.com/signup?email=newperson%40example.com"
    magic_link_repo.create.assert_not_called()
    email_provider.send.assert_not_called()


@pytest.mark.asyncio
async def test_totp_setup_uses_customer_email_in_authenticator_label(monkeypatch):
    repo = AsyncMock()
    repo.get_by_user = AsyncMock(return_value=None)
    repo.create = AsyncMock()
    monkeypatch.setattr("src.domain.auth.totp_service.encrypt_value", lambda value: value)

    data = await TotpService(repo).setup(uuid4(), account_name="customer@example.com")

    assert "customer%40example.com" in data["provisioning_uri"]
    assert "otpauth://totp/" in data["provisioning_uri"]


def _billing_policy(sub_repo: AsyncMock, ws_slug: Optional[str] = "pro"):
    ws_repo = AsyncMock()
    ws_repo.get_by_id = AsyncMock(return_value=MagicMock(selected_plan_slug=ws_slug))
    return BillingAccessPolicy(sub_repo, ws_repo), ws_repo


# --- BillingAccessPolicy ---
@pytest.mark.asyncio
async def test_require_active_billing_raises_when_none():
    sub_repo = AsyncMock()
    sub_repo.get_latest_for_workspace = AsyncMock(return_value=None)
    policy, _ = _billing_policy(sub_repo, ws_slug="pro")
    ws_id = uuid4()
    with pytest.raises(PaymentRequiredError) as exc_info:
        await policy.require_active_billing(ws_id)
    assert exc_info.value.workspace_id == str(ws_id)
    assert exc_info.value.billing_status == "none"


@pytest.mark.asyncio
async def test_require_active_billing_raises_when_past_due():
    sub_repo = AsyncMock()
    sub_repo.get_latest_for_workspace = AsyncMock(return_value=MagicMock(status="past_due"))
    policy, _ = _billing_policy(sub_repo, ws_slug="pro")
    ws_id = uuid4()
    with pytest.raises(PaymentRequiredError) as exc_info:
        await policy.require_active_billing(ws_id)
    assert exc_info.value.billing_status == "past_due"


@pytest.mark.asyncio
async def test_require_active_billing_passes_when_active():
    sub_repo = AsyncMock()
    sub_repo.get_latest_for_workspace = AsyncMock(return_value=MagicMock(status="active"))
    policy, _ = _billing_policy(sub_repo, ws_slug="pro")
    ws_id = uuid4()
    await policy.require_active_billing(ws_id)
    sub_repo.get_latest_for_workspace.assert_called_once_with(ws_id)


@pytest.mark.asyncio
async def test_require_active_billing_passes_free_tier_no_subscription():
    sub_repo = AsyncMock()
    sub_repo.get_latest_for_workspace = AsyncMock(return_value=None)
    policy, _ = _billing_policy(sub_repo, ws_slug="free")
    ws_id = uuid4()
    await policy.require_active_billing(ws_id)


# --- SignupService ---
@pytest.mark.asyncio
async def test_signup_new_user_creates_pending_and_sends_magic_link():
    """New user: create pending_registrations (no user until verify), send magic link."""
    user_repo = AsyncMock()
    user_repo.get_by_email = AsyncMock(return_value=None)

    pending_repo = AsyncMock()
    pending_repo.get_by_email = AsyncMock(return_value=None)
    pending_repo.create_or_update = AsyncMock(return_value=MagicMock(id=uuid4(), email="new@example.com"))

    invite_repo = AsyncMock()
    invite_repo.has_pending_invite_for_email = AsyncMock(return_value=False)

    auth_service = AsyncMock()
    auth_service.request_magic_link = AsyncMock(return_value="token123")

    svc = SignupService(
        user_repo=user_repo,
        pending_repo=pending_repo,
        invite_repo=invite_repo,
        auth_service=auth_service,
    )
    result = await svc.signup("new@example.com", first_name="John", workspace_name="Acme")
    assert result["ok"] is True
    assert result["user_exists"] is False
    assert result["next_step"] == "verify_email"
    pending_repo.create_or_update.assert_called_once()
    user_repo.create.assert_not_called()
    auth_service.request_magic_link.assert_called_once_with(
        "new@example.com", ip_address=None, app_base_url=None, is_signup=True
    )


@pytest.mark.asyncio
async def test_signup_existing_email_idempotent():
    """Existing verified user: update profile, send magic link (login flow)."""
    existing_user = MagicMock(id=uuid4(), email="existing@example.com", first_name=None, last_name=None)
    user_repo = AsyncMock()
    user_repo.get_by_email = AsyncMock(return_value=existing_user)
    user_repo.update_profile = AsyncMock()

    pending_repo = AsyncMock()
    invite_repo = AsyncMock()
    auth_service = AsyncMock()
    auth_service.request_magic_link = AsyncMock(return_value="token456")

    svc = SignupService(
        user_repo=user_repo,
        pending_repo=pending_repo,
        invite_repo=invite_repo,
        auth_service=auth_service,
    )
    result = await svc.signup("existing@example.com", first_name="Jane")
    assert result["ok"] is True
    assert result["user_exists"] is True
    assert result["next_step"] == "verify_email"
    user_repo.create.assert_not_called()
    user_repo.update_profile.assert_called_once()
    auth_service.request_magic_link.assert_called_once()


@pytest.mark.asyncio
async def test_signup_invite_first_no_workspace_created():
    """Invited user: create pending (no workspace until verify+promote with invite-first logic)."""
    user_repo = AsyncMock()
    user_repo.get_by_email = AsyncMock(return_value=None)

    pending_repo = AsyncMock()
    pending_repo.get_by_email = AsyncMock(return_value=None)
    pending_repo.create_or_update = AsyncMock(return_value=MagicMock(id=uuid4(), email="invited@example.com"))

    invite_repo = AsyncMock()
    invite_repo.has_pending_invite_for_email = AsyncMock(return_value=True)  # invite-first
    auth_service = AsyncMock()
    auth_service.request_magic_link = AsyncMock(return_value="token789")

    svc = SignupService(
        user_repo=user_repo,
        pending_repo=pending_repo,
        invite_repo=invite_repo,
        auth_service=auth_service,
    )
    result = await svc.signup("invited@example.com")
    assert result["ok"] is True
    assert result["workspace_id"] is None
    pending_repo.create_or_update.assert_called_once()
    user_repo.create.assert_not_called()


@pytest.mark.asyncio
async def test_staff_domain_signup_bypasses_payment_and_uses_confirmation_email(monkeypatch):
    monkeypatch.setattr(
        "src.domain.auth.signup_service.get_settings",
        lambda: SimpleNamespace(staff_email_domain_set={"p2hs.internal"}),
    )
    user_repo = AsyncMock()
    user_repo.get_by_email = AsyncMock(return_value=None)
    pending_repo = AsyncMock()
    invite_repo = AsyncMock()
    invite_repo.has_pending_invite_for_email = AsyncMock(return_value=False)
    auth_service = AsyncMock()

    svc = SignupService(user_repo, pending_repo, invite_repo, auth_service)
    result = await svc.signup("staff@p2hs.internal", plan_slug="pro_monthly")

    assert result["requires_payment"] is False
    assert result["requires_plan_selection"] is False
    assert result["onboarding_reason"] == "staff_domain"
    assert pending_repo.create_or_update.call_args.kwargs["plan_slug"] == "staff"
    auth_service.request_magic_link.assert_awaited_once()


@pytest.mark.asyncio
async def test_invited_signup_bypasses_payment_even_when_paid_plan_is_sent(monkeypatch):
    monkeypatch.setattr(
        "src.domain.auth.signup_service.get_settings",
        lambda: SimpleNamespace(staff_email_domain_set=set()),
    )
    user_repo = AsyncMock()
    user_repo.get_by_email = AsyncMock(return_value=None)
    pending_repo = AsyncMock()
    invite_repo = AsyncMock()
    invite_repo.has_pending_invite_for_email = AsyncMock(return_value=True)
    auth_service = AsyncMock()

    svc = SignupService(user_repo, pending_repo, invite_repo, auth_service)
    result = await svc.signup("invitee@example.com", plan_slug="pro_monthly")

    assert result["requires_payment"] is False
    assert result["requires_plan_selection"] is False
    assert result["onboarding_reason"] == "workspace_invitation"
    assert pending_repo.create_or_update.call_args.kwargs["plan_slug"] is None


# --- Signup plan_slug contract ---
@pytest.mark.asyncio
async def test_signup_stores_plan_slug():
    """Signup with plan_slug stores plan_slug in pending (workspace gets it on promote)."""
    user_repo = AsyncMock()
    user_repo.get_by_email = AsyncMock(return_value=None)

    pending_repo = AsyncMock()
    pending_repo.get_by_email = AsyncMock(return_value=None)
    pending_repo.create_or_update = AsyncMock(return_value=MagicMock(id=uuid4(), email="plan@example.com", plan_slug="pro_monthly"))

    invite_repo = AsyncMock()
    invite_repo.has_pending_invite_for_email = AsyncMock(return_value=False)
    auth_service = AsyncMock()
    auth_service.request_magic_link = AsyncMock(return_value="token")

    svc = SignupService(
        user_repo=user_repo,
        pending_repo=pending_repo,
        invite_repo=invite_repo,
        auth_service=auth_service,
    )
    await svc.signup("plan@example.com", workspace_name="Test", plan_slug="pro_monthly")
    pending_repo.create_or_update.assert_called_once()
    call_kwargs = pending_repo.create_or_update.call_args[1]
    assert call_kwargs["plan_slug"] == "pro_monthly"


# --- AuthBootstrapService /auth/me contract ---
@pytest.mark.asyncio
async def test_bootstrap_mfa_setup_precedence_over_billing():
    """When any paid-active workspace requires MFA setup, routing.next_step is mfa_setup, not billing_checkout."""
    user_id = uuid4()
    user = MagicMock(id=user_id, email="u@example.com", email_verified_at=datetime.now(timezone.utc))
    user.display_name = "User"
    user.first_name = None
    user.last_name = None
    user.phone = None
    user.company = None

    ws_paid = MagicMock(
        id=uuid4(),
        name="Paid",
        owner_id=user_id,
        deleted_at=None,
        security_policy_require_mfa_for_paid_users=True,
        selected_plan_slug="pro",
    )
    ws_paid.created_at = datetime(2024, 1, 1, tzinfo=timezone.utc)

    ws_unpaid = MagicMock(
        id=uuid4(),
        name="Unpaid",
        owner_id=user_id,
        deleted_at=None,
        security_policy_require_mfa_for_paid_users=True,
        selected_plan_slug="pro",
    )
    ws_unpaid.created_at = datetime(2024, 1, 2, tzinfo=timezone.utc)

    workspace_repo = AsyncMock()
    workspace_repo.list_for_user = AsyncMock(return_value=[ws_paid, ws_unpaid])

    membership_repo = AsyncMock()
    membership_repo.get = AsyncMock(return_value=MagicMock(role_id=uuid4()))

    role_repo = AsyncMock()
    role_repo.get_by_id = AsyncMock(return_value=MagicMock(name="owner"))

    sub_paid = MagicMock(id=uuid4(), status="active", plan_id="price_xxx")

    def get_sub(ws_id):
        return sub_paid if ws_id == ws_paid.id else None

    subscription_repo = AsyncMock()
    subscription_repo.get_latest_for_workspace = AsyncMock(side_effect=get_sub)

    totp_repo = AsyncMock()
    totp_repo.get_by_user = AsyncMock(return_value=None)
    webauthn_repo = AsyncMock()
    webauthn_repo.list_for_user = AsyncMock(return_value=[])
    admin_repo = AsyncMock()
    admin_repo.is_admin = AsyncMock(return_value=False)

    invite_repo = AsyncMock()
    invite_repo.list_pending_invites_for_email = AsyncMock(return_value=[])

    svc = AuthBootstrapService(
        workspace_repo=workspace_repo,
        membership_repo=membership_repo,
        role_repo=role_repo,
        subscription_repo=subscription_repo,
        totp_repo=totp_repo,
        webauthn_repo=webauthn_repo,
        admin_repo=admin_repo,
        invite_repo=invite_repo,
    )
    payload = await svc.build_bootstrap(user)
    assert payload["session"]["requires_mfa_setup"] is True
    assert payload["routing"]["next_step"] == "setup_mfa"
    assert payload["routing"]["next_step_alias"] == "mfa_setup"


@pytest.mark.asyncio
async def test_bootstrap_invite_first_routes_accept_invite():
    """No workspaces but pending invite: expose pending_invites and route to accept_invite."""
    user_id = uuid4()
    ws_id = uuid4()
    inv_id = uuid4()
    user = MagicMock(id=user_id, email="invitee@example.com", email_verified_at=datetime.now(timezone.utc))
    user.display_name = "Inv"
    user.first_name = None
    user.last_name = None
    user.phone = None
    user.company = None

    workspace_repo = AsyncMock()
    workspace_repo.list_for_user = AsyncMock(return_value=[])

    inv = MagicMock(
        id=inv_id,
        email="invitee@example.com",
        expires_at=datetime(2030, 1, 1, tzinfo=timezone.utc),
    )
    ws = SimpleNamespace(id=ws_id, name="Acme", deleted_at=None)
    role = SimpleNamespace(name="editor")
    invite_repo = AsyncMock()
    invite_repo.list_pending_invites_for_email = AsyncMock(return_value=[(inv, ws, role)])

    membership_repo = AsyncMock()
    role_repo = AsyncMock()
    subscription_repo = AsyncMock()
    totp_repo = AsyncMock()
    totp_repo.get_by_user = AsyncMock(return_value=None)
    webauthn_repo = AsyncMock()
    webauthn_repo.list_for_user = AsyncMock(return_value=[])
    admin_repo = AsyncMock()
    admin_repo.is_admin = AsyncMock(return_value=False)

    svc = AuthBootstrapService(
        workspace_repo=workspace_repo,
        membership_repo=membership_repo,
        role_repo=role_repo,
        subscription_repo=subscription_repo,
        totp_repo=totp_repo,
        webauthn_repo=webauthn_repo,
        admin_repo=admin_repo,
        invite_repo=invite_repo,
    )
    payload = await svc.build_bootstrap(user)
    assert payload["workspaces"] == []
    assert len(payload["pending_invites"]) == 1
    pi = payload["pending_invites"][0]
    assert pi["workspace_id"] == str(ws_id)
    assert pi["workspace_name"] == "Acme"
    assert pi["role_name"] == "editor"
    assert payload["routing"]["next_step"] == "accept_invite"
    assert payload["routing"]["next_path"] == "/invite"
    assert payload["routing"]["reason"] == "pending_workspace_invites"


@pytest.mark.asyncio
async def test_bootstrap_unpaid_workspace_access_metadata():
    """Unpaid workspace: billing.requires_payment=true, access.allowed=false, access.reason=billing_required."""
    user_id = uuid4()
    user = MagicMock(id=user_id, email="u@example.com", email_verified_at=datetime.now(timezone.utc))
    user.display_name = "User"
    user.first_name = None
    user.last_name = None
    user.phone = None
    user.company = None

    ws = MagicMock(
        id=uuid4(),
        name="Unpaid",
        owner_id=user_id,
        deleted_at=None,
        security_policy_require_mfa_for_paid_users=True,
        selected_plan_slug="pro",
    )
    ws.created_at = datetime(2024, 1, 1, tzinfo=timezone.utc)

    workspace_repo = AsyncMock()
    workspace_repo.list_for_user = AsyncMock(return_value=[ws])
    membership_repo = AsyncMock()
    membership_repo.get = AsyncMock(return_value=MagicMock(role_id=uuid4()))
    role_repo = AsyncMock()
    role_repo.get_by_id = AsyncMock(return_value=MagicMock(name="owner"))
    subscription_repo = AsyncMock()
    subscription_repo.get_latest_for_workspace = AsyncMock(return_value=None)
    totp_repo = AsyncMock()
    totp_repo.get_by_user = AsyncMock(return_value=None)
    webauthn_repo = AsyncMock()
    webauthn_repo.list_for_user = AsyncMock(return_value=[])
    admin_repo = AsyncMock()
    admin_repo.is_admin = AsyncMock(return_value=False)

    invite_repo = AsyncMock()
    invite_repo.list_pending_invites_for_email = AsyncMock(return_value=[])

    svc = AuthBootstrapService(
        workspace_repo=workspace_repo,
        membership_repo=membership_repo,
        role_repo=role_repo,
        subscription_repo=subscription_repo,
        totp_repo=totp_repo,
        webauthn_repo=webauthn_repo,
        admin_repo=admin_repo,
        invite_repo=invite_repo,
    )
    payload = await svc.build_bootstrap(user)
    assert len(payload["workspaces"]) == 1
    w = payload["workspaces"][0]
    assert w["billing"]["requires_payment"] is True
    assert w["access"]["allowed"] is False
    assert w["access"]["reason"] == "billing_required"


@pytest.mark.asyncio
async def test_bootstrap_paid_workspace_mfa_required_access_reason():
    """Paid-active workspace with MFA required but user not enrolled: access.reason=mfa_required."""
    user_id = uuid4()
    user = MagicMock(id=user_id, email="u@example.com", email_verified_at=datetime.now(timezone.utc))
    user.display_name = "User"
    user.first_name = None
    user.last_name = None
    user.phone = None
    user.company = None

    ws = MagicMock(
        id=uuid4(),
        name="Paid",
        owner_id=user_id,
        deleted_at=None,
        security_policy_require_mfa_for_paid_users=True,
        selected_plan_slug="pro",
    )
    ws.created_at = datetime(2024, 1, 1, tzinfo=timezone.utc)

    workspace_repo = AsyncMock()
    workspace_repo.list_for_user = AsyncMock(return_value=[ws])
    membership_repo = AsyncMock()
    membership_repo.get = AsyncMock(return_value=MagicMock(role_id=uuid4()))
    role_repo = AsyncMock()
    role_repo.get_by_id = AsyncMock(return_value=MagicMock(name="owner"))
    subscription_repo = AsyncMock()
    subscription_repo.get_latest_for_workspace = AsyncMock(return_value=MagicMock(status="active", id=uuid4(), plan_id="price_xxx"))
    totp_repo = AsyncMock()
    totp_repo.get_by_user = AsyncMock(return_value=None)
    webauthn_repo = AsyncMock()
    webauthn_repo.list_for_user = AsyncMock(return_value=[])
    admin_repo = AsyncMock()
    admin_repo.is_admin = AsyncMock(return_value=False)

    invite_repo = AsyncMock()
    invite_repo.list_pending_invites_for_email = AsyncMock(return_value=[])

    svc = AuthBootstrapService(
        workspace_repo=workspace_repo,
        membership_repo=membership_repo,
        role_repo=role_repo,
        subscription_repo=subscription_repo,
        totp_repo=totp_repo,
        webauthn_repo=webauthn_repo,
        admin_repo=admin_repo,
        invite_repo=invite_repo,
    )
    payload = await svc.build_bootstrap(user)
    assert len(payload["workspaces"]) == 1
    w = payload["workspaces"][0]
    assert w["access"]["reason"] == "mfa_required"
    assert w["access"]["allowed"] is False


@pytest.mark.asyncio
async def test_bootstrap_deterministic_recommended_workspace():
    """Same user and workspaces yield stable recommended_workspace_id."""
    user_id = uuid4()
    user = MagicMock(id=user_id, email="u@example.com", email_verified_at=datetime.now(timezone.utc))
    user.display_name = "User"
    user.first_name = None
    user.last_name = None
    user.phone = None
    user.company = None

    ws1 = MagicMock(id=uuid4(), name="A", owner_id=user_id, deleted_at=None, security_policy_require_mfa_for_paid_users=True, selected_plan_slug=None)
    ws1.created_at = datetime(2024, 1, 1, tzinfo=timezone.utc)
    ws2 = MagicMock(id=uuid4(), name="B", owner_id=user_id, deleted_at=None, security_policy_require_mfa_for_paid_users=True, selected_plan_slug=None)
    ws2.created_at = datetime(2024, 1, 2, tzinfo=timezone.utc)

    workspace_repo = AsyncMock()
    workspace_repo.list_for_user = AsyncMock(return_value=[ws1, ws2])
    membership_repo = AsyncMock()
    membership_repo.get = AsyncMock(return_value=MagicMock(role_id=uuid4()))
    role_repo = AsyncMock()
    role_repo.get_by_id = AsyncMock(return_value=MagicMock(name="owner"))
    subscription_repo = AsyncMock()
    subscription_repo.get_latest_for_workspace = AsyncMock(return_value=None)
    totp_repo = AsyncMock()
    totp_repo.get_by_user = AsyncMock(return_value=None)
    webauthn_repo = AsyncMock()
    webauthn_repo.list_for_user = AsyncMock(return_value=[])
    admin_repo = AsyncMock()
    admin_repo.is_admin = AsyncMock(return_value=False)

    invite_repo = AsyncMock()
    invite_repo.list_pending_invites_for_email = AsyncMock(return_value=[])

    svc = AuthBootstrapService(
        workspace_repo=workspace_repo,
        membership_repo=membership_repo,
        role_repo=role_repo,
        subscription_repo=subscription_repo,
        totp_repo=totp_repo,
        webauthn_repo=webauthn_repo,
        admin_repo=admin_repo,
        invite_repo=invite_repo,
    )
    p1 = await svc.build_bootstrap(user)
    p2 = await svc.build_bootstrap(user)
    assert p1["routing"]["recommended_workspace_id"] == p2["routing"]["recommended_workspace_id"]
    assert p1["routing"]["recommended_workspace_id"] == str(ws1.id)


# --- Invite accept path validation ---
@pytest.mark.asyncio
async def test_accept_invite_path_mismatch_raises():
    """Accept invite with wrong workspace_id in path returns error."""
    from src.core.exceptions import InvitePathMismatchError
    from src.domain.workspaces.service import WorkspaceService

    ws_correct = uuid4()
    ws_wrong = uuid4()
    inv = MagicMock(workspace_id=ws_correct, role_id=uuid4(), invited_by=uuid4())

    invite_repo = AsyncMock()
    invite_repo.resolve_pending_invite_token = AsyncMock(return_value=inv)

    workspace_repo = AsyncMock()
    workspace_repo.get_by_id = AsyncMock(return_value=MagicMock(id=ws_correct))
    membership_repo = AsyncMock()
    role_repo = AsyncMock()
    settings_repo = AsyncMock()
    invite_repo.mark_accepted = AsyncMock()

    user_repo = AsyncMock()

    svc = WorkspaceService(
        workspace_repo=workspace_repo,
        role_repo=role_repo,
        membership_repo=membership_repo,
        invite_repo=invite_repo,
        settings_repo=settings_repo,
        user_repo=user_repo,
    )
    with pytest.raises(InvitePathMismatchError) as exc_info:
        await svc.accept_invite(ws_wrong, "valid-token", uuid4())
    assert "workspace" in exc_info.value.message.lower()


@pytest.mark.asyncio
async def test_accept_invite_wrong_email_forbidden():
    from src.core.exceptions import ForbiddenError
    from src.domain.workspaces.service import WorkspaceService

    ws_id = uuid4()
    uid = uuid4()
    inv = MagicMock(
        workspace_id=ws_id,
        role_id=uuid4(),
        invited_by=uuid4(),
        email="invitee@example.com",
        id=uuid4(),
    )

    invite_repo = AsyncMock()
    invite_repo.resolve_pending_invite_token = AsyncMock(return_value=inv)

    workspace_repo = AsyncMock()
    workspace_repo.get_by_id = AsyncMock(return_value=MagicMock(id=ws_id))
    membership_repo = AsyncMock()
    membership_repo.create = AsyncMock()
    role_repo = AsyncMock()
    settings_repo = AsyncMock()
    invite_repo.mark_accepted = AsyncMock()

    user_repo = AsyncMock()
    user_repo.get_by_id = AsyncMock(return_value=MagicMock(email="other@example.com"))

    svc = WorkspaceService(
        workspace_repo=workspace_repo,
        role_repo=role_repo,
        membership_repo=membership_repo,
        invite_repo=invite_repo,
        settings_repo=settings_repo,
        user_repo=user_repo,
    )
    with pytest.raises(ForbiddenError):
        await svc.accept_invite(ws_id, "valid-token", uid)
    membership_repo.create.assert_not_called()
    invite_repo.mark_accepted.assert_not_called()


@pytest.mark.asyncio
async def test_accept_invite_idempotent_when_already_member():
    from src.domain.workspaces.service import WorkspaceService

    ws_id = uuid4()
    uid = uuid4()
    inv_id = uuid4()
    inv = MagicMock(
        workspace_id=ws_id,
        role_id=uuid4(),
        invited_by=uuid4(),
        email="member@example.com",
        id=inv_id,
    )

    invite_repo = AsyncMock()
    invite_repo.resolve_pending_invite_token = AsyncMock(return_value=inv)

    workspace_repo = AsyncMock()
    workspace_repo.get_by_id = AsyncMock(return_value=MagicMock(id=ws_id))
    membership_repo = AsyncMock()
    membership_repo.get = AsyncMock(return_value=MagicMock())
    membership_repo.create = AsyncMock()
    role_repo = AsyncMock()
    settings_repo = AsyncMock()
    invite_repo.mark_accepted = AsyncMock()

    user_repo = AsyncMock()
    user_repo.get_by_id = AsyncMock(return_value=MagicMock(email="member@example.com"))

    svc = WorkspaceService(
        workspace_repo=workspace_repo,
        role_repo=role_repo,
        membership_repo=membership_repo,
        invite_repo=invite_repo,
        settings_repo=settings_repo,
        user_repo=user_repo,
    )
    out = await svc.accept_invite(ws_id, "valid-token", uid)
    assert out is not None
    membership_repo.create.assert_not_called()
    invite_repo.mark_accepted.assert_called_once_with(inv_id)


# --- PlanLimitError ---
def test_plan_limit_error_detail():
    err = PlanLimitError(
        message="This plan allows up to 5 members.",
        limit_code="max_members",
        current_count=5,
        max_allowed=5,
    )
    assert err.code == "PLAN_LIMIT_REACHED"
    assert err.current_count == 5
    assert err.max_allowed == 5
