"""
Invitation CRUD API router.

Handles invite-only onboarding flow for new portal users:
  POST /api/portal/invitations              — Create invitation (tenant admin)
  POST /api/portal/invitations/accept       — Accept invitation, create account
  POST /api/portal/invitations/{id}/resend  — Resend email (tenant admin)
  GET  /api/portal/invitations              — List pending invitations (tenant admin)

Authentication model:
  - Create/resend/list require tenant admin (X-Portal-* headers)
  - Accept is unauthenticated (uses HMAC-signed token instead)

Token flow:
  1. POST /invitations → generate HMAC token, store SHA-256(token) as token_hash
  2. Email includes full token in acceptance URL
  3. POST /invitations/accept → validate HMAC token, look up invitation by SHA-256(token)
  4. Create PortalUser + UserTenantRole, mark invitation accepted

This keeps the raw token out of the DB while allowing secure lookup.
"""

from __future__ import annotations

import hashlib
import logging
import uuid
from datetime import datetime, timedelta, timezone
from typing import Any

import bcrypt
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession

from shared.api.rbac import PortalCaller, get_portal_caller, require_tenant_admin
from shared.config import settings
from shared.db import get_session
from shared.invite_token import generate_invite_token, token_to_hash, validate_invite_token
from shared.models.auth import PortalInvitation, PortalUser, UserTenantRole
from shared.models.tenant import Tenant

logger = logging.getLogger(__name__)

invitations_router = APIRouter(prefix="/api/portal/invitations", tags=["invitations"])

_INVITE_TTL_HOURS = 48


# ---------------------------------------------------------------------------
# Pydantic schemas
# ---------------------------------------------------------------------------


class InvitationCreate(BaseModel):
    email: str
    name: str
    role: str
    tenant_id: uuid.UUID


class InvitationResponse(BaseModel):
    id: str
    email: str
    name: str
    role: str
    tenant_id: str
    status: str
    expires_at: datetime
    created_at: datetime
    token: str | None = None  # Only included in create/resend responses


class InvitationAccept(BaseModel):
    token: str
    password: str


class AcceptResponse(BaseModel):
    id: str
    email: str
    name: str
    role: str


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


def _dispatch_invite_email(
    to_email: str,
    invitee_name: str,
    tenant_name: str,
    invite_url: str,
) -> None:
    """
    Fire-and-forget Celery task dispatch for invitation email.

    Uses lazy import to avoid circular dependency: shared -> orchestrator -> shared.
    Logs warning if orchestrator is not available (e.g. during unit testing).
    """
    try:
        from orchestrator.tasks import send_invite_email_task  # noqa: PLC0415

        send_invite_email_task.delay(to_email, invitee_name, tenant_name, invite_url)
    except ImportError:
        logger.warning(
            "orchestrator not available — skipping invite email dispatch to %s",
            to_email,
        )


async def _get_tenant_or_404(tenant_id: uuid.UUID, session: AsyncSession) -> Tenant:
    result = await session.execute(select(Tenant).where(Tenant.id == tenant_id))
    tenant = result.scalar_one_or_none()
    if tenant is None:
        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tenant not found")
    return tenant


# ---------------------------------------------------------------------------
# Endpoints
# ---------------------------------------------------------------------------


@invitations_router.post("", status_code=status.HTTP_201_CREATED, response_model=InvitationResponse)
async def create_invitation(
    body: InvitationCreate,
    caller: PortalCaller = Depends(get_portal_caller),
    session: AsyncSession = Depends(get_session),
) -> Any:
    """
    Create an invitation for a new user to join a tenant.

    Requires: tenant admin or platform admin.
    Returns: invitation record + raw token (for display/copy in UI).
    """
    await require_tenant_admin(body.tenant_id, caller, session)
    tenant = await _get_tenant_or_404(body.tenant_id, session)

    invitation = PortalInvitation(
        id=uuid.uuid4(),
        email=body.email,
        name=body.name,
        tenant_id=body.tenant_id,
        role=body.role,
        invited_by=caller.user_id,
        token_hash="placeholder",  # Will be updated below
        status="pending",
        expires_at=datetime.now(tz=timezone.utc) + timedelta(hours=_INVITE_TTL_HOURS),
    )
    session.add(invitation)
    await session.flush()  # Get the ID assigned

    # Generate token after we have the invitation ID
    token = generate_invite_token(str(invitation.id))
    invitation.token_hash = token_to_hash(token)
    await session.commit()
    await session.refresh(invitation)

    # Build invite URL and dispatch email fire-and-forget
    invite_url = f"{settings.portal_url}/invite/accept?token={token}"
    _dispatch_invite_email(body.email, body.name, tenant.name, invite_url)

    return InvitationResponse(
        id=str(invitation.id),
        email=invitation.email,
        name=invitation.name,
        role=invitation.role,
        tenant_id=str(invitation.tenant_id),
        status=invitation.status,
        expires_at=invitation.expires_at,
        created_at=invitation.created_at,
        token=token,  # Include raw token in creation response
    )


@invitations_router.post("/accept", response_model=AcceptResponse)
async def accept_invitation(
    body: InvitationAccept,
    session: AsyncSession = Depends(get_session),
) -> Any:
    """
    Accept an invitation and create a new portal user account.

    Validates the HMAC token, creates the user and tenant membership, and
    marks the invitation as accepted. All DB operations run in one transaction.
    """
    # Validate token (raises ValueError on tamper/expiry)
    try:
        invitation_id_str = validate_invite_token(body.token)
    except ValueError as exc:
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail=f"Invalid or expired token: {exc}",
        ) from exc

    try:
        invitation_id = uuid.UUID(invitation_id_str)
    except ValueError as exc:
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail="Malformed token: invalid invitation ID",
        ) from exc

    # Load and validate invitation
    result = await session.execute(
        select(PortalInvitation).where(PortalInvitation.id == invitation_id)
    )
    invitation = result.scalar_one_or_none()
    if invitation is None:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail="Invitation not found",
        )

    if invitation.status != "pending":
        raise HTTPException(
            status_code=status.HTTP_409_CONFLICT,
            detail=f"Invitation already {invitation.status}",
        )

    now = datetime.now(tz=timezone.utc)
    # Ensure expires_at is timezone-aware for comparison
    expires = invitation.expires_at
    if expires.tzinfo is None:
        expires = expires.replace(tzinfo=timezone.utc)
    if now > expires:
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail="Invitation has expired",
        )

    # Check email not already registered
    existing = await session.execute(
        select(PortalUser).where(PortalUser.email == invitation.email)
    )
    if existing.scalar_one_or_none() is not None:
        raise HTTPException(
            status_code=status.HTTP_409_CONFLICT,
            detail="Email already registered",
        )

    # Create user
    hashed = bcrypt.hashpw(body.password.encode(), bcrypt.gensalt()).decode()
    user = PortalUser(
        id=uuid.uuid4(),
        email=invitation.email,
        hashed_password=hashed,
        name=invitation.name,
        role=invitation.role,
    )
    session.add(user)
    await session.flush()

    # Create tenant membership
    membership = UserTenantRole(
        id=uuid.uuid4(),
        user_id=user.id,
        tenant_id=invitation.tenant_id,
        role=invitation.role,
    )
    session.add(membership)

    # Mark invitation accepted
    invitation.status = "accepted"

    await session.commit()
    await session.refresh(user)

    return AcceptResponse(
        id=str(user.id),
        email=user.email,
        name=user.name,
        role=user.role,
    )


@invitations_router.post("/{invitation_id}/resend", response_model=InvitationResponse)
async def resend_invitation(
    invitation_id: uuid.UUID,
    caller: PortalCaller = Depends(get_portal_caller),
    session: AsyncSession = Depends(get_session),
) -> Any:
    """
    Resend an invitation by generating a new token and extending expiry.

    Requires: tenant admin or platform admin.
    """
    result = await session.execute(
        select(PortalInvitation).where(PortalInvitation.id == invitation_id)
    )
    invitation = result.scalar_one_or_none()
    if invitation is None:
        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Invitation not found")

    await require_tenant_admin(invitation.tenant_id, caller, session)
    tenant = await _get_tenant_or_404(invitation.tenant_id, session)

    # Generate new token and extend expiry
    new_token = generate_invite_token(str(invitation.id))
    invitation.token_hash = token_to_hash(new_token)
    invitation.expires_at = datetime.now(tz=timezone.utc) + timedelta(hours=_INVITE_TTL_HOURS)
    invitation.status = "pending"  # Re-open if it was revoked

    await session.commit()
    await session.refresh(invitation)

    invite_url = f"{settings.portal_url}/invite/accept?token={new_token}"
    _dispatch_invite_email(invitation.email, invitation.name, tenant.name, invite_url)

    return InvitationResponse(
        id=str(invitation.id),
        email=invitation.email,
        name=invitation.name,
        role=invitation.role,
        tenant_id=str(invitation.tenant_id),
        status=invitation.status,
        expires_at=invitation.expires_at,
        created_at=invitation.created_at,
        token=new_token,
    )


@invitations_router.get("", response_model=list[InvitationResponse])
async def list_invitations(
    caller: PortalCaller = Depends(get_portal_caller),
    session: AsyncSession = Depends(get_session),
) -> Any:
    """
    List pending invitations for the caller's active tenant.

    Requires: tenant admin or platform admin.
    The tenant is resolved from X-Portal-Tenant-Id header.
    """
    if caller.tenant_id is None:
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail="X-Portal-Tenant-Id header required for listing invitations",
        )

    await require_tenant_admin(caller.tenant_id, caller, session)

    result = await session.execute(
        select(PortalInvitation).where(
            PortalInvitation.tenant_id == caller.tenant_id,
            PortalInvitation.status == "pending",
        )
    )
    invitations = result.scalars().all()

    return [
        InvitationResponse(
            id=str(inv.id),
            email=inv.email,
            name=inv.name,
            role=inv.role,
            tenant_id=str(inv.tenant_id),
            status=inv.status,
            expires_at=inv.expires_at,
            created_at=inv.created_at,
            token=None,  # Never expose token in list
        )
        for inv in invitations
    ]
