"""
End-to-end integration tests for the portal invitation flow.

Tests:
  1. Full flow: admin creates invite -> token generated -> accept with password ->
     new user created with correct role and tenant membership
  2. Expired invite acceptance returns error
  3. Resend generates new token and extends expiry
  4. Double-accept prevented (status no longer 'pending')
  5. Login works after acceptance: /auth/verify returns correct role
"""

from __future__ import annotations

import uuid
from datetime import datetime, timedelta, timezone

import bcrypt
import pytest
import pytest_asyncio
from fastapi import FastAPI
from httpx import ASGITransport, AsyncClient
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession

from shared.api.invitations import invitations_router
from shared.api.portal import portal_router
from shared.db import get_session
from shared.invite_token import generate_invite_token, token_to_hash
from shared.models.auth import PortalInvitation, PortalUser, UserTenantRole
from shared.models.tenant import Tenant


# ---------------------------------------------------------------------------
# App factory
# ---------------------------------------------------------------------------


def make_app(session: AsyncSession) -> FastAPI:
    """Build a FastAPI test app with portal and invitations routers."""
    app = FastAPI()
    app.include_router(portal_router)
    app.include_router(invitations_router)

    async def override_get_session():  # type: ignore[return]
        yield session

    app.dependency_overrides[get_session] = override_get_session
    return app


# ---------------------------------------------------------------------------
# Header helpers
# ---------------------------------------------------------------------------


def admin_headers(user_id: uuid.UUID, tenant_id: uuid.UUID) -> dict[str, str]:
    return {
        "X-Portal-User-Id": str(user_id),
        "X-Portal-User-Role": "customer_admin",
        "X-Portal-Tenant-Id": str(tenant_id),
    }


def platform_admin_headers(user_id: uuid.UUID) -> dict[str, str]:
    return {
        "X-Portal-User-Id": str(user_id),
        "X-Portal-User-Role": "platform_admin",
    }


# ---------------------------------------------------------------------------
# DB setup helpers
# ---------------------------------------------------------------------------


async def _create_tenant_direct(session: AsyncSession) -> Tenant:
    suffix = uuid.uuid4().hex[:8]
    tenant = Tenant(
        id=uuid.uuid4(),
        name=f"Invite Test Tenant {suffix}",
        slug=f"invite-test-{suffix}",
        settings={},
    )
    session.add(tenant)
    await session.flush()
    return tenant


async def _create_admin_user(session: AsyncSession, tenant: Tenant) -> PortalUser:
    hashed = bcrypt.hashpw(b"adminpassword123", bcrypt.gensalt()).decode()
    suffix = uuid.uuid4().hex[:8]
    user = PortalUser(
        id=uuid.uuid4(),
        email=f"admin-{suffix}@example.com",
        hashed_password=hashed,
        name=f"Admin User {suffix}",
        role="customer_admin",
    )
    session.add(user)
    await session.flush()

    membership = UserTenantRole(
        id=uuid.uuid4(),
        user_id=user.id,
        tenant_id=tenant.id,
        role="customer_admin",
    )
    session.add(membership)
    await session.flush()
    return user


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


@pytest_asyncio.fixture
async def invite_client(db_session: AsyncSession) -> AsyncClient:
    """HTTP client with portal and invitations routers."""
    app = make_app(db_session)
    async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
        yield c


@pytest_asyncio.fixture
async def invite_setup(db_session: AsyncSession) -> dict:
    """Create a tenant and admin user for invitation tests."""
    tenant = await _create_tenant_direct(db_session)
    admin = await _create_admin_user(db_session, tenant)
    await db_session.commit()
    return {"tenant": tenant, "admin": admin}


# ---------------------------------------------------------------------------
# Tests: Full invite flow
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
class TestInviteFlow:
    async def test_full_invite_flow_operator(
        self, invite_client: AsyncClient, db_session: AsyncSession, invite_setup: dict
    ) -> None:
        """
        Full end-to-end invite flow for a customer_operator:

        1. Admin creates invitation
        2. Token is included in response
        3. Invitee accepts invitation with password
        4. PortalUser created with role=customer_operator
        5. UserTenantRole created linking user to tenant
        6. Invitation status updated to 'accepted'
        7. Login returns correct role
        """
        tenant = invite_setup["tenant"]
        admin = invite_setup["admin"]
        invitee_email = f"operator-{uuid.uuid4().hex[:8]}@example.com"

        # Step 1: Admin creates invitation
        resp = await invite_client.post(
            "/api/portal/invitations",
            json={
                "email": invitee_email,
                "name": "New Operator",
                "role": "customer_operator",
                "tenant_id": str(tenant.id),
            },
            headers=admin_headers(admin.id, tenant.id),
        )
        assert resp.status_code == 201
        invite_data = resp.json()
        assert invite_data["email"] == invitee_email
        assert invite_data["role"] == "customer_operator"
        assert invite_data["status"] == "pending"
        token = invite_data["token"]
        assert token is not None and len(token) > 0

        # Step 2: Invitee accepts the invitation
        accept_resp = await invite_client.post(
            "/api/portal/invitations/accept",
            json={"token": token, "password": "securepass123"},
        )
        assert accept_resp.status_code == 200
        accept_data = accept_resp.json()
        assert accept_data["email"] == invitee_email
        assert accept_data["role"] == "customer_operator"
        new_user_id = accept_data["id"]

        # Step 3: Verify PortalUser was created correctly
        user_result = await db_session.execute(
            select(PortalUser).where(PortalUser.id == uuid.UUID(new_user_id))
        )
        new_user = user_result.scalar_one_or_none()
        assert new_user is not None
        assert new_user.email == invitee_email
        assert new_user.role == "customer_operator"

        # Step 4: Verify UserTenantRole was created
        membership_result = await db_session.execute(
            select(UserTenantRole).where(
                UserTenantRole.user_id == uuid.UUID(new_user_id),
                UserTenantRole.tenant_id == tenant.id,
            )
        )
        membership = membership_result.scalar_one_or_none()
        assert membership is not None
        assert membership.role == "customer_operator"

        # Step 5: Verify invitation status is 'accepted'
        inv_result = await db_session.execute(
            select(PortalInvitation).where(
                PortalInvitation.id == uuid.UUID(invite_data["id"])
            )
        )
        invitation = inv_result.scalar_one_or_none()
        assert invitation is not None
        assert invitation.status == "accepted"

        # Step 6: Verify login works with correct role
        login_resp = await invite_client.post(
            "/api/portal/auth/verify",
            json={"email": invitee_email, "password": "securepass123"},
        )
        assert login_resp.status_code == 200
        login_data = login_resp.json()
        assert login_data["role"] == "customer_operator"
        assert str(tenant.id) in login_data["tenant_ids"]

    async def test_full_invite_flow_customer_admin(
        self, invite_client: AsyncClient, invite_setup: dict
    ) -> None:
        """Full flow for a customer_admin invitee."""
        tenant = invite_setup["tenant"]
        admin = invite_setup["admin"]
        invitee_email = f"new-admin-{uuid.uuid4().hex[:8]}@example.com"

        # Create invitation
        resp = await invite_client.post(
            "/api/portal/invitations",
            json={
                "email": invitee_email,
                "name": "New Admin",
                "role": "customer_admin",
                "tenant_id": str(tenant.id),
            },
            headers=admin_headers(admin.id, tenant.id),
        )
        assert resp.status_code == 201
        token = resp.json()["token"]

        # Accept invitation
        accept_resp = await invite_client.post(
            "/api/portal/invitations/accept",
            json={"token": token, "password": "adminpass123"},
        )
        assert accept_resp.status_code == 200
        assert accept_resp.json()["role"] == "customer_admin"


# ---------------------------------------------------------------------------
# Tests: Expired invitation
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
class TestExpiredInvite:
    async def test_expired_invite_acceptance_returns_error(
        self, invite_client: AsyncClient, db_session: AsyncSession, invite_setup: dict
    ) -> None:
        """Accepting an expired invitation returns a 400 error."""
        tenant = invite_setup["tenant"]
        admin = invite_setup["admin"]
        invitee_email = f"expired-{uuid.uuid4().hex[:8]}@example.com"

        # Create invitation normally
        resp = await invite_client.post(
            "/api/portal/invitations",
            json={
                "email": invitee_email,
                "name": "Expired User",
                "role": "customer_operator",
                "tenant_id": str(tenant.id),
            },
            headers=admin_headers(admin.id, tenant.id),
        )
        assert resp.status_code == 201
        token = resp.json()["token"]
        invite_id = uuid.UUID(resp.json()["id"])

        # Manually set expires_at to the past
        inv_result = await db_session.execute(
            select(PortalInvitation).where(PortalInvitation.id == invite_id)
        )
        invitation = inv_result.scalar_one()
        invitation.expires_at = datetime.now(tz=timezone.utc) - timedelta(hours=1)
        await db_session.commit()

        # Attempt to accept — should fail with 400
        accept_resp = await invite_client.post(
            "/api/portal/invitations/accept",
            json={"token": token, "password": "somepass123"},
        )
        assert accept_resp.status_code == 400
        assert "expired" in accept_resp.json()["detail"].lower()


# ---------------------------------------------------------------------------
# Tests: Resend invitation
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
class TestResendInvite:
    async def test_resend_generates_new_token_and_extends_expiry(
        self, invite_client: AsyncClient, db_session: AsyncSession, invite_setup: dict
    ) -> None:
        """Resending an invitation generates a new token and extends expiry."""
        tenant = invite_setup["tenant"]
        admin = invite_setup["admin"]
        invitee_email = f"resend-{uuid.uuid4().hex[:8]}@example.com"

        # Create initial invitation
        create_resp = await invite_client.post(
            "/api/portal/invitations",
            json={
                "email": invitee_email,
                "name": "Resend User",
                "role": "customer_operator",
                "tenant_id": str(tenant.id),
            },
            headers=admin_headers(admin.id, tenant.id),
        )
        assert create_resp.status_code == 201
        invite_id = create_resp.json()["id"]
        original_token = create_resp.json()["token"]

        # Record original expiry
        inv_result = await db_session.execute(
            select(PortalInvitation).where(PortalInvitation.id == uuid.UUID(invite_id))
        )
        original_invitation = inv_result.scalar_one()
        original_expiry = original_invitation.expires_at

        # Resend the invitation
        resend_resp = await invite_client.post(
            f"/api/portal/invitations/{invite_id}/resend",
            headers=admin_headers(admin.id, tenant.id),
        )
        assert resend_resp.status_code == 200
        new_token = resend_resp.json()["token"]

        # Token should be different
        assert new_token != original_token
        assert new_token is not None and len(new_token) > 0

        # Verify new token hash in DB and extended expiry
        await db_session.refresh(original_invitation)
        assert original_invitation.token_hash == token_to_hash(new_token)

        # Expiry should be extended (refreshed from "now + 48h")
        new_expiry = original_invitation.expires_at
        assert new_expiry > original_expiry or (
            # Allow if original was already far in future (within 1 second tolerance)
            abs((new_expiry - original_expiry).total_seconds()) < 5
        )

    async def test_resend_new_token_can_be_used_to_accept(
        self, invite_client: AsyncClient, invite_setup: dict
    ) -> None:
        """New token from resend works for acceptance."""
        tenant = invite_setup["tenant"]
        admin = invite_setup["admin"]
        invitee_email = f"resend-accept-{uuid.uuid4().hex[:8]}@example.com"

        # Create invitation
        create_resp = await invite_client.post(
            "/api/portal/invitations",
            json={
                "email": invitee_email,
                "name": "Resend Accept User",
                "role": "customer_operator",
                "tenant_id": str(tenant.id),
            },
            headers=admin_headers(admin.id, tenant.id),
        )
        invite_id = create_resp.json()["id"]

        # Resend to get new token
        resend_resp = await invite_client.post(
            f"/api/portal/invitations/{invite_id}/resend",
            headers=admin_headers(admin.id, tenant.id),
        )
        new_token = resend_resp.json()["token"]

        # Accept with new token
        accept_resp = await invite_client.post(
            "/api/portal/invitations/accept",
            json={"token": new_token, "password": "securepass123"},
        )
        assert accept_resp.status_code == 200
        assert accept_resp.json()["email"] == invitee_email


# ---------------------------------------------------------------------------
# Tests: Double-accept prevention
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
class TestDoubleAcceptPrevention:
    async def test_double_accept_returns_conflict(
        self, invite_client: AsyncClient, invite_setup: dict
    ) -> None:
        """Attempting to accept an already-accepted invitation returns 409."""
        tenant = invite_setup["tenant"]
        admin = invite_setup["admin"]
        invitee_email = f"double-accept-{uuid.uuid4().hex[:8]}@example.com"

        # Create invitation
        create_resp = await invite_client.post(
            "/api/portal/invitations",
            json={
                "email": invitee_email,
                "name": "Double Accept User",
                "role": "customer_operator",
                "tenant_id": str(tenant.id),
            },
            headers=admin_headers(admin.id, tenant.id),
        )
        token = create_resp.json()["token"]

        # First accept — should succeed
        first_resp = await invite_client.post(
            "/api/portal/invitations/accept",
            json={"token": token, "password": "firstpass123"},
        )
        assert first_resp.status_code == 200

        # Second accept with same token — should fail
        second_resp = await invite_client.post(
            "/api/portal/invitations/accept",
            json={"token": token, "password": "secondpass123"},
        )
        # Either 409 (already accepted) or 400 (email already registered)
        assert second_resp.status_code in (400, 409)

    async def test_only_pending_invites_can_be_accepted(
        self, invite_client: AsyncClient, db_session: AsyncSession, invite_setup: dict
    ) -> None:
        """Invitations with status != 'pending' cannot be accepted."""
        tenant = invite_setup["tenant"]
        admin = invite_setup["admin"]
        invitee_email = f"non-pending-{uuid.uuid4().hex[:8]}@example.com"

        # Create invitation
        create_resp = await invite_client.post(
            "/api/portal/invitations",
            json={
                "email": invitee_email,
                "name": "Non Pending User",
                "role": "customer_operator",
                "tenant_id": str(tenant.id),
            },
            headers=admin_headers(admin.id, tenant.id),
        )
        invite_id = uuid.UUID(create_resp.json()["id"])
        token = create_resp.json()["token"]

        # Manually set status to 'revoked'
        inv_result = await db_session.execute(
            select(PortalInvitation).where(PortalInvitation.id == invite_id)
        )
        invitation = inv_result.scalar_one()
        invitation.status = "revoked"
        await db_session.commit()

        # Attempt to accept revoked invitation
        accept_resp = await invite_client.post(
            "/api/portal/invitations/accept",
            json={"token": token, "password": "somepass123"},
        )
        assert accept_resp.status_code == 409
