# @File: backend/services/support_chat.py
# @Desc: Core business-logic helpers for the AI Chat Support System.
#        - AI reply generation with KB context + safety filter
#        - Agent reply suggestion
#        - Conversation summarization
#        - Lightweight KB search (hybrid: exact/ILIKE text match with scoring)
#        - Routing-rule evaluator
#        - Availability heartbeat / auto-offline sweeper
#        - Analytics rollup
import json
import logging
import re
from datetime import date, datetime, timedelta, timezone
from typing import Any, Dict, List, Optional, Tuple

from sqlalchemy import and_, desc, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession

from models.agent_availability import Agent_availability
from models.chat_analytics_daily import Chat_analytics_daily
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.aihub import ChatMessage, GenTxtRequest
from services.aihub import AIHubService

logger = logging.getLogger(__name__)

AI_MODEL = "gpt-5.4"

# Strings we MUST never let the AI surface back to the visitor.
# Matching is case-insensitive and uses word-boundary-ish rules so we don't
# mangle normal prose (e.g. "the client was identified" stays fine).
_FORBIDDEN_PATTERNS: List[re.Pattern] = [
    re.compile(r"\bidentifier\s*[:=]\s*\S+", re.IGNORECASE),
    re.compile(r"\bsecret\s*[:=]\s*\S+", re.IGNORECASE),
    re.compile(r"\bapi[\s_-]?key\s*[:=]?\s*\S*", re.IGNORECASE),
    re.compile(r"\bwhmcs_api_\w+", re.IGNORECASE),
    re.compile(r"\bsmtp_(?:host|port|username|password|from_\w+)\b", re.IGNORECASE),
    re.compile(r"\b(?:stripe|openai|aws|google)_[a-z_]*key\b", re.IGNORECASE),
    re.compile(r"\benv(?:ironment)?\s+variable[s]?\b", re.IGNORECASE),
    re.compile(r"-----BEGIN [A-Z ]+-----"),
]


def _contains_secret_leak(text: str) -> bool:
    if not text:
        return False
    for pat in _FORBIDDEN_PATTERNS:
        if pat.search(text):
            return True
    return False


def sanitize_ai_output(text: str) -> str:
    """Redact anything that looks like it might be a credential / env var.

    If the AI output tripped any of the forbidden patterns we replace the
    offending substrings with [redacted]. We also return a sanitized version
    even on clean input (noop) so callers can treat this uniformly.
    """
    if not text:
        return ""
    cleaned = text
    for pat in _FORBIDDEN_PATTERNS:
        cleaned = pat.sub("[redacted]", cleaned)
    return cleaned


# ---------- Knowledge base search ----------
async def search_kb(
    db: AsyncSession,
    query: str,
    limit: int = 5,
) -> List[Dict[str, Any]]:
    """Hybrid KB search. We do not have pgvector embedding here so we rank by
    overlapping token count in title+tags+content using plain SQL ILIKE plus
    a Python post-score pass. This is cheap, deterministic, and returns
    reasonable results for small/medium KBs.
    """
    if not query or not query.strip():
        return []

    tokens = [t.strip().lower() for t in re.split(r"\W+", query) if len(t.strip()) > 2]
    if not tokens:
        return []

    # Narrow candidate set with any-token ILIKE; score in Python.
    ilike_conds = []
    for tok in tokens[:6]:
        like = f"%{tok}%"
        ilike_conds.append(Kb_articles.title.ilike(like))
        ilike_conds.append(Kb_articles.content.ilike(like))
        ilike_conds.append(Kb_articles.tags.ilike(like))

    stmt = (
        select(Kb_articles)
        .where(and_(Kb_articles.is_published.is_(True), or_(*ilike_conds)))
        .limit(50)
    )
    rows = (await db.execute(stmt)).scalars().all()

    scored: List[Tuple[int, Kb_articles]] = []
    for row in rows:
        haystack = " ".join(
            [
                (row.title or "").lower(),
                (row.tags or "").lower(),
                (row.content or "").lower(),
            ]
        )
        score = sum(haystack.count(tok) for tok in tokens)
        # Title match boosts score.
        title_lower = (row.title or "").lower()
        score += sum(2 for tok in tokens if tok in title_lower)
        if score > 0:
            scored.append((score, row))

    scored.sort(key=lambda x: x[0], reverse=True)
    results: List[Dict[str, Any]] = []
    for score, row in scored[:limit]:
        results.append(
            {
                "id": row.id,
                "title": row.title,
                "slug": row.slug,
                "source": row.source,
                "score": score,
                "snippet": _snippet(row.content or "", tokens, max_len=400),
            }
        )
    return results


def _snippet(text: str, tokens: List[str], max_len: int = 400) -> str:
    if not text:
        return ""
    lower = text.lower()
    best_pos = -1
    for tok in tokens:
        pos = lower.find(tok)
        if pos != -1 and (best_pos == -1 or pos < best_pos):
            best_pos = pos
    if best_pos == -1:
        return text[:max_len].strip()
    start = max(0, best_pos - 80)
    end = min(len(text), best_pos + max_len - 80)
    prefix = "…" if start > 0 else ""
    suffix = "…" if end < len(text) else ""
    return prefix + text[start:end].strip() + suffix


# ---------- AI reply generation ----------
_SYSTEM_PROMPT = """You are DoitEasy's friendly support assistant.

Rules you MUST follow:
1. Be concise and helpful. Prefer short paragraphs and bullet lists.
2. Use ONLY the provided Knowledge Base context and the visitor's conversation
   to answer. If the KB does not cover the question, say so honestly and
   recommend handing off to a human agent.
3. NEVER reveal or invent:
   - WHMCS API identifiers or secrets
   - API keys of any kind (OpenAI, Stripe, SMTP, etc.)
   - Environment variable names or infrastructure details
   - Internal logs, database schemas, or developer tooling
4. For account-specific actions (change password, refund, cancel service,
   access invoices, modify DNS, etc.) set escalate=true because those need
   an authenticated support agent.
5. If the visitor seems frustrated, their question is ambiguous, or you are
   uncertain → ask ONE concise clarifying question OR set escalate=true.

Return STRICT JSON matching exactly this shape:
{
  "reply": "<string: your reply to the visitor, plain text>",
  "confidence": <float 0.0-1.0>,
  "escalate": <boolean>,
  "reasoning": "<string: 1-2 sentence internal reason, never shown to visitor>"
}
Do not wrap the JSON in code fences.
"""


def _build_kb_context(kb_hits: List[Dict[str, Any]]) -> str:
    if not kb_hits:
        return "No knowledge base articles matched this query."
    chunks = []
    for hit in kb_hits:
        chunks.append(
            f"### {hit['title']} (source={hit['source']})\n{hit['snippet']}"
        )
    return "Knowledge base context:\n\n" + "\n\n".join(chunks)


def _transcript_messages(
    history: List[Chat_messages], include_internal: bool = False
) -> List[ChatMessage]:
    """Convert DB messages to LLM chat history, skipping internal agent notes."""
    msgs: List[ChatMessage] = []
    for m in history:
        if m.is_internal_note and not include_internal:
            continue
        role = "user" if m.sender_type == "visitor" else "assistant"
        # system messages from us -> fold into assistant for LLM context.
        if m.sender_type == "system":
            role = "assistant"
        content = (m.content or "").strip()
        if content:
            msgs.append(ChatMessage(role=role, content=content))
    return msgs


def _parse_ai_json(raw: str) -> Dict[str, Any]:
    """Best-effort parse of the JSON object the LLM is asked to return.

    Falls back to a plain-text reply when JSON is malformed so the visitor
    still gets a sensible answer instead of a hard error.
    """
    if not raw:
        return {"reply": "", "confidence": 0.0, "escalate": True, "reasoning": "empty"}
    raw_stripped = raw.strip()
    # Strip accidental code fences.
    if raw_stripped.startswith("```"):
        raw_stripped = re.sub(r"^```(?:json)?\s*", "", raw_stripped)
        raw_stripped = re.sub(r"\s*```$", "", raw_stripped)
    try:
        parsed = json.loads(raw_stripped)
        if isinstance(parsed, dict) and "reply" in parsed:
            return {
                "reply": str(parsed.get("reply") or ""),
                "confidence": float(parsed.get("confidence") or 0.5),
                "escalate": bool(parsed.get("escalate") or False),
                "reasoning": str(parsed.get("reasoning") or ""),
            }
    except (ValueError, TypeError):
        pass
    # JSON failed — wrap the raw text as a low-confidence reply.
    return {
        "reply": raw_stripped[:1500],
        "confidence": 0.4,
        "escalate": False,
        "reasoning": "Fallback: AI did not return valid JSON.",
    }


async def generate_ai_reply(
    db: AsyncSession,
    conversation: Chat_conversations,
    history: List[Chat_messages],
) -> Dict[str, Any]:
    """Call gpt-5.4 to answer the most recent visitor message.

    Returns dict:
      { reply: str, confidence: float, escalate: bool, reasoning: str,
        kb_hits: list, was_redacted: bool }
    """
    # The latest visitor message drives the KB query.
    last_visitor_msg = next(
        (m for m in reversed(history) if m.sender_type == "visitor"), None
    )
    kb_query = (last_visitor_msg.content if last_visitor_msg else "") or (
        conversation.subject or ""
    )
    kb_hits = await search_kb(db, kb_query, limit=4)

    visitor_identity_bits: List[str] = []
    if conversation.visitor_name:
        visitor_identity_bits.append(f"name={conversation.visitor_name}")
    if conversation.whmcs_client_id:
        visitor_identity_bits.append(
            f"whmcs_client_id={conversation.whmcs_client_id} (logged in)"
        )
    else:
        visitor_identity_bits.append("not authenticated")
    identity_line = (
        "Visitor: " + ", ".join(visitor_identity_bits) if visitor_identity_bits else ""
    )

    messages: List[ChatMessage] = [
        ChatMessage(role="system", content=_SYSTEM_PROMPT),
        ChatMessage(
            role="system",
            content=f"{_build_kb_context(kb_hits)}\n\n{identity_line}".strip(),
        ),
    ]
    messages.extend(_transcript_messages(history, include_internal=False))

    service = AIHubService()
    request = GenTxtRequest(messages=messages, model=AI_MODEL)

    try:
        response = await service.gentxt(request)
        raw_content = response.content or ""
    except Exception as exc:  # noqa: BLE001
        logger.exception("AI reply generation failed")
        return {
            "reply": (
                "I'm having trouble reaching the AI right now. I'll hand this "
                "over to a human agent shortly."
            ),
            "confidence": 0.0,
            "escalate": True,
            "reasoning": f"ai_error: {exc}",
            "kb_hits": kb_hits,
            "was_redacted": False,
        }

    parsed = _parse_ai_json(raw_content)
    reply_text = parsed["reply"] or ""
    was_redacted = _contains_secret_leak(reply_text)
    safe_reply = sanitize_ai_output(reply_text)

    # Force escalation when output contained forbidden content or when
    # confidence is very low.
    escalate = bool(parsed["escalate"])
    if was_redacted:
        escalate = True
    if parsed["confidence"] < 0.35:
        escalate = True

    return {
        "reply": safe_reply,
        "confidence": parsed["confidence"],
        "escalate": escalate,
        "reasoning": parsed["reasoning"],
        "kb_hits": kb_hits,
        "was_redacted": was_redacted,
    }


# ---------- Suggest reply (for agents) ----------
_AGENT_SUGGEST_PROMPT = """You are helping a human support agent answer a
chat. Write ONE concise, professional reply the agent can send to the visitor.
Do not reveal any credentials, API keys, env variables, or internal logs.
Return only the reply text — no preamble, no explanations.
"""


async def generate_suggested_reply(
    db: AsyncSession,
    conversation: Chat_conversations,
    history: List[Chat_messages],
) -> str:
    kb_query = ""
    for m in reversed(history):
        if m.sender_type == "visitor":
            kb_query = m.content or ""
            break
    kb_hits = await search_kb(db, kb_query, limit=3)

    messages: List[ChatMessage] = [
        ChatMessage(role="system", content=_AGENT_SUGGEST_PROMPT),
        ChatMessage(role="system", content=_build_kb_context(kb_hits)),
    ]
    messages.extend(_transcript_messages(history, include_internal=True))

    service = AIHubService()
    request = GenTxtRequest(messages=messages, model=AI_MODEL)
    try:
        response = await service.gentxt(request)
        return sanitize_ai_output((response.content or "").strip())
    except Exception as exc:  # noqa: BLE001
        logger.exception("Suggested reply generation failed")
        return f"(Unable to generate suggestion: {exc})"


# ---------- Summarize conversation ----------
_SUMMARY_PROMPT = """Summarize the conversation between a visitor and the
support bot/agent in 4-8 bullet points for a human agent about to take over.
Include: (1) who the visitor is, (2) the problem, (3) what's already been
tried, (4) any account identifiers the visitor mentioned, (5) what the agent
should do next.
Never include credentials, API keys, or internal infrastructure details.
Return plain text bullets, no JSON."""


async def summarize_conversation(
    db: AsyncSession,
    conversation: Chat_conversations,
    history: List[Chat_messages],
) -> str:
    messages: List[ChatMessage] = [
        ChatMessage(role="system", content=_SUMMARY_PROMPT),
    ]
    messages.extend(_transcript_messages(history, include_internal=True))

    service = AIHubService()
    request = GenTxtRequest(messages=messages, model=AI_MODEL)
    try:
        response = await service.gentxt(request)
        return sanitize_ai_output((response.content or "").strip())
    except Exception as exc:  # noqa: BLE001
        logger.exception("Summarization failed")
        return f"(Unable to summarize: {exc})"


# ---------- Routing ----------
async def apply_routing(
    db: AsyncSession,
    conversation: Chat_conversations,
) -> Dict[str, Optional[int]]:
    """Return {target_department_id, target_seat_id} by evaluating active rules
    in priority order. A rule matches when every key in ``match_conditions``
    is present in ``ctx`` and equal/contained.
    """
    stmt = (
        select(Chat_routing_rules)
        .where(Chat_routing_rules.is_active.is_(True))
        .order_by(Chat_routing_rules.priority.asc())
    )
    rules = (await db.execute(stmt)).scalars().all()

    # Pull last visitor message for keyword matching.
    last_msg_stmt = (
        select(Chat_messages)
        .where(
            Chat_messages.conversation_id == conversation.id,
            Chat_messages.sender_type == "visitor",
        )
        .order_by(desc(Chat_messages.id))
        .limit(1)
    )
    last_msg = (await db.execute(last_msg_stmt)).scalar_one_or_none()
    last_text = (last_msg.content if last_msg else "") or ""

    ctx = {
        "department_id": conversation.department_id,
        "priority": conversation.priority,
        "authenticated": bool(conversation.whmcs_client_id),
        "text": last_text.lower(),
    }

    for rule in rules:
        try:
            conditions = json.loads(rule.match_conditions or "{}")
        except (ValueError, TypeError):
            continue
        if _rule_matches(conditions, ctx):
            return {
                "target_department_id": rule.target_department_id
                or conversation.department_id,
                "target_seat_id": rule.target_seat_id,
            }

    return {
        "target_department_id": conversation.department_id,
        "target_seat_id": None,
    }


def _rule_matches(conditions: Dict[str, Any], ctx: Dict[str, Any]) -> bool:
    for key, expected in (conditions or {}).items():
        if key == "keywords":
            if not isinstance(expected, list):
                return False
            text = ctx.get("text") or ""
            if not any(str(k).lower() in text for k in expected):
                return False
            continue
        if key == "authenticated":
            if bool(ctx.get("authenticated")) != bool(expected):
                return False
            continue
        if ctx.get(key) != expected:
            return False
    return True


# ---------- Availability sweeper ----------
AUTO_OFFLINE_SECONDS = 120


async def sweep_offline(db: AsyncSession) -> int:
    """Mark agents as offline when they haven't heartbeated recently.

    Called from the heartbeat endpoint as a cheap opportunistic sweep.
    Returns the number of rows flipped.
    """
    cutoff = datetime.now(timezone.utc) - timedelta(seconds=AUTO_OFFLINE_SECONDS)
    stmt = (
        select(Agent_availability)
        .where(
            Agent_availability.status != "offline",
            Agent_availability.last_heartbeat_at < cutoff,
        )
    )
    rows = (await db.execute(stmt)).scalars().all()
    flipped = 0
    for row in rows:
        row.status = "offline"
        flipped += 1
    if flipped:
        await db.flush()
    return flipped


# ---------- Analytics rollup ----------
async def compute_analytics(
    db: AsyncSession,
    window_days: int = 7,
    department_id: Optional[int] = None,
) -> Dict[str, Any]:
    """Aggregate metrics directly from chat_conversations + chat_messages for
    the last `window_days`. Cheap enough to run on demand for the admin page.
    """
    start = datetime.now(timezone.utc) - timedelta(days=window_days)

    conv_q = select(Chat_conversations).where(Chat_conversations.created_at >= start)
    if department_id:
        conv_q = conv_q.where(Chat_conversations.department_id == department_id)
    conversations = (await db.execute(conv_q)).scalars().all()

    total = len(conversations)
    ai_resolved = sum(
        1 for c in conversations if c.status in {"resolved", "closed"} and not c.assigned_seat_id
    )
    human_resolved = sum(
        1 for c in conversations if c.status in {"resolved", "closed"} and c.assigned_seat_id
    )
    escalations = sum(1 for c in conversations if c.assigned_seat_id)
    sla_breaches = sum(
        1
        for c in conversations
        if c.sla_due_at
        and c.status not in {"resolved", "closed"}
        and c.sla_due_at < datetime.now(timezone.utc)
    )

    # Avg first agent response (seconds) — distance from conversation start
    # to the first agent/ai message sent AFTER a visitor message.
    first_responses: List[int] = []
    resolution_times: List[int] = []
    for c in conversations:
        msgs_q = (
            select(Chat_messages)
            .where(Chat_messages.conversation_id == c.id)
            .order_by(Chat_messages.id.asc())
        )
        msgs = (await db.execute(msgs_q)).scalars().all()
        first_visitor = next((m for m in msgs if m.sender_type == "visitor"), None)
        first_agent = next(
            (m for m in msgs if m.sender_type in {"agent", "ai"}), None
        )
        if first_visitor and first_agent and first_agent.created_at and first_visitor.created_at:
            delta = (first_agent.created_at - first_visitor.created_at).total_seconds()
            if delta >= 0:
                first_responses.append(int(delta))
        if c.status in {"resolved", "closed"} and c.created_at and c.last_message_at:
            delta = (c.last_message_at - c.created_at).total_seconds()
            if delta >= 0:
                resolution_times.append(int(delta))

    def avg(xs: List[int]) -> int:
        return int(sum(xs) / len(xs)) if xs else 0

    # Volume per day for chart.
    volume: Dict[str, int] = {}
    for c in conversations:
        if c.created_at:
            key = c.created_at.date().isoformat()
            volume[key] = volume.get(key, 0) + 1

    return {
        "window_days": window_days,
        "total_chats": total,
        "ai_resolved": ai_resolved,
        "human_resolved": human_resolved,
        "escalations": escalations,
        "sla_breaches": sla_breaches,
        "avg_first_response_sec": avg(first_responses),
        "avg_resolution_sec": avg(resolution_times),
        "ai_deflection_rate": (
            round(ai_resolved / total, 3) if total else 0.0
        ),
        "volume_by_day": [
            {"date": k, "count": v} for k, v in sorted(volume.items())
        ],
    }


# ---------- Seed defaults ----------
DEFAULT_DEPARTMENTS = [
    {"name": "General Support", "slug": "general", "description": "General enquiries"},
    {"name": "Billing", "slug": "billing", "description": "Invoices, refunds, payments"},
    {"name": "Technical", "slug": "technical", "description": "Hosting, servers, DNS, email"},
]


async def ensure_default_departments(db: AsyncSession) -> None:
    existing = (
        await db.execute(select(func.count(Support_departments.id)))
    ).scalar_one()
    if existing and existing > 0:
        return
    for d in DEFAULT_DEPARTMENTS:
        db.add(
            Support_departments(
                name=d["name"],
                slug=d["slug"],
                description=d["description"],
                is_active=True,
            )
        )
    await db.flush()