# @File: backend/routers/support_chat.py
# @Desc: Business-logic endpoints for the AI Chat Support System.
#        Thin over the auto-generated CRUD routers:
#          - visitor-facing: POST /chat/conversations, POST messages, ai-reply,
#            attachments upload, handover
#          - agent-facing: list/assign, suggest-reply, summarize, convert-to-ticket,
#            link to WHMCS entities, internal notes
#          - admin-only: seat management flows built on top of support_seats
#          - availability heartbeat + analytics
import json
import logging
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, List, Optional

from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, Field
from sqlalchemy import and_, desc, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession

from core.database import get_db
from dependencies.auth import get_current_user
from models.agent_availability import Agent_availability
from models.chat_attachments import Chat_attachments
from models.chat_conversations import Chat_conversations
from models.chat_messages import Chat_messages
from models.chat_routing_rules import Chat_routing_rules
from models.kb_articles import Kb_articles
from models.support_departments import Support_departments
from models.support_seats import Support_seats
from schemas.auth import UserResponse
from services.email_sender import send_email
from services.support_chat import (
    apply_routing,
    compute_analytics,
    ensure_default_departments,
    generate_ai_reply,
    generate_suggested_reply,
    sanitize_ai_output,
    search_kb,
    summarize_conversation,
    sweep_offline,
)
from services.whmcs import call_whmcs, call_whmcs_safe, get_client_by_email

logger = logging.getLogger(__name__)

router = APIRouter(prefix="/api/v1/chat", tags=["support-chat"])


# -------- helpers --------
AGENT_ROLES = {"admin", "supervisor", "support_agent", "billing_agent", "technical_agent"}


async def _get_seat_for_user(
    db: AsyncSession, user: UserResponse
) -> Optional[Support_seats]:
    """Return active support seat for the authenticated user, if any."""
    stmt = select(Support_seats).where(
        or_(
            Support_seats.user_id == user.id,
            func.lower(Support_seats.email) == func.lower(user.email or ""),
        ),
        Support_seats.status == "active",
    )
    return (await db.execute(stmt)).scalar_one_or_none()


async def _require_agent_seat(
    db: AsyncSession, user: UserResponse
) -> Support_seats:
    seat = await _get_seat_for_user(db, user)
    # Admins (Atoms role) implicitly get a virtual agent view even without a seat.
    if seat is None and user.role == "admin":
        # Fabricate a transient seat-like admin record by looking up/creating one.
        # We prefer creating a persistent seat so audits are clean.
        stmt = select(Support_seats).where(
            func.lower(Support_seats.email) == func.lower(user.email or "")
        )
        existing = (await db.execute(stmt)).scalar_one_or_none()
        if existing:
            return existing
        admin_seat = Support_seats(
            user_id=user.id,
            display_name=user.name or user.email or "Admin",
            email=user.email or "",
            role="admin",
            status="active",
            chat_limit=50,
            working_hours="{}",
            permissions=json.dumps({"manage_seats": True, "manage_kb": True, "view_all": True}),
        )
        db.add(admin_seat)
        await db.flush()
        return admin_seat
    if seat is None:
        raise HTTPException(status_code=403, detail="You are not a support agent.")
    if seat.status != "active":
        raise HTTPException(status_code=403, detail="Your seat is suspended.")
    return seat


def _is_admin_like(seat: Support_seats) -> bool:
    return seat.role in {"admin", "supervisor"}


async def _conversation_or_404(
    db: AsyncSession, conversation_id: int
) -> Chat_conversations:
    conv = await db.get(Chat_conversations, conversation_id)
    if conv is None:
        raise HTTPException(status_code=404, detail="Conversation not found.")
    return conv


async def _messages_for(
    db: AsyncSession, conversation_id: int, *, include_internal: bool
) -> List[Chat_messages]:
    stmt = (
        select(Chat_messages)
        .where(Chat_messages.conversation_id == conversation_id)
        .order_by(Chat_messages.id.asc())
    )
    rows = (await db.execute(stmt)).scalars().all()
    if include_internal:
        return rows
    return [m for m in rows if not m.is_internal_note]


def _message_to_dict(m: Chat_messages) -> Dict[str, Any]:
    attachments: List[Dict[str, Any]] = []
    try:
        attachments = json.loads(m.attachments) if m.attachments else []
    except (ValueError, TypeError):
        attachments = []
    return {
        "id": m.id,
        "conversation_id": m.conversation_id,
        "sender_type": m.sender_type,
        "sender_seat_id": m.sender_seat_id,
        "sender_name": m.sender_name,
        "content": m.content,
        "attachments": attachments,
        "is_internal_note": bool(m.is_internal_note),
        "ai_confidence": m.ai_confidence,
        "created_at": m.created_at.isoformat() if m.created_at else None,
    }


def _conversation_to_dict(c: Chat_conversations) -> Dict[str, Any]:
    return {
        "id": c.id,
        "user_id": c.user_id,
        "whmcs_client_id": c.whmcs_client_id,
        "visitor_name": c.visitor_name,
        "visitor_email": c.visitor_email,
        "department_id": c.department_id,
        "assigned_seat_id": c.assigned_seat_id,
        "status": c.status,
        "priority": c.priority,
        "sla_due_at": c.sla_due_at.isoformat() if c.sla_due_at else None,
        "linked_whmcs_service_id": c.linked_whmcs_service_id,
        "linked_whmcs_invoice_id": c.linked_whmcs_invoice_id,
        "linked_whmcs_domain_id": c.linked_whmcs_domain_id,
        "linked_ticket_id": c.linked_ticket_id,
        "ai_summary": c.ai_summary,
        "subject": c.subject,
        "created_at": c.created_at.isoformat() if c.created_at else None,
        "last_message_at": c.last_message_at.isoformat() if c.last_message_at else None,
    }


def _sla_due_for(priority: str) -> datetime:
    mins = {"urgent": 15, "high": 60, "normal": 240, "low": 1440}.get(priority, 240)
    return datetime.now(timezone.utc) + timedelta(minutes=mins)


# ==========================================================
# Visitor-facing endpoints
# ==========================================================


class StartConversationRequest(BaseModel):
    visitor_name: Optional[str] = None
    visitor_email: Optional[str] = None
    subject: Optional[str] = None
    initial_message: Optional[str] = None
    department_id: Optional[int] = None


@router.post("/conversations")
async def start_conversation(
    body: StartConversationRequest,
    db: AsyncSession = Depends(get_db),
):
    """Start a new chat. Auth is optional: if the caller is authenticated we'll
    attach the Atoms user_id and attempt to resolve the matching WHMCS client.
    """
    await ensure_default_departments(db)

    # Best-effort: sniff auth without forcing 401 so anonymous visitors can chat.
    # (We don't decode JWT here; the widget passes visitor_email when known.)
    whmcs_client_id: Optional[int] = None
    email = (body.visitor_email or "").strip().lower() or None
    if email:
        try:
            client = await get_client_by_email(email)
            if client and isinstance(client, dict):
                whmcs_client_id = client.get("userid") or client.get("id")
        except Exception:  # noqa: BLE001
            logger.debug("WHMCS client lookup failed for %s (non-fatal)", email)

    priority = "normal"
    conv = Chat_conversations(
        user_id=None,
        whmcs_client_id=whmcs_client_id if isinstance(whmcs_client_id, int) else None,
        visitor_name=(body.visitor_name or "Guest").strip()[:120],
        visitor_email=email,
        department_id=body.department_id,
        status="ai",
        priority=priority,
        sla_due_at=_sla_due_for(priority),
        subject=(body.subject or "").strip()[:200] or None,
        last_message_at=datetime.now(timezone.utc),
    )
    db.add(conv)
    await db.flush()

    # Greeting from the AI bot.
    greeting = Chat_messages(
        conversation_id=conv.id,
        sender_type="ai",
        sender_name="DoitEasy Assistant",
        content=(
            f"Hi {conv.visitor_name}! I'm DoitEasy's support assistant. "
            "Tell me what you need help with and I'll do my best — if it's "
            "account-specific I'll hand you off to a human agent."
        ),
        is_internal_note=False,
        ai_confidence=1.0,
    )
    db.add(greeting)
    await db.flush()

    initial_message = (body.initial_message or "").strip()
    if initial_message:
        visitor_msg = Chat_messages(
            conversation_id=conv.id,
            sender_type="visitor",
            sender_name=conv.visitor_name,
            content=initial_message[:8000],
            is_internal_note=False,
        )
        db.add(visitor_msg)
        conv.last_message_at = datetime.now(timezone.utc)
        await db.flush()

    await db.commit()
    return {"conversation": _conversation_to_dict(conv)}


class PostMessageRequest(BaseModel):
    content: str = Field(..., min_length=1, max_length=8000)
    sender_type: str = Field(
        "visitor", description="visitor|agent|system  (internal_note implies agent)"
    )
    is_internal_note: bool = False
    attachments: Optional[List[Dict[str, Any]]] = None


@router.post("/conversations/{conversation_id}/messages")
async def post_message(
    conversation_id: int,
    body: PostMessageRequest,
    db: AsyncSession = Depends(get_db),
):
    """Post a message to a conversation.

    - sender_type=visitor: no auth required (widget usage).
    - sender_type=agent or is_internal_note=True: requires an active support
      seat. We re-derive the user from the bearer token manually to allow
      mixed anonymous/auth access on the same route.
    """
    conv = await _conversation_or_404(db, conversation_id)

    seat: Optional[Support_seats] = None
    if body.sender_type == "agent" or body.is_internal_note:
        # Manually resolve the caller through the same dependency chain.
        # We cannot mark the whole route auth-required because visitors also use it.
        from fastapi import Request  # local import to avoid shadowing

        # Use FastAPI's dependency resolution indirectly — easier: forbid
        # agent-type posts without Authorization header.
        raise HTTPException(
            status_code=400,
            detail="Agent replies must go through /agent/conversations/{id}/reply.",
        )

    sender_name = body.sender_type == "visitor" and (conv.visitor_name or "Guest")
    msg = Chat_messages(
        conversation_id=conv.id,
        sender_type=body.sender_type or "visitor",
        sender_name=sender_name or None,
        content=body.content.strip(),
        is_internal_note=False,
        attachments=(
            json.dumps(body.attachments) if body.attachments else None
        ),
    )
    db.add(msg)
    conv.last_message_at = datetime.now(timezone.utc)
    await db.flush()
    await db.commit()
    return {"message": _message_to_dict(msg)}


@router.get("/conversations/{conversation_id}/messages")
async def get_messages(
    conversation_id: int,
    db: AsyncSession = Depends(get_db),
    include_internal: bool = Query(False),
):
    await _conversation_or_404(db, conversation_id)
    msgs = await _messages_for(
        db, conversation_id, include_internal=bool(include_internal)
    )
    return {"items": [_message_to_dict(m) for m in msgs]}


@router.post("/conversations/{conversation_id}/ai-reply")
async def trigger_ai_reply(
    conversation_id: int,
    db: AsyncSession = Depends(get_db),
):
    """Generate an AI reply to the latest visitor message and persist it.
    Auto-triggers handover if the AI asked to escalate.
    """
    conv = await _conversation_or_404(db, conversation_id)
    if conv.status in {"resolved", "closed"}:
        raise HTTPException(status_code=409, detail="Conversation is already closed.")

    history = await _messages_for(db, conversation_id, include_internal=False)
    result = await generate_ai_reply(db, conv, history)

    ai_msg = Chat_messages(
        conversation_id=conv.id,
        sender_type="ai",
        sender_name="DoitEasy Assistant",
        content=result["reply"] or "(No reply generated.)",
        is_internal_note=False,
        ai_confidence=float(result.get("confidence") or 0.0),
    )
    db.add(ai_msg)
    conv.last_message_at = datetime.now(timezone.utc)
    await db.flush()

    escalated = False
    if result.get("escalate"):
        # Move to queued and produce a draft summary.
        summary = await summarize_conversation(
            db, conv, history + [ai_msg]
        )
        conv.ai_summary = summary
        conv.status = "queued"

        # Apply routing.
        routing = await apply_routing(db, conv)
        if routing.get("target_department_id"):
            conv.department_id = routing["target_department_id"]
        if routing.get("target_seat_id"):
            conv.assigned_seat_id = routing["target_seat_id"]
            conv.status = "assigned"

        # System breadcrumb.
        db.add(
            Chat_messages(
                conversation_id=conv.id,
                sender_type="system",
                sender_name="System",
                content=(
                    "Chat handed over to human support. A summary has been "
                    "prepared for the agent."
                ),
                is_internal_note=False,
            )
        )
        escalated = True

    await db.flush()
    await db.commit()
    return {
        "message": _message_to_dict(ai_msg),
        "conversation": _conversation_to_dict(conv),
        "escalated": escalated,
        "kb_hits": result.get("kb_hits") or [],
        "was_redacted": result.get("was_redacted") or False,
    }


@router.get("/conversations/{conversation_id}")
async def get_conversation(
    conversation_id: int,
    db: AsyncSession = Depends(get_db),
):
    conv = await _conversation_or_404(db, conversation_id)
    return {"conversation": _conversation_to_dict(conv)}


# ==========================================================
# Agent-facing endpoints (auth required)
# ==========================================================


class AgentReplyRequest(BaseModel):
    content: str = Field(..., min_length=1, max_length=8000)
    is_internal_note: bool = False
    attachments: Optional[List[Dict[str, Any]]] = None


@router.post("/agent/conversations/{conversation_id}/reply")
async def agent_reply(
    conversation_id: int,
    body: AgentReplyRequest,
    db: AsyncSession = Depends(get_db),
    current_user: UserResponse = Depends(get_current_user),
):
    """Agent posts a normal reply OR an internal note."""
    seat = await _require_agent_seat(db, current_user)
    conv = await _conversation_or_404(db, conversation_id)

    if (
        not _is_admin_like(seat)
        and conv.assigned_seat_id
        and conv.assigned_seat_id != seat.id
    ):
        raise HTTPException(status_code=403, detail="Assigned to a different agent.")

    msg = Chat_messages(
        conversation_id=conv.id,
        sender_type="agent",
        sender_seat_id=seat.id,
        sender_name=seat.display_name,
        content=body.content.strip(),
        is_internal_note=bool(body.is_internal_note),
        attachments=(json.dumps(body.attachments) if body.attachments else None),
    )
    db.add(msg)
    conv.last_message_at = datetime.now(timezone.utc)
    # First agent reply flips status from queued to assigned/in-progress.
    if not body.is_internal_note and conv.status in {"queued", "ai"}:
        conv.status = "assigned"
        if not conv.assigned_seat_id:
            conv.assigned_seat_id = seat.id

    await db.flush()
    await db.commit()
    return {"message": _message_to_dict(msg), "conversation": _conversation_to_dict(conv)}


@router.post("/agent/conversations/{conversation_id}/suggest-reply")
async def agent_suggest_reply(
    conversation_id: int,
    db: AsyncSession = Depends(get_db),
    current_user: UserResponse = Depends(get_current_user),
):
    await _require_agent_seat(db, current_user)
    conv = await _conversation_or_404(db, conversation_id)
    history = await _messages_for(db, conversation_id, include_internal=True)
    suggestion = await generate_suggested_reply(db, conv, history)
    return {"suggestion": suggestion}


@router.post("/agent/conversations/{conversation_id}/summarize")
async def agent_summarize(
    conversation_id: int,
    db: AsyncSession = Depends(get_db),
    current_user: UserResponse = Depends(get_current_user),
):
    await _require_agent_seat(db, current_user)
    conv = await _conversation_or_404(db, conversation_id)
    history = await _messages_for(db, conversation_id, include_internal=True)
    summary = await summarize_conversation(db, conv, history)
    conv.ai_summary = summary
    await db.flush()
    await db.commit()
    return {"summary": summary}


class HandoverRequest(BaseModel):
    department_id: Optional[int] = None
    priority: Optional[str] = None


@router.post("/agent/conversations/{conversation_id}/handover")
async def agent_handover(
    conversation_id: int,
    body: HandoverRequest,
    db: AsyncSession = Depends(get_db),
    current_user: UserResponse = Depends(get_current_user),
):
    """Force handover from AI -> human queue; regenerate summary & apply routing."""
    await _require_agent_seat(db, current_user)
    conv = await _conversation_or_404(db, conversation_id)
    if body.department_id is not None:
        conv.department_id = body.department_id
    if body.priority:
        conv.priority = body.priority
        conv.sla_due_at = _sla_due_for(body.priority)
    history = await _messages_for(db, conversation_id, include_internal=True)
    conv.ai_summary = await summarize_conversation(db, conv, history)
    conv.status = "queued"
    conv.assigned_seat_id = None
    routing = await apply_routing(db, conv)
    if routing.get("target_department_id"):
        conv.department_id = routing["target_department_id"]
    if routing.get("target_seat_id"):
        conv.assigned_seat_id = routing["target_seat_id"]
        conv.status = "assigned"
    await db.flush()
    await db.commit()
    return {"conversation": _conversation_to_dict(conv)}


class AssignRequest(BaseModel):
    seat_id: Optional[int] = None
    department_id: Optional[int] = None


@router.post("/agent/conversations/{conversation_id}/assign")
async def agent_assign(
    conversation_id: int,
    body: AssignRequest,
    db: AsyncSession = Depends(get_db),
    current_user: UserResponse = Depends(get_current_user),
):
    seat = await _require_agent_seat(db, current_user)
    conv = await _conversation_or_404(db, conversation_id)
    if body.seat_id is not None:
        # Only admin/supervisor can reassign to another agent.
        if not _is_admin_like(seat) and body.seat_id != seat.id:
            raise HTTPException(status_code=403, detail="Only supervisors can reassign.")
        target = await db.get(Support_seats, body.seat_id)
        if target is None or target.status != "active":
            raise HTTPException(status_code=400, detail="Invalid or suspended seat.")
        conv.assigned_seat_id = target.id
        conv.status = "assigned"
    if body.department_id is not None:
        conv.department_id = body.department_id
    await db.flush()
    await db.commit()
    return {"conversation": _conversation_to_dict(conv)}


class LinkRequest(BaseModel):
    whmcs_service_id: Optional[int] = None
    whmcs_invoice_id: Optional[int] = None
    whmcs_domain_id: Optional[int] = None


@router.post("/agent/conversations/{conversation_id}/link")
async def agent_link(
    conversation_id: int,
    body: LinkRequest,
    db: AsyncSession = Depends(get_db),
    current_user: UserResponse = Depends(get_current_user),
):
    await _require_agent_seat(db, current_user)
    conv = await _conversation_or_404(db, conversation_id)
    if body.whmcs_service_id is not None:
        conv.linked_whmcs_service_id = body.whmcs_service_id
    if body.whmcs_invoice_id is not None:
        conv.linked_whmcs_invoice_id = body.whmcs_invoice_id
    if body.whmcs_domain_id is not None:
        conv.linked_whmcs_domain_id = body.whmcs_domain_id
    await db.flush()
    await db.commit()
    return {"conversation": _conversation_to_dict(conv)}


class StatusUpdateRequest(BaseModel):
    status: str = Field(..., description="ai|queued|assigned|resolved|closed")
    priority: Optional[str] = None


@router.post("/agent/conversations/{conversation_id}/status")
async def agent_set_status(
    conversation_id: int,
    body: StatusUpdateRequest,
    db: AsyncSession = Depends(get_db),
    current_user: UserResponse = Depends(get_current_user),
):
    await _require_agent_seat(db, current_user)
    conv = await _conversation_or_404(db, conversation_id)
    if body.status not in {"ai", "queued", "assigned", "resolved", "closed"}:
        raise HTTPException(status_code=400, detail="Invalid status.")
    conv.status = body.status
    if body.priority:
        conv.priority = body.priority
        conv.sla_due_at = _sla_due_for(body.priority)
    await db.flush()
    await db.commit()
    return {"conversation": _conversation_to_dict(conv)}


class ConvertTicketRequest(BaseModel):
    subject: Optional[str] = None
    department_id: Optional[int] = None
    priority: Optional[str] = None


@router.post("/agent/conversations/{conversation_id}/convert-to-ticket")
async def agent_convert_to_ticket(
    conversation_id: int,
    body: ConvertTicketRequest,
    db: AsyncSession = Depends(get_db),
    current_user: UserResponse = Depends(get_current_user),
):
    """Create a WHMCS ticket from the conversation transcript + AI summary."""
    await _require_agent_seat(db, current_user)
    conv = await _conversation_or_404(db, conversation_id)
    if conv.linked_ticket_id:
        raise HTTPException(status_code=409, detail="Already linked to a ticket.")

    history = await _messages_for(db, conversation_id, include_internal=False)

    if not conv.ai_summary:
        conv.ai_summary = await summarize_conversation(db, conv, history)

    transcript_lines: List[str] = []
    for m in history:
        who = {
            "visitor": conv.visitor_name or "Visitor",
            "ai": "DoitEasy Assistant",
            "agent": m.sender_name or "Agent",
            "system": "System",
        }.get(m.sender_type, m.sender_type)
        ts = m.created_at.isoformat() if m.created_at else ""
        transcript_lines.append(f"[{ts}] {who}: {m.content}")
    transcript = "\n".join(transcript_lines) or "(no messages)"

    ticket_body = (
        "This ticket was created from a live chat.\n\n"
        f"AI Summary:\n{conv.ai_summary}\n\n"
        f"Transcript:\n{transcript}"
    )

    # WHMCS OpenTicket
    subject = (body.subject or conv.subject or "Chat conversation")[:200]
    payload = {
        "subject": subject,
        "message": ticket_body,
        "priority": (body.priority or conv.priority or "Medium").capitalize(),
        "name": conv.visitor_name or "Guest",
    }
    if conv.whmcs_client_id:
        payload["clientid"] = conv.whmcs_client_id
    if conv.visitor_email:
        payload["email"] = conv.visitor_email
    # WHMCS requires deptid. Pick 1 as default; admin can pre-configure mapping later.
    whmcs_dept_id = 1
    if conv.department_id:
        dept = await db.get(Support_departments, conv.department_id)
        if dept and dept.slug:
            # If admin stored a numeric deptid in description (convention), use it.
            # Otherwise stay on default and rely on WHMCS-side routing.
            pass
    payload["deptid"] = whmcs_dept_id

    try:
        resp = await call_whmcs("OpenTicket", payload)
    except HTTPException:
        raise
    except Exception as exc:  # noqa: BLE001
        logger.exception("OpenTicket failed")
        raise HTTPException(status_code=502, detail=f"Failed to create ticket: {exc}")

    ticket_id = resp.get("id") or resp.get("tid")
    try:
        ticket_id_int = int(ticket_id) if ticket_id is not None else None
    except (TypeError, ValueError):
        ticket_id_int = None

    conv.linked_ticket_id = ticket_id_int
    conv.status = "resolved"
    await db.flush()
    db.add(
        Chat_messages(
            conversation_id=conv.id,
            sender_type="system",
            sender_name="System",
            content=f"Converted to WHMCS ticket #{ticket_id or '?'}.",
            is_internal_note=False,
        )
    )
    await db.flush()
    await db.commit()
    return {
        "conversation": _conversation_to_dict(conv),
        "whmcs_ticket_id": ticket_id,
        "whmcs_response": {k: v for k, v in resp.items() if k != "result"},
    }


@router.get("/agent/conversations")
async def agent_list(
    db: AsyncSession = Depends(get_db),
    current_user: UserResponse = Depends(get_current_user),
    scope: str = Query("unassigned", description="unassigned|mine|all|resolved"),
    skip: int = 0,
    limit: int = 50,
):
    seat = await _require_agent_seat(db, current_user)

    base = select(Chat_conversations)

    if scope == "unassigned":
        base = base.where(
            Chat_conversations.status.in_(["queued", "ai"]),
            Chat_conversations.assigned_seat_id.is_(None),
        )
    elif scope == "mine":
        base = base.where(Chat_conversations.assigned_seat_id == seat.id)
    elif scope == "resolved":
        base = base.where(Chat_conversations.status.in_(["resolved", "closed"]))
    elif scope == "all":
        if not _is_admin_like(seat):
            # Restrict plain agents to their department.
            if seat.department_id is not None:
                base = base.where(
                    or_(
                        Chat_conversations.assigned_seat_id == seat.id,
                        Chat_conversations.department_id == seat.department_id,
                    )
                )
            else:
                base = base.where(Chat_conversations.assigned_seat_id == seat.id)
    else:
        raise HTTPException(status_code=400, detail="Invalid scope.")

    total = (
        await db.execute(select(func.count()).select_from(base.subquery()))
    ).scalar_one()
    rows = (
        await db.execute(
            base.order_by(desc(Chat_conversations.last_message_at))
            .offset(skip)
            .limit(min(limit, 200))
        )
    ).scalars().all()
    return {
        "items": [_conversation_to_dict(c) for c in rows],
        "total": total,
        "skip": skip,
        "limit": limit,
    }


# ==========================================================
# Client context sidebar (agent view)
# ==========================================================
@router.get("/agent/conversations/{conversation_id}/whmcs-context")
async def agent_whmcs_context(
    conversation_id: int,
    db: AsyncSession = Depends(get_db),
    current_user: UserResponse = Depends(get_current_user),
):
    """Pull a lightweight WHMCS snapshot for the visitor: basic profile, active
    services, open invoices, domains. Uses `call_whmcs_safe` so individual
    action-permission errors don't break the sidebar.
    """
    await _require_agent_seat(db, current_user)
    conv = await _conversation_or_404(db, conversation_id)

    email = (conv.visitor_email or "").strip().lower()
    if not email:
        return {"client": None, "services": [], "invoices": [], "domains": []}

    client = None
    try:
        client = await get_client_by_email(email)
    except HTTPException:
        client = None

    client_id = None
    if client and isinstance(client, dict):
        client_id = client.get("userid") or client.get("id")

    services_resp = await call_whmcs_safe(
        "GetClientsProducts", {"clientid": client_id} if client_id else {"email": email}
    )
    invoices_resp = await call_whmcs_safe(
        "GetInvoices",
        {"userid": client_id} if client_id else {"email": email},
    )
    domains_resp = await call_whmcs_safe(
        "GetClientsDomains",
        {"clientid": client_id} if client_id else {"email": email},
    )

    def _items(resp, container, inner):
        if not resp or not isinstance(resp, dict):
            return []
        c = resp.get(container) or {}
        return (c or {}).get(inner) or []

    return {
        "client": client,
        "services": _items(services_resp, "products", "product"),
        "invoices": _items(invoices_resp, "invoices", "invoice"),
        "domains": _items(domains_resp, "domains", "domain"),
    }


# ==========================================================
# Seat / department / routing-rule management (admin-only)
# ==========================================================
def _is_platform_admin(user: UserResponse) -> bool:
    return user.role == "admin"


class SeatCreateRequest(BaseModel):
    display_name: str
    email: str
    role: str = "support_agent"
    department_id: Optional[int] = None
    chat_limit: int = 5
    working_hours: Optional[Dict[str, Any]] = None
    permissions: Optional[Dict[str, Any]] = None


class SeatUpdateRequest(BaseModel):
    display_name: Optional[str] = None
    role: Optional[str] = None
    department_id: Optional[int] = None
    status: Optional[str] = None
    chat_limit: Optional[int] = None
    working_hours: Optional[Dict[str, Any]] = None
    permissions: Optional[Dict[str, Any]] = None


@router.post("/admin/seats")
async def admin_create_seat(
    body: SeatCreateRequest,
    db: AsyncSession = Depends(get_db),
    current_user: UserResponse = Depends(get_current_user),
):
    if not _is_platform_admin(current_user):
        raise HTTPException(status_code=403, detail="Admin access required.")
    if body.role not in AGENT_ROLES:
        raise HTTPException(status_code=400, detail="Invalid role.")

    seat = Support_seats(
        display_name=body.display_name.strip()[:120],
        email=body.email.strip().lower(),
        role=body.role,
        department_id=body.department_id,
        status="active",
        chat_limit=max(1, min(100, int(body.chat_limit or 5))),
        working_hours=json.dumps(body.working_hours or {}),
        permissions=json.dumps(body.permissions or {}),
    )
    db.add(seat)
    await db.flush()
    await db.commit()
    return {"seat": _seat_to_dict(seat)}


@router.patch("/admin/seats/{seat_id}")
async def admin_update_seat(
    seat_id: int,
    body: SeatUpdateRequest,
    db: AsyncSession = Depends(get_db),
    current_user: UserResponse = Depends(get_current_user),
):
    if not _is_platform_admin(current_user):
        raise HTTPException(status_code=403, detail="Admin access required.")
    seat = await db.get(Support_seats, seat_id)
    if seat is None:
        raise HTTPException(status_code=404, detail="Seat not found.")

    if body.display_name is not None:
        seat.display_name = body.display_name.strip()[:120]
    if body.role is not None:
        if body.role not in AGENT_ROLES:
            raise HTTPException(status_code=400, detail="Invalid role.")
        seat.role = body.role
    if body.department_id is not None:
        seat.department_id = body.department_id
    if body.status is not None:
        if body.status not in {"active", "suspended"}:
            raise HTTPException(status_code=400, detail="Invalid status.")
        seat.status = body.status
    if body.chat_limit is not None:
        seat.chat_limit = max(1, min(100, int(body.chat_limit)))
    if body.working_hours is not None:
        seat.working_hours = json.dumps(body.working_hours)
    if body.permissions is not None:
        seat.permissions = json.dumps(body.permissions)
    await db.flush()
    await db.commit()
    return {"seat": _seat_to_dict(seat)}


@router.delete("/admin/seats/{seat_id}")
async def admin_delete_seat(
    seat_id: int,
    db: AsyncSession = Depends(get_db),
    current_user: UserResponse = Depends(get_current_user),
):
    if not _is_platform_admin(current_user):
        raise HTTPException(status_code=403, detail="Admin access required.")
    seat = await db.get(Support_seats, seat_id)
    if seat is None:
        raise HTTPException(status_code=404, detail="Seat not found.")
    await db.delete(seat)
    await db.commit()
    return {"deleted": seat_id}


@router.get("/admin/seats")
async def admin_list_seats(
    db: AsyncSession = Depends(get_db),
    current_user: UserResponse = Depends(get_current_user),
):
    if not _is_platform_admin(current_user):
        raise HTTPException(status_code=403, detail="Admin access required.")
    rows = (
        await db.execute(select(Support_seats).order_by(Support_seats.id.asc()))
    ).scalars().all()

    # Attach availability info.
    avail_rows = (
        await db.execute(select(Agent_availability))
    ).scalars().all()
    avail_by_seat: Dict[int, Agent_availability] = {a.seat_id: a for a in avail_rows}
    out = []
    for s in rows:
        d = _seat_to_dict(s)
        a = avail_by_seat.get(s.id)
        d["availability"] = (
            {
                "status": a.status,
                "last_heartbeat_at": a.last_heartbeat_at.isoformat()
                if a.last_heartbeat_at
                else None,
            }
            if a
            else {"status": "offline", "last_heartbeat_at": None}
        )
        out.append(d)
    return {"items": out}


def _seat_to_dict(s: Support_seats) -> Dict[str, Any]:
    def _parse(val: Optional[str]) -> Any:
        if not val:
            return {}
        try:
            return json.loads(val)
        except (ValueError, TypeError):
            return {}

    return {
        "id": s.id,
        "user_id": s.user_id,
        "display_name": s.display_name,
        "email": s.email,
        "role": s.role,
        "department_id": s.department_id,
        "status": s.status,
        "chat_limit": s.chat_limit,
        "working_hours": _parse(s.working_hours),
        "permissions": _parse(s.permissions),
    }


# -------- Departments (admin) --------
class DepartmentBody(BaseModel):
    name: str
    slug: Optional[str] = None
    description: Optional[str] = None
    is_active: bool = True


@router.get("/departments")
async def list_departments(db: AsyncSession = Depends(get_db)):
    rows = (
        await db.execute(
            select(Support_departments).order_by(Support_departments.id.asc())
        )
    ).scalars().all()
    return {
        "items": [
            {
                "id": r.id,
                "name": r.name,
                "slug": r.slug,
                "description": r.description,
                "is_active": bool(r.is_active),
            }
            for r in rows
        ]
    }


@router.post("/admin/departments")
async def admin_create_department(
    body: DepartmentBody,
    db: AsyncSession = Depends(get_db),
    current_user: UserResponse = Depends(get_current_user),
):
    if not _is_platform_admin(current_user):
        raise HTTPException(status_code=403, detail="Admin access required.")
    slug = (body.slug or body.name).lower().replace(" ", "-").strip()[:80] or "dept"
    row = Support_departments(
        name=body.name.strip()[:120],
        slug=slug,
        description=(body.description or "").strip() or None,
        is_active=bool(body.is_active),
    )
    db.add(row)
    await db.flush()
    await db.commit()
    return {
        "id": row.id,
        "name": row.name,
        "slug": row.slug,
        "description": row.description,
        "is_active": bool(row.is_active),
    }


@router.patch("/admin/departments/{dept_id}")
async def admin_update_department(
    dept_id: int,
    body: DepartmentBody,
    db: AsyncSession = Depends(get_db),
    current_user: UserResponse = Depends(get_current_user),
):
    if not _is_platform_admin(current_user):
        raise HTTPException(status_code=403, detail="Admin access required.")
    row = await db.get(Support_departments, dept_id)
    if row is None:
        raise HTTPException(status_code=404, detail="Department not found.")
    row.name = body.name.strip()[:120]
    if body.slug:
        row.slug = body.slug.lower().replace(" ", "-").strip()[:80]
    row.description = (body.description or "").strip() or None
    row.is_active = bool(body.is_active)
    await db.flush()
    await db.commit()
    return {
        "id": row.id,
        "name": row.name,
        "slug": row.slug,
        "description": row.description,
        "is_active": bool(row.is_active),
    }


@router.delete("/admin/departments/{dept_id}")
async def admin_delete_department(
    dept_id: int,
    db: AsyncSession = Depends(get_db),
    current_user: UserResponse = Depends(get_current_user),
):
    if not _is_platform_admin(current_user):
        raise HTTPException(status_code=403, detail="Admin access required.")
    row = await db.get(Support_departments, dept_id)
    if row is None:
        raise HTTPException(status_code=404, detail="Department not found.")
    await db.delete(row)
    await db.commit()
    return {"deleted": dept_id}


# -------- Routing rules (admin) --------
class RoutingRuleBody(BaseModel):
    name: str
    priority: int = 100
    match_conditions: Dict[str, Any] = Field(default_factory=dict)
    target_department_id: Optional[int] = None
    target_seat_id: Optional[int] = None
    is_active: bool = True


@router.get("/admin/routing-rules")
async def admin_list_routing_rules(
    db: AsyncSession = Depends(get_db),
    current_user: UserResponse = Depends(get_current_user),
):
    if not _is_platform_admin(current_user):
        raise HTTPException(status_code=403, detail="Admin access required.")
    rows = (
        await db.execute(
            select(Chat_routing_rules).order_by(Chat_routing_rules.priority.asc())
        )
    ).scalars().all()
    return {
        "items": [
            {
                "id": r.id,
                "name": r.name,
                "priority": r.priority,
                "match_conditions": json.loads(r.match_conditions or "{}"),
                "target_department_id": r.target_department_id,
                "target_seat_id": r.target_seat_id,
                "is_active": bool(r.is_active),
            }
            for r in rows
        ]
    }


@router.post("/admin/routing-rules")
async def admin_create_routing_rule(
    body: RoutingRuleBody,
    db: AsyncSession = Depends(get_db),
    current_user: UserResponse = Depends(get_current_user),
):
    if not _is_platform_admin(current_user):
        raise HTTPException(status_code=403, detail="Admin access required.")
    row = Chat_routing_rules(
        name=body.name.strip()[:120],
        priority=int(body.priority),
        match_conditions=json.dumps(body.match_conditions or {}),
        target_department_id=body.target_department_id,
        target_seat_id=body.target_seat_id,
        is_active=bool(body.is_active),
    )
    db.add(row)
    await db.flush()
    await db.commit()
    return {"id": row.id}


@router.patch("/admin/routing-rules/{rule_id}")
async def admin_update_routing_rule(
    rule_id: int,
    body: RoutingRuleBody,
    db: AsyncSession = Depends(get_db),
    current_user: UserResponse = Depends(get_current_user),
):
    if not _is_platform_admin(current_user):
        raise HTTPException(status_code=403, detail="Admin access required.")
    row = await db.get(Chat_routing_rules, rule_id)
    if row is None:
        raise HTTPException(status_code=404, detail="Rule not found.")
    row.name = body.name.strip()[:120]
    row.priority = int(body.priority)
    row.match_conditions = json.dumps(body.match_conditions or {})
    row.target_department_id = body.target_department_id
    row.target_seat_id = body.target_seat_id
    row.is_active = bool(body.is_active)
    await db.flush()
    await db.commit()
    return {"id": row.id}


@router.delete("/admin/routing-rules/{rule_id}")
async def admin_delete_routing_rule(
    rule_id: int,
    db: AsyncSession = Depends(get_db),
    current_user: UserResponse = Depends(get_current_user),
):
    if not _is_platform_admin(current_user):
        raise HTTPException(status_code=403, detail="Admin access required.")
    row = await db.get(Chat_routing_rules, rule_id)
    if row is None:
        raise HTTPException(status_code=404, detail="Rule not found.")
    await db.delete(row)
    await db.commit()
    return {"deleted": rule_id}


# ==========================================================
# Availability heartbeat
# ==========================================================
class HeartbeatRequest(BaseModel):
    status: str = "online"


@router.post("/availability/heartbeat")
async def availability_heartbeat(
    body: HeartbeatRequest,
    db: AsyncSession = Depends(get_db),
    current_user: UserResponse = Depends(get_current_user),
):
    seat = await _require_agent_seat(db, current_user)
    if body.status not in {"online", "busy", "away", "offline"}:
        raise HTTPException(status_code=400, detail="Invalid status.")

    stmt = select(Agent_availability).where(Agent_availability.seat_id == seat.id)
    avail = (await db.execute(stmt)).scalar_one_or_none()
    now = datetime.now(timezone.utc)
    if avail is None:
        avail = Agent_availability(
            seat_id=seat.id, status=body.status, last_heartbeat_at=now
        )
        db.add(avail)
    else:
        avail.status = body.status
        avail.last_heartbeat_at = now

    swept = await sweep_offline(db)
    await db.flush()
    await db.commit()
    return {
        "seat_id": seat.id,
        "status": avail.status,
        "last_heartbeat_at": avail.last_heartbeat_at.isoformat(),
        "auto_swept_offline": swept,
    }


@router.get("/availability/me")
async def availability_me(
    db: AsyncSession = Depends(get_db),
    current_user: UserResponse = Depends(get_current_user),
):
    seat = await _require_agent_seat(db, current_user)
    stmt = select(Agent_availability).where(Agent_availability.seat_id == seat.id)
    avail = (await db.execute(stmt)).scalar_one_or_none()

    # Current chat count (assigned + not closed)
    count_stmt = select(func.count(Chat_conversations.id)).where(
        Chat_conversations.assigned_seat_id == seat.id,
        Chat_conversations.status.in_(["assigned", "queued"]),
    )
    current_chats = (await db.execute(count_stmt)).scalar_one()

    return {
        "seat": _seat_to_dict(seat),
        "availability": {
            "status": avail.status if avail else "offline",
            "last_heartbeat_at": (
                avail.last_heartbeat_at.isoformat()
                if avail and avail.last_heartbeat_at
                else None
            ),
        },
        "current_chats": current_chats,
        "chat_limit": seat.chat_limit or 5,
    }


# ==========================================================
# KB search + management
# ==========================================================
@router.get("/kb/search")
async def kb_search(
    q: str = Query(..., min_length=2),
    limit: int = Query(5, ge=1, le=20),
    db: AsyncSession = Depends(get_db),
):
    return {"items": await search_kb(db, q, limit=limit)}


class KBArticleBody(BaseModel):
    title: str
    content: str
    source: str = "manual"
    slug: Optional[str] = None
    source_url: Optional[str] = None
    tags: Optional[str] = None
    is_published: bool = True


@router.post("/admin/kb")
async def admin_create_kb(
    body: KBArticleBody,
    db: AsyncSession = Depends(get_db),
    current_user: UserResponse = Depends(get_current_user),
):
    if not _is_platform_admin(current_user):
        raise HTTPException(status_code=403, detail="Admin access required.")
    if body.source not in {"website", "whmcs_product", "faq", "manual"}:
        raise HTTPException(status_code=400, detail="Invalid source.")
    slug = (body.slug or body.title).lower().replace(" ", "-").strip()[:120] or "article"
    row = Kb_articles(
        title=body.title.strip()[:200],
        slug=slug,
        source=body.source,
        source_url=body.source_url,
        content=body.content,
        tags=(body.tags or None),
        is_published=bool(body.is_published),
    )
    db.add(row)
    await db.flush()
    await db.commit()
    return {"id": row.id}


@router.patch("/admin/kb/{article_id}")
async def admin_update_kb(
    article_id: int,
    body: KBArticleBody,
    db: AsyncSession = Depends(get_db),
    current_user: UserResponse = Depends(get_current_user),
):
    if not _is_platform_admin(current_user):
        raise HTTPException(status_code=403, detail="Admin access required.")
    row = await db.get(Kb_articles, article_id)
    if row is None:
        raise HTTPException(status_code=404, detail="Article not found.")
    row.title = body.title.strip()[:200]
    row.content = body.content
    row.source = body.source
    row.source_url = body.source_url
    row.tags = body.tags or None
    row.is_published = bool(body.is_published)
    if body.slug:
        row.slug = body.slug.lower().replace(" ", "-").strip()[:120]
    await db.flush()
    await db.commit()
    return {"id": row.id}


@router.delete("/admin/kb/{article_id}")
async def admin_delete_kb(
    article_id: int,
    db: AsyncSession = Depends(get_db),
    current_user: UserResponse = Depends(get_current_user),
):
    if not _is_platform_admin(current_user):
        raise HTTPException(status_code=403, detail="Admin access required.")
    row = await db.get(Kb_articles, article_id)
    if row is None:
        raise HTTPException(status_code=404, detail="Article not found.")
    await db.delete(row)
    await db.commit()
    return {"deleted": article_id}


@router.get("/admin/kb")
async def admin_list_kb(
    db: AsyncSession = Depends(get_db),
    current_user: UserResponse = Depends(get_current_user),
    skip: int = 0,
    limit: int = 100,
):
    if not _is_platform_admin(current_user):
        raise HTTPException(status_code=403, detail="Admin access required.")
    total = (await db.execute(select(func.count(Kb_articles.id)))).scalar_one()
    rows = (
        await db.execute(
            select(Kb_articles)
            .order_by(desc(Kb_articles.id))
            .offset(skip)
            .limit(min(limit, 500))
        )
    ).scalars().all()
    return {
        "items": [
            {
                "id": r.id,
                "title": r.title,
                "slug": r.slug,
                "source": r.source,
                "source_url": r.source_url,
                "tags": r.tags,
                "is_published": bool(r.is_published),
                "content": r.content,
                "updated_at": r.updated_at.isoformat() if r.updated_at else None,
            }
            for r in rows
        ],
        "total": total,
        "skip": skip,
        "limit": limit,
    }


@router.post("/admin/kb/ingest-whmcs-products")
async def admin_ingest_whmcs_products(
    db: AsyncSession = Depends(get_db),
    current_user: UserResponse = Depends(get_current_user),
):
    """Pull WHMCS products and write each as a KB article."""
    if not _is_platform_admin(current_user):
        raise HTTPException(status_code=403, detail="Admin access required.")
    resp = await call_whmcs_safe("GetProducts", {})
    if not resp:
        raise HTTPException(status_code=502, detail="WHMCS products fetch failed.")
    products = ((resp.get("products") or {}).get("product")) or []
    created = 0
    for p in products:
        title = (p.get("name") or "").strip()
        if not title:
            continue
        desc = (p.get("description") or "").strip()
        content = (
            f"{desc}\n\n"
            f"Pricing is managed in WHMCS. Product ID: {p.get('pid')}. "
            f"Type: {p.get('type') or 'N/A'}."
        )
        slug = f"whmcs-product-{p.get('pid')}"
        # Upsert by slug.
        stmt = select(Kb_articles).where(Kb_articles.slug == slug)
        existing = (await db.execute(stmt)).scalar_one_or_none()
        if existing:
            existing.title = title[:200]
            existing.content = content
            existing.source = "whmcs_product"
            existing.is_published = True
        else:
            db.add(
                Kb_articles(
                    title=title[:200],
                    slug=slug,
                    source="whmcs_product",
                    content=content,
                    is_published=True,
                )
            )
            created += 1
    await db.flush()
    await db.commit()
    return {"imported": created, "total_products": len(products)}


# ==========================================================
# Attachment upload (thin proxy to Atoms object storage)
# ==========================================================
class AttachmentRegisterRequest(BaseModel):
    conversation_id: int
    object_key: str
    file_name: str
    mime_type: Optional[str] = None
    size_bytes: Optional[int] = None
    url: Optional[str] = None


@router.post("/attachments/register")
async def register_attachment(
    body: AttachmentRegisterRequest,
    db: AsyncSession = Depends(get_db),
):
    """Frontend uploads via web-sdk storage, then POSTs the object_key here so
    we can persist attachment metadata linked to the conversation.
    """
    conv = await _conversation_or_404(db, body.conversation_id)
    row = Chat_attachments(
        conversation_id=conv.id,
        object_key=body.object_key,
        file_name=body.file_name[:200],
        mime_type=body.mime_type,
        size_bytes=body.size_bytes,
        url=body.url,
    )
    db.add(row)
    await db.flush()
    await db.commit()
    return {
        "id": row.id,
        "conversation_id": row.conversation_id,
        "object_key": row.object_key,
        "file_name": row.file_name,
        "mime_type": row.mime_type,
        "size_bytes": row.size_bytes,
        "url": row.url,
    }


# ==========================================================
# Missed chat notifications + analytics
# ==========================================================
@router.post("/admin/notifications/missed-chat")
async def admin_notify_missed_chats(
    db: AsyncSession = Depends(get_db),
    current_user: UserResponse = Depends(get_current_user),
    older_than_minutes: int = Query(10, ge=1, le=1440),
):
    """Find queued chats older than N minutes with no assigned seat and email
    all active admin/supervisor seats. Returns a list of conversation ids.
    """
    if not _is_platform_admin(current_user):
        raise HTTPException(status_code=403, detail="Admin access required.")

    cutoff = datetime.now(timezone.utc) - timedelta(minutes=older_than_minutes)
    stmt = select(Chat_conversations).where(
        Chat_conversations.status == "queued",
        Chat_conversations.assigned_seat_id.is_(None),
        Chat_conversations.last_message_at < cutoff,
    )
    missed = (await db.execute(stmt)).scalars().all()
    if not missed:
        return {"notified": 0, "conversations": []}

    recipient_stmt = select(Support_seats).where(
        Support_seats.status == "active",
        Support_seats.role.in_(["admin", "supervisor"]),
    )
    recipients = (await db.execute(recipient_stmt)).scalars().all()
    recipient_emails = [r.email for r in recipients if r.email]

    if not recipient_emails:
        return {"notified": 0, "conversations": [c.id for c in missed]}

    body_lines = [
        f"#{c.id} — {c.visitor_name or 'Visitor'} <{c.visitor_email or 'n/a'}> "
        f"(waiting since {c.last_message_at.isoformat() if c.last_message_at else 'n/a'})"
        for c in missed
    ]
    try:
        await send_email(
            to_addrs=recipient_emails,
            subject=f"[DoitEasy Support] {len(missed)} unattended chat(s)",
            text_body="These chats have been waiting for a human agent:\n\n"
            + "\n".join(body_lines)
            + "\n\nOpen the inbox: /admin/support/inbox",
        )
    except Exception as exc:  # noqa: BLE001
        logger.warning("Missed-chat notification email failed: %s", exc)
        return {"notified": 0, "error": str(exc), "conversations": [c.id for c in missed]}

    return {
        "notified": len(recipient_emails),
        "conversations": [c.id for c in missed],
    }


@router.get("/admin/analytics")
async def admin_analytics(
    db: AsyncSession = Depends(get_db),
    current_user: UserResponse = Depends(get_current_user),
    window_days: int = Query(7, ge=1, le=365),
    department_id: Optional[int] = Query(None),
):
    if not _is_platform_admin(current_user):
        raise HTTPException(status_code=403, detail="Admin access required.")
    return await compute_analytics(db, window_days=window_days, department_id=department_id)