import asyncio
import base64
import csv
import hashlib
import hmac
import html
import io
import logging
import os
import re
import secrets
import zipfile
from datetime import datetime, timedelta
from typing import Any
from urllib.parse import quote, unquote_to_bytes, urlparse
from xml.etree import ElementTree as ET
from time import perf_counter

import httpx
from dotenv import load_dotenv
from fastapi import BackgroundTasks, Depends, FastAPI, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response
from fastapi.staticfiles import StaticFiles
from litellm import acompletion, aembedding
from sqlalchemy import func, or_
from sqlalchemy.orm import Session

from app.core.config import get_settings, is_placeholder_secret
from app.core.migrations import apply_schema_updates
from app.core.paths import DOWNLOADS_DIR, RESOURCES_DIR, STATIC_DIR
from app.core.public_slug import (
    PUBLIC_SLUG_MAX_LENGTH,
    is_reserved_public_slug,
    is_valid_public_slug,
    normalize_public_slug,
)
from app.services.agent_config import (
    agent_config_payload,
    normalize_conversation_goals,
    normalize_handoff_config,
    normalize_negative_rules,
    normalize_temperature,
)
from app.services.agent_builder import build_agent_blueprint
from app.services.agent_capabilities import agent_memory_variables
from app.services.admin_auth import (
    admin_password_fingerprint,
    admin_user_payload,
    hash_admin_password,
    normalize_admin_role,
    normalize_admin_username,
    verify_admin_password,
)
from app.services.agent_readiness import apply_setup_progress, compute_readiness, kb_health as compute_kb_health
from app.services.agent_runtime import (
    _kb_context as runtime_kb_context,
    _notify_whatsapp_after_ticket_creation,
    get_ai_response as runtime_get_ai_response,
    maybe_notify_whatsapp_site_conversion,
)
from app.services.conditional_prompts import conditional_prompt_payload
from app.services.conversation_service import (
    append_message as append_conversation_message,
    get_or_create_conversation as service_get_or_create_conversation,
)
from app.services.conversation_memory import archive_conversation_snapshot, flush_pending_conversation_archive, hydrate_previous_context
from app.services.llm_security import sanitize_user_input
from app.services.client_portal import (
    CLIENT_PORTAL_BLOCKED_TOOL_TYPES,
    CLIENT_PORTAL_EXECUTABLE_TOOL_TYPES,
    PORTAL_MENU_PERMISSIONS,
    PORTAL_SESSION_COOKIE,
    ClientPortalContext,
    access_permissions,
    accessible_agent_ids,
    active_client_access_query,
    active_agent_access,
    clean_portal_text,
    client_portal_cookie_secure,
    default_permissions,
    hash_password,
    hash_token,
    invite_expiration,
    issue_token,
    log_client_portal_audit,
    normalize_client_slug,
    normalize_email,
    normalize_portal_username,
    normalize_permissions,
    organization_payload,
    permission_enabled,
    portal_agent_payload,
    redact_portal_value,
    request_fingerprint,
    session_expiration,
    superadmin_permissions,
    user_payload,
    verify_password,
    visible_menus,
)
from app.services.message_processor import process_agent_webhook_event, process_channel_webhook_event
from app.services.system_assistants import (
    apply_optimizer_patch,
    guide_chat,
    list_agent_versions,
    optimizer_preview,
    restore_agent_version,
    run_agent_test_suite,
)
from app.services.task_queue import enqueue_agent_webhook_event, enqueue_channel_webhook_event
from app.services.whatsapp_policy import is_group_jid, normalize_whatsapp_target_id, select_first_message_targets
from app.models import (
    Agent,
    AgentRun,
    AgentVersion,
    AdminUser,
    CRMAttendant,
    CRMTag,
    CRMTeam,
    Channel,
    ClientOrganization,
    ClientPortalAgentAccess,
    ClientPortalAllowedTarget,
    ClientPortalAuditLog,
    ClientPortalInvite,
    ClientPortalMembership,
    ClientPortalSession,
    ClientPortalUser,
    Conversation,
    DataTable,
    KnowledgeBase,
    KnowledgeBaseChunk,
    KnowledgeBaseDocument,
    KnowledgeBaseSource,
    MemoryItem,
    Message,
    SharedChatLink,
    SharedChatUsageEvent,
    SessionLocal,
    Squad,
    SquadMember,
    SystemAssistantRun,
    TableRecord,
    Ticket,
    ToolDefinition,
    ToolInvocation,
    WorkflowTrigger,
    WebhookEvent,
    create_tables,
    engine,
    get_db,
)
from app.services.tickets import choose_round_robin_attendant, maybe_create_handoff_ticket, ticket_payload


load_dotenv()


settings = get_settings()
EVOLUTION_API_URL = settings.evolution_api_url
EVOLUTION_API_KEY = settings.evolution_api_key
WEBHOOK_BASE_URL = settings.webhook_base_url
CONTEXT_MESSAGE_LIMIT = settings.context_message_limit

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
    datefmt="%Y-%m-%d %H:%M:%S",
)
logger = logging.getLogger("whatsapp_ai")
_WHATSAPP_TARGETS_CACHE: dict[int, tuple[float, dict[str, Any]]] = {}
SUPERADMIN_SESSION_COOKIE = "sm_superadmin_session"
ADMIN_SESSION_COOKIE = "sm_admin_session"
SUPERADMIN_SESSION_TTL_HOURS = 12
SUPERADMIN_PUBLIC_API_PATHS = {
    "/api/superadmin/auth/login",
    "/api/superadmin/auth/logout",
    "/api/superadmin/auth/me",
}
PUBLIC_API_PREFIXES = (
    "/api/client-portal/",
    "/api/chat/",
    "/api/site-agents/",
)

app = FastAPI(title="System Manager AI Studio Backend")
SITE_AGENT_ALLOWED_ORIGINS = [
    origin.strip()
    for origin in os.getenv(
        "SITE_AGENT_ALLOWED_ORIGINS",
        "https://systemmanager.com.br,https://www.systemmanager.com.br",
    ).split(",")
    if origin.strip()
]

BLOCKED_STATIC_SUFFIXES = {
    ".bat",
    ".db",
    ".env",
    ".log",
    ".md",
    ".ps1",
    ".py",
    ".sqlite",
    ".sqlite3",
}


def _url_is_https_public(value: str | None) -> bool:
    try:
        parsed = urlparse(str(value or "").strip())
    except Exception:
        return False
    host = (parsed.hostname or "").lower()
    if parsed.scheme != "https" or not host:
        return False
    return host not in {"localhost", "127.0.0.1", "0.0.0.0", "::1", "host.docker.internal"} and not host.endswith(".local")


def _production_deployment_mode() -> bool:
    mode = os.getenv("IA_INFRA_MODE", "").strip().lower()
    return os.getenv("VERCEL_ENV", "").strip().lower() == "production" or mode in {"production", "prod", "vercel"}


def _whatsapp_integration_enabled() -> bool:
    raw = os.getenv("WHATSAPP_INTEGRATION_ENABLED", "true").strip().lower()
    return raw not in {"0", "false", "no", "off", "disabled"}


def validate_production_deployment_config() -> None:
    if not _production_deployment_mode():
        return

    problems: list[str] = []
    for name in [
        "SUPERADMIN_API_TOKEN",
        "SUPERADMIN_SESSION_SECRET",
        "ADMIN_SESSION_SECRET",
        "SHARED_CHAT_IP_HASH_SALT",
    ]:
        if is_placeholder_secret(os.getenv(name)):
            problems.append(f"{name} ausente ou placeholder")

    whatsapp_enabled = _whatsapp_integration_enabled()
    if whatsapp_enabled and is_placeholder_secret(os.getenv("EVOLUTION_API_KEY")):
        problems.append("EVOLUTION_API_KEY ausente ou placeholder")

    database_url = os.getenv("DATABASE_URL", "").strip()
    db_host = (urlparse(database_url).hostname or "").lower() if database_url else ""
    if not database_url:
        problems.append("DATABASE_URL ausente")
    elif db_host in {"localhost", "127.0.0.1", "0.0.0.0", "::1", "host.docker.internal"} or db_host.endswith(".local"):
        problems.append("DATABASE_URL precisa apontar para host publico/gerenciado, nao localhost")

    public_url_names = ["WEBHOOK_BASE_URL", "APP_PUBLIC_BASE_URL", "PUBLIC_CHAT_BASE_URL"]
    if whatsapp_enabled:
        public_url_names.append("EVOLUTION_API_URL")
    for name in public_url_names:
        if not _url_is_https_public(os.getenv(name)):
            problems.append(f"{name} precisa ser HTTPS publico")

    if any(origin == "*" or origin.startswith("http://") for origin in SITE_AGENT_ALLOWED_ORIGINS):
        problems.append("SITE_AGENT_ALLOWED_ORIGINS nao pode usar '*' nem HTTP em producao")

    if problems:
        raise RuntimeError("Production deployment blocked: " + "; ".join(problems))


@app.middleware("http")
async def block_internal_static_files(request: Request, call_next):
    path = request.url.path.lower()
    if path.startswith("/static/") and any(path.endswith(suffix) for suffix in BLOCKED_STATIC_SUFFIXES):
        return Response(status_code=404)
    return await call_next(request)

app.add_middleware(
    CORSMiddleware,
    allow_origins=SITE_AGENT_ALLOWED_ORIGINS,
    allow_credentials=False,
    allow_methods=["GET", "POST", "OPTIONS"],
    allow_headers=["*"],
)
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")


def _api_requires_superadmin(path: str) -> bool:
    if not path.startswith("/api/"):
        return False
    if path in SUPERADMIN_PUBLIC_API_PATHS:
        return False
    return not any(path.startswith(prefix) for prefix in PUBLIC_API_PREFIXES)


@app.middleware("http")
async def superadmin_api_middleware(request: Request, call_next):
    if request.method != "OPTIONS" and _api_requires_superadmin(request.url.path):
        try:
            require_superadmin_request(request)
        except HTTPException as exc:
            return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail})
    return await call_next(request)

@app.on_event("startup")
def on_startup() -> None:
    validate_production_deployment_config()
    create_tables()
    apply_schema_updates(engine, logger)


def normalize_phone(raw_phone: str | None) -> str | None:
    if not raw_phone:
        return None

    phone = raw_phone.split("@", maxsplit=1)[0]
    return phone.replace("+", "").strip()


def dict_or_empty(value: Any) -> dict[str, Any]:
    return value if isinstance(value, dict) else {}


def coerce_webhook_payload(value: Any) -> dict[str, Any]:
    return value if isinstance(value, dict) else {"raw": value}


async def read_webhook_payload(request: Request) -> dict[str, Any]:
    try:
        raw_payload = await request.json()
    except Exception:
        body = await request.body()
        raw_payload = body.decode("utf-8", errors="replace")
    return coerce_webhook_payload(raw_payload)


def webhook_data_dict(payload: Any) -> dict[str, Any]:
    payload_dict = dict_or_empty(payload)
    data = payload_dict.get("data", payload_dict)
    return data if isinstance(data, dict) else {}


def webhook_key_dict(payload: Any) -> dict[str, Any]:
    payload_dict = dict_or_empty(payload)
    data = webhook_data_dict(payload_dict)
    key = data.get("key") or payload_dict.get("key")
    return key if isinstance(key, dict) else {}


def nested_dict(source: dict[str, Any], key: str) -> dict[str, Any]:
    value = source.get(key)
    return value if isinstance(value, dict) else {}


def first_text(*values: Any) -> str | None:
    for value in values:
        if value is None or isinstance(value, (dict, list, tuple, set)):
            continue
        text_value = str(value).strip()
        if text_value:
            return text_value
    return None


def clean_display_text(value: str | None) -> str | None:
    if value is None:
        return None
    value = str(value)
    try:
        fixed = value.encode("latin1").decode("utf-8")
    except UnicodeError:
        return value
    return fixed


def extract_keywords(value: str, limit: int = 18) -> list[str]:
    stopwords = {
        "para",
        "como",
        "com",
        "que",
        "uma",
        "por",
        "dos",
        "das",
        "isso",
        "esse",
        "essa",
        "sobre",
        "qual",
        "quais",
        "quando",
        "onde",
        "voce",
        "você",
        "the",
        "and",
        "for",
        "with",
    }
    words = [item.lower() for item in re.findall(r"[\wÀ-ÿ]{4,}", value or "")]
    ranked: dict[str, int] = {}
    for word in words:
        if word in stopwords:
            continue
        ranked[word] = ranked.get(word, 0) + 1
    return [word for word, _ in sorted(ranked.items(), key=lambda item: (-item[1], item[0]))[:limit]]


def extract_html_title(value: str, fallback: str = "") -> str:
    match = re.search(r"(?is)<title[^>]*>(.*?)</title>", value or "")
    if not match:
        return fallback
    return clean_display_text(strip_html(match.group(1))) or fallback


def strip_html(value: str) -> str:
    value = re.sub(r"(?is)<(script|style|noscript|svg|canvas|form).*?>.*?</\1>", " ", value or "")
    value = re.sub(r"(?is)<(nav|footer|aside)[^>]*>.*?</\1>", " ", value)
    value = re.sub(r"(?i)<h([1-6])[^>]*>", r"\n# ", value)
    value = re.sub(r"(?i)</(h[1-6]|p|li|section|article|div)>", "\n", value)
    value = re.sub(r"(?s)<[^>]+>", " ", value)
    lines = [re.sub(r"\s+", " ", line).strip() for line in value.splitlines()]
    return "\n".join(line for line in lines if line)


def estimate_token_count(value: str) -> int:
    return max(1, int(len(re.findall(r"\S+", value or "")) * 1.3))


def chunk_text(value: str, size: int | None = None, overlap: int | None = None) -> list[str]:
    max_tokens = int(os.getenv("KB_CHUNK_TOKENS", str(size or 700)))
    overlap_tokens = int(os.getenv("KB_CHUNK_OVERLAP_TOKENS", str(overlap or 120)))
    max_tokens = max(120, max_tokens)
    overlap_tokens = max(0, min(overlap_tokens, max_tokens // 2))

    paragraphs = [item.strip() for item in re.split(r"\n{1,}", value or "") if item.strip()]
    chunks: list[str] = []
    current_parts: list[str] = []
    current_tokens = 0

    for paragraph in paragraphs:
        words = paragraph.split()
        paragraph_tokens = estimate_token_count(paragraph)
        if paragraph_tokens > max_tokens:
            if current_parts:
                chunks.append("\n".join(current_parts).strip())
                current_parts = []
                current_tokens = 0
            step = max(1, max_tokens - overlap_tokens)
            for start in range(0, len(words), step):
                window = " ".join(words[start : start + max_tokens])
                if window.strip():
                    chunks.append(window.strip())
            continue
        if current_tokens + paragraph_tokens > max_tokens and current_parts:
            chunks.append("\n".join(current_parts).strip())
            carry_words = " ".join(current_parts).split()[-overlap_tokens:] if overlap_tokens else []
            current_parts = [" ".join(carry_words)] if carry_words else []
            current_tokens = estimate_token_count(current_parts[0]) if current_parts else 0
        current_parts.append(paragraph)
        current_tokens += paragraph_tokens

    if current_parts:
        chunks.append("\n".join(current_parts).strip())
    return [chunk for chunk in chunks if chunk.strip()]


def valid_embedding_key(agent: Agent) -> str | None:
    key = (
        os.getenv("EMBEDDING_API_KEY")
        or os.getenv("OPENAI_API_KEY")
        or (agent.ai_api_key if (agent.ai_model or "").startswith("openai") else None)
    )
    key = (key or "").strip()
    if not key or key.lower().startswith("your_"):
        return None
    return key


async def generate_kb_embedding(agent: Agent, value: str) -> list[float] | None:
    key = valid_embedding_key(agent)
    if not key:
        return None
    try:
        response = await aembedding(
            model=os.getenv("EMBEDDING_MODEL", "text-embedding-3-small"),
            input=[value[:8000]],
            api_key=key,
        )
        data = getattr(response, "data", None) or response.get("data", [])
        first = data[0] if data else {}
        embedding = first.get("embedding") if isinstance(first, dict) else getattr(first, "embedding", None)
        if embedding and len(embedding) != 1536:
            logger.warning("Embedding dimension %s does not match kb_chunks.vector(1536); skipping.", len(embedding))
            return None
        return embedding
    except Exception as exc:
        logger.warning("Embedding generation skipped: %s", exc)
        return None


async def extract_kb_content(source: dict[str, Any]) -> dict[str, Any]:
    source_type = source.get("source_type")
    content = source.get("content") or ""
    uri = source.get("uri") or ""
    mime_type = source.get("mime_type") or ""
    if source_type == "url" and uri:
        async with httpx.AsyncClient(timeout=20, follow_redirects=True) as client:
            response = await client.get(uri)
        response.raise_for_status()
        title = extract_html_title(response.text, uri)
        return {
            "title": title,
            "content": strip_html(response.text),
            "metadata": {"source_kind": "site", "url": uri},
        }
    if source_type == "file" and ("pdf" in mime_type or content.startswith("data:application/pdf")):
        try:
            from pypdf import PdfReader

            payload = content.split(",", 1)[1] if content.startswith("data:") else content
            DOWNLOADS_DIR.mkdir(exist_ok=True)
            pdf_path = str(DOWNLOADS_DIR / f"kb_{secrets.token_hex(8)}.pdf")
            with open(pdf_path, "wb") as pdf_file:
                pdf_file.write(base64.b64decode(payload))
            reader = PdfReader(pdf_path)
            return {
                "title": uri or "Arquivo PDF",
                "content": "\n".join(page.extract_text() or "" for page in reader.pages),
                "metadata": {"source_kind": "file", "mime_type": mime_type, "pages": len(reader.pages)},
            }
        except Exception:
            return {"title": uri or "Arquivo PDF", "content": f"Arquivo PDF adicionado: {uri}", "metadata": {"source_kind": "file"}}
    if source_type == "file" and ("wordprocessingml.document" in mime_type or uri.lower().endswith(".docx")):
        try:
            payload = content.split(",", 1)[1] if content.startswith("data:") else content
            with zipfile.ZipFile(io.BytesIO(base64.b64decode(payload))) as docx_file:
                xml = docx_file.read("word/document.xml").decode("utf-8", errors="ignore")
            text = re.sub(r"</w:p>", "\n", xml)
            text = re.sub(r"<[^>]+>", " ", text)
            text = re.sub(r"\s+", " ", text).strip()
            return {
                "title": uri or "Arquivo DOCX",
                "content": text,
                "metadata": {"source_kind": "file", "mime_type": mime_type},
            }
        except Exception:
            return {"title": uri or "Arquivo DOCX", "content": f"Arquivo DOCX adicionado: {uri}", "metadata": {"source_kind": "file"}}
    if source_type == "file" and _is_text_like_file(uri, mime_type, content):
        decoded = _decode_file_source_content(content)
        if decoded:
            text = decoded.decode("utf-8", errors="replace")
            if "html" in mime_type or uri.lower().endswith((".html", ".htm")):
                text = strip_html(text)
            text = re.sub(r"\r\n?", "\n", text).strip()
            return {
                "title": uri or "Arquivo de texto",
                "content": text or f"Arquivo vazio: {uri}",
                "metadata": {"source_kind": "file", "mime_type": mime_type, "bytes": len(decoded)},
            }
    if source_type == "file" and content.startswith("data:"):
        return {"title": uri or "Arquivo", "content": f"Arquivo adicionado: {uri}", "metadata": {"source_kind": "file", "mime_type": mime_type}}
    return {
        "title": uri or "Texto direto",
        "content": content or uri,
        "metadata": {"source_kind": source_type or "text", "mime_type": mime_type},
    }


def _decode_file_source_content(content: str) -> bytes:
    if not content:
        return b""
    if not content.startswith("data:"):
        return str(content).encode("utf-8", errors="replace")
    header, _, payload = content.partition(",")
    if not payload:
        return b""
    if ";base64" in header.lower():
        return base64.b64decode(payload)
    return unquote_to_bytes(payload)


def _is_text_like_file(uri: str, mime_type: str, content: str) -> bool:
    lowered_uri = (uri or "").lower()
    lowered_mime = (mime_type or "").lower()
    if lowered_uri.endswith((".txt", ".md", ".markdown", ".csv", ".json", ".xml", ".html", ".htm", ".yml", ".yaml", ".log")):
        return True
    if lowered_mime.startswith("text/"):
        return True
    return any(kind in lowered_mime for kind in ("json", "xml", "csv", "markdown", "yaml"))


async def builder_source_context(sources: list[dict[str, Any]], limit: int = 6) -> str:
    blocks: list[str] = []
    clean_sources = [item for item in (sources or []) if item.get("uri") or item.get("content")]
    for index, item in enumerate(clean_sources[:limit], start=1):
        try:
            extracted = await extract_kb_content(item)
            content = re.sub(r"\s+", " ", extracted.get("content") or "").strip()
            if not content:
                continue
            title = clean_display_text(extracted.get("title") or item.get("uri") or f"Fonte {index}")
            blocks.append(
                f"[Fonte {index}] Titulo: {title}\n"
                f"Origem: {item.get('uri') or item.get('source_type') or 'texto direto'}\n"
                f"Trecho real lido: {content[:1600]}"
            )
        except Exception as exc:
            logger.warning("Builder source preview failed for %r: %s", item.get("uri"), exc)
    return "\n\n".join(blocks)[:9000]


async def add_kb_sources(db: Session, agent: Agent, sources: list[dict[str, Any]]) -> None:
    clean_sources = [item for item in (sources or []) if item.get("uri") or item.get("content")]
    if not clean_sources:
        return
    kb = (
        db.query(KnowledgeBase)
        .filter(KnowledgeBase.agent_id == agent.id)
        .order_by(KnowledgeBase.id.asc())
        .first()
    )
    if not kb:
        kb = KnowledgeBase(agent_id=agent.id, name="Knowledge Base", description="Base ativada sem configuração.")
        db.add(kb)
        db.commit()
        db.refresh(kb)
    for item in clean_sources:
        source = KnowledgeBaseSource(
            kb_id=kb.id,
            source_type=(item.get("source_type") or "text").strip(),
            uri=(item.get("uri") or item.get("content") or "").strip()[:4000],
            status="ingested",
            source_metadata={"mime_type": item.get("mime_type"), "automatic": True, "informacoes_para_agente": True},
        )
        db.add(source)
        db.commit()
        db.refresh(source)
        try:
            extracted = await extract_kb_content(item)
        except Exception as exc:
            logger.error("KB source ingestion failed uri=%r: %s", item.get("uri"), exc)
            source.status = "pending"
            source.source_metadata = {**(source.source_metadata or {}), "ingestion_error": str(exc)}
            db.add(source)
            db.commit()
            extracted = {
                "title": item.get("uri") or "Fonte com erro",
                "content": item.get("content") or item.get("uri") or "",
                "metadata": {"ingestion_error": str(exc)},
            }
        text_content = re.sub(r"\s+\n", "\n", extracted.get("content") or "").strip()
        checksum_payload = f"{source.source_type}\n{source.uri}\n{text_content}".encode("utf-8", errors="ignore")
        checksum = hashlib.sha256(checksum_payload).hexdigest()
        duplicate = (
            db.query(KnowledgeBaseSource)
            .filter(KnowledgeBaseSource.kb_id == kb.id, KnowledgeBaseSource.checksum == checksum, KnowledgeBaseSource.id != source.id)
            .first()
        )
        source.checksum = checksum
        if duplicate:
            source.status = "duplicate"
            source.source_metadata = {
                **(source.source_metadata or {}),
                "duplicate_of_source_id": duplicate.id,
                "informacoes_para_agente": True,
            }
            db.add(source)
            db.commit()
            continue
        source_keywords = extract_keywords(text_content)
        source.source_metadata = {
            **(source.source_metadata or {}),
            **(extracted.get("metadata") or {}),
            "keywords": source_keywords,
            "informacoes_para_agente": {
                "title": extracted.get("title"),
                "summary": text_content[:500],
            },
        }
        source.last_ingested_at = datetime.utcnow()
        db.add(source)
        db.commit()
        document = KnowledgeBaseDocument(
            kb_id=kb.id,
            source_id=source.id,
            title=(extracted.get("title") or item.get("uri") or "Texto direto")[:255],
            document_metadata={
                "source_type": source.source_type,
                "keywords": source_keywords,
                "context_hierarchical": [kb.name, extracted.get("title") or item.get("uri") or "Texto direto"],
                "informacoes_para_agente": {
                    "title": extracted.get("title"),
                    "source": item.get("uri"),
                    "summary": text_content[:500],
                },
            },
        )
        db.add(document)
        db.commit()
        db.refresh(document)
        for index, chunk in enumerate(chunk_text(text_content), start=1):
            embedding = await generate_kb_embedding(agent, chunk)
            db.add(
                KnowledgeBaseChunk(
                    document_id=document.id,
                    content=chunk,
                    embedding=embedding,
                    token_count=estimate_token_count(chunk),
                    chunk_metadata={
                        "automatic": True,
                        "chunk_index": index,
                        "keywords": extract_keywords(chunk),
                        "source_title": document.title,
                        "context_hierarchical": [kb.name, document.title, f"chunk_{index}"],
                        "chunking": {
                            "strategy": "semantic_paragraph_with_overlap",
                            "max_tokens": int(os.getenv("KB_CHUNK_TOKENS", "700")),
                            "overlap_tokens": int(os.getenv("KB_CHUNK_OVERLAP_TOKENS", "120")),
                        },
                        "embedding_status": "generated" if embedding else "not_configured",
                        "embedding_model": os.getenv("EMBEDDING_MODEL", "text-embedding-3-small") if embedding else None,
                        "informacoes_para_agente": {
                            "title": document.title,
                            "trecho_relevante_resumido": chunk[:320],
                        },
                    },
                )
            )
        db.commit()


def upsert_agent_tool(
    db: Session,
    agent_id: int,
    tool_type: str,
    enabled: bool,
    config: dict[str, Any] | None = None,
) -> None:
    names = {
        "web_search": "Web Search",
        "knowledge_base": "Knowledge Base",
        "ticket_creation": "Criar Ticket",
        "contextual_memory": "Memória Contextual",
    }
    descriptions = {
        "web_search": "Pesquisa web para informações recentes, validação externa, notícias, preços e páginas públicas.",
        "knowledge_base": "Consulta documentos, URLs, arquivos e textos internos conectados ao agente.",
        "ticket_creation": "Cria ticket no CRM quando o usuário pedir humano ou quando o agente precisar escalar atendimento.",
        "contextual_memory": "Salva variáveis persistentes do usuário, como nome, número, email, problema e necessidade.",
    }
    tool = (
        db.query(ToolDefinition)
        .filter(ToolDefinition.agent_id == agent_id, ToolDefinition.tool_type == tool_type)
        .first()
    )
    if not tool:
        tool = ToolDefinition(agent_id=agent_id, name=names[tool_type], tool_type=tool_type)
    tool.name = names[tool_type]
    tool.description = descriptions[tool_type]
    tool.config = config or {}
    tool.enabled = enabled
    tool.requires_approval = False
    db.add(tool)


def enable_agent_knowledge_access(db: Session, agent: Agent) -> None:
    upsert_agent_tool(db, agent.id, "knowledge_base", True, {})
    db.commit()


async def apply_agent_tools(db: Session, agent: Agent, body: dict[str, Any]) -> None:
    if "tools" not in body:
        return
    tools = body.get("tools") or {}
    web = tools.get("web_search") or {}
    kb = tools.get("knowledge_base") or {}
    ticket = tools.get("ticket_creation") or {}
    memory = tools.get("contextual_memory") or {}
    upsert_agent_tool(db, agent.id, "web_search", bool(web.get("enabled")), {"prompt_rules": web.get("prompt_rules") or ""})
    upsert_agent_tool(db, agent.id, "knowledge_base", bool(kb.get("enabled")), {})
    if memory:
        upsert_agent_tool(
            db,
            agent.id,
            "contextual_memory",
            bool(memory.get("enabled")),
            {
                "variables": memory.get("variables") or memory.get("fields") or [],
                "mode": memory.get("mode") or "persistent",
            },
        )
    if ticket:
        upsert_agent_tool(
            db,
            agent.id,
            "ticket_creation",
            bool(ticket.get("enabled")),
            {"crm_team_id": ticket.get("crm_team_id") or ticket.get("team_id") or None},
        )
    db.commit()
    if kb.get("enabled"):
        await add_kb_sources(db, agent, body.get("kb_sources") or body.get("sources") or [])


def apply_agent_conditional_prompts(db: Session, agent_id: int, body: dict[str, Any]) -> None:
    if "conditional_prompts" not in body:
        return
    items = body.get("conditional_prompts") or []
    if not isinstance(items, list):
        return
    for item in items:
        if not isinstance(item, dict):
            continue
        name = (item.get("name") or "Regra condicional").strip()
        prompt = (item.get("prompt") or "").strip()
        condition = item.get("condition") or {}
        if not prompt or not isinstance(condition, dict):
            continue
        condition["agent_id"] = agent_id
        db.add(
            WorkflowTrigger(
                name=name,
                condition_json=condition,
                action_json={"type": "inject_prompt", "prompt": prompt},
                enabled=bool(item.get("enabled", True)),
            )
        )
    db.commit()


def extract_message_text(payload: dict[str, Any]) -> str | None:
    payload_dict = dict_or_empty(payload)
    data = webhook_data_dict(payload_dict)
    message = nested_dict(data, "message")

    text = first_text(
        payload_dict.get("message"),
        payload_dict.get("text"),
        data.get("messageText"),
        data.get("text"),
        data.get("body"),
        message.get("conversation"),
    )

    if text:
        return text

    extended_text = nested_dict(message, "extendedTextMessage")
    image_message = nested_dict(message, "imageMessage")
    video_message = nested_dict(message, "videoMessage")
    audio_message = nested_dict(message, "audioMessage")

    return first_text(
        extended_text.get("text"),
        image_message.get("caption"),
        video_message.get("caption"),
        audio_message.get("caption"),
    )


def extract_customer_phone(payload: dict[str, Any]) -> str | None:
    payload_dict = dict_or_empty(payload)
    data = webhook_data_dict(payload_dict)
    key = webhook_key_dict(payload_dict)

    return normalize_phone(
        payload_dict.get("phone")
        or payload_dict.get("number")
        or payload_dict.get("remoteJid")
        or data.get("phone")
        or data.get("number")
        or data.get("sender")
        or data.get("remoteJid")
        or key.get("participant")
        or key.get("remoteJid")
    )


def is_message_from_bot(payload: dict[str, Any]) -> bool:
    payload_dict = dict_or_empty(payload)
    data = webhook_data_dict(payload_dict)
    key = webhook_key_dict(payload_dict)
    return bool(payload_dict.get("fromMe") or data.get("fromMe") or key.get("fromMe"))


def get_or_create_conversation(
    db: Session,
    agent_id: int,
    customer_phone: str,
) -> Conversation:
    conversation = (
        db.query(Conversation)
        .filter(
            Conversation.agent_id == agent_id,
            Conversation.customer_phone == customer_phone,
        )
        .first()
    )

    if conversation:
        return conversation

    conversation = Conversation(
        agent_id=agent_id,
        customer_phone=customer_phone,
        history=[],
    )
    db.add(conversation)
    db.commit()
    db.refresh(conversation)
    return conversation


def build_context_messages(agent: Agent, conversation: Conversation) -> list[dict[str, str]]:
    history = conversation.history or []
    recent_history = history[-CONTEXT_MESSAGE_LIMIT:]

    return [
        {"role": "system", "content": agent.system_prompt},
        *[
            {"role": item["role"], "content": item["content"]}
            for item in recent_history
            if item.get("role") in {"user", "assistant"} and item.get("content")
        ],
    ]


async def get_ai_response(
    agent: Agent,
    conversation: Conversation,
    user_message: str,
) -> str:
    messages = [
        *build_context_messages(agent, conversation),
        {"role": "user", "content": user_message},
    ]

    response = await acompletion(
        model=agent.ai_model,
        messages=messages,
        temperature=agent.temperature,
        api_key=agent.ai_api_key or None,
    )

    return response.choices[0].message.content.strip()


def append_message(conversation: Conversation, role: str, content: str) -> None:
    conversation.history = [
        *(conversation.history or []),
        {"role": role, "content": content},
    ]


def evolution_headers() -> dict[str, str]:
    return {"apikey": EVOLUTION_API_KEY} if EVOLUTION_API_KEY else {}


def slugify_instance(value: str) -> str:
    slug = re.sub(r"[^a-zA-Z0-9_-]+", "-", value.strip()).strip("-")
    return slug.lower()


def validate_public_slug_or_400(value: str | None) -> str:
    raw = str(value or "").strip().lower()
    if not raw:
        raise HTTPException(status_code=400, detail="Informe um slug publico para o agente.")
    if raw != normalize_public_slug(raw, "") or not is_valid_public_slug(raw):
        raise HTTPException(
            status_code=400,
            detail="Slug publico invalido. Use letras minusculas, numeros e hifens, sem espacos ou caracteres especiais.",
        )
    return raw


def public_slug_available(db: Session, slug: str, agent_id: int | None = None) -> bool:
    query = db.query(Agent).filter(Agent.public_slug == slug)
    if agent_id:
        query = query.filter(Agent.id != agent_id)
    return query.first() is None


def unique_public_slug(db: Session, value: str | None, agent_id: int | None = None) -> str:
    base = normalize_public_slug(value, f"agente-{agent_id or 'novo'}")
    if is_reserved_public_slug(base):
        base = f"{base}-agente"
    candidate = base
    suffix = 2
    while not public_slug_available(db, candidate, agent_id) or is_reserved_public_slug(candidate):
        suffix_text = f"-{suffix}"
        candidate = f"{base[:PUBLIC_SLUG_MAX_LENGTH - len(suffix_text)].rstrip('-')}{suffix_text}"
        suffix += 1
    return candidate


def set_agent_public_slug(db: Session, agent: Agent, value: str | None, auto: bool = False) -> str:
    slug = unique_public_slug(db, value, agent.id) if auto else validate_public_slug_or_400(value)
    if not auto and not public_slug_available(db, slug, agent.id):
        raise HTTPException(status_code=409, detail="Slug publico ja esta em uso por outro agente.")
    agent.public_slug = slug
    return slug


def agent_public_ref(agent: Agent) -> str:
    return str(agent.public_slug or agent.id)


def get_agent_by_public_ref(db: Session, value: int | str) -> Agent | None:
    ref = str(value or "").strip().lower()
    if not ref:
        return None
    if ref.isdigit():
        agent = db.get(Agent, int(ref))
        if agent:
            return agent
    return db.query(Agent).filter(Agent.public_slug == ref).first()


async def evolution_request(method: str, path: str, **kwargs: Any) -> Any:
    async with httpx.AsyncClient(timeout=40) as client:
        response = await client.request(
            method,
            f"{EVOLUTION_API_URL}{path}",
            headers=evolution_headers(),
            **kwargs,
        )

    if response.status_code >= 400:
        error_msg = response.text
        try:
            data = response.json()
            if isinstance(data, dict) and "response" in data and isinstance(data["response"], dict) and "message" in data["response"]:
                msgs = data["response"]["message"]
                error_msg = ", ".join(msgs) if isinstance(msgs, list) else str(msgs)
            elif isinstance(data, dict) and "message" in data:
                error_msg = str(data["message"])
        except Exception:
            pass
        raise HTTPException(
            status_code=502,
            detail=f"Evolution API ({response.status_code}): {error_msg}",
        )

    if not response.content:
        return {}
    try:
        return response.json()
    except ValueError:
        return {}


def evolution_webhook_path(target_id: int | str) -> str:
    value = str(target_id)
    if value.startswith("channel:"):
        return f"/webhook/channel/{value.split(':', 1)[1]}"
    return f"/webhook/{value}"


def evolution_logout_path(instance_name: str) -> str:
    return f"/instance/logout/{quote(str(instance_name), safe='')}"


def evolution_delete_path(instance_name: str) -> str:
    return f"/instance/delete/{quote(str(instance_name), safe='')}"


def _is_evolution_connection_closed_error(exc: Exception) -> bool:
    if not isinstance(exc, HTTPException):
        return False
    detail = str(exc.detail or "").lower()
    return exc.status_code == 502 and "connection closed" in detail


def _is_evolution_missing_instance_error(exc: Exception) -> bool:
    if not isinstance(exc, HTTPException):
        return False
    detail = str(exc.detail or "").lower()
    return exc.status_code == 502 and (
        "instance does not exist" in detail
        or "instance not found" in detail
        or "does not exist" in detail
        or "not found" in detail
        or "evolution api (404)" in detail
    )


def _is_evolution_instance_exists_error(exc: Exception) -> bool:
    if not isinstance(exc, HTTPException):
        return False
    detail = str(exc.detail or "").lower()
    return exc.status_code == 502 and (
        "already exists" in detail
        or "already exist" in detail
        or "já existe" in detail
        or "já existe" in detail
    )


async def reset_evolution_instance(target_id: int | str, instance_name: str) -> dict[str, Any]:
    try:
        deleted = await evolution_request("DELETE", evolution_delete_path(instance_name))
    except HTTPException as exc:
        if exc.status_code != 502 and exc.status_code != 404:
            raise
        deleted = {"status": "ignored", "detail": exc.detail}
    created = None
    last_error: HTTPException | None = None
    for attempt in range(3):
        if attempt:
            await asyncio.sleep(2)
        try:
            created = await create_evolution_instance(target_id, instance_name)
            break
        except HTTPException as exc:
            last_error = exc
            if exc.status_code not in {502, 403}:
                raise
    if created is None:
        raise last_error or HTTPException(status_code=502, detail="Evolution instance reset failed.")
    return {"status": "reset", "instance": instance_name, "deleted": deleted, "created": created}


async def disconnect_evolution_instance(instance_name: str, target_id: int | str | None = None) -> Any:
    try:
        return await evolution_request("DELETE", evolution_logout_path(instance_name))
    except HTTPException as exc:
        instance = await fetch_evolution_instance(instance_name)
        status = _evolution_instance_status(instance) if instance else ""
        if status and status != "open":
            return {
                "status": "disconnected",
                "instance": instance_name,
                "connectionStatus": status,
                "warning": str(exc.detail or ""),
            }
        if target_id is not None and (exc.status_code == 502 or _is_evolution_connection_closed_error(exc)):
            logger.warning("Evolution logout failed for %s; resetting instance.", instance_name)
            return await reset_evolution_instance(target_id, instance_name)
        raise


async def set_evolution_groups_ignore(instance_name: str, ignore_groups: bool) -> Any:
    return await evolution_request(
        "POST",
        f"/settings/set/{quote(str(instance_name), safe='')}",
        json={
            "rejectCall": False,
            "msgCall": "",
            "groupsIgnore": bool(ignore_groups),
            "alwaysOnline": True,
            "readMessages": True,
            "readStatus": True,
            "syncFullHistory": False,
        },
    )


async def create_evolution_instance(agent_id: int | str, instance_name: str) -> Any:
    return await evolution_request(
        "POST",
        "/instance/create",
        json={
            "instanceName": instance_name,
            "integration": "WHATSAPP-BAILEYS",
            "token": secrets.token_urlsafe(24),
            "qrcode": False,
            "groupsIgnore": True,
            "alwaysOnline": True,
            "readMessages": True,
            "readStatus": True,
            "syncFullHistory": False,
            "webhook": {
                "url": f"{WEBHOOK_BASE_URL}{evolution_webhook_path(agent_id)}",
                "byEvents": True,
                "base64": os.getenv("EVOLUTION_WEBHOOK_BASE64", "true").strip().lower()
                not in {"0", "false", "no", "off"},
                "events": ["MESSAGES_UPSERT", "CONNECTION_UPDATE", "QRCODE_UPDATED"],
            },
        },
    )


async def send_whatsapp_text(agent: Agent, customer_phone: str, text: str) -> None:
    await send_whatsapp_text_to_instance(agent.instance_token, customer_phone, text)


async def send_whatsapp_text_to_instance(instance_name: str, customer_phone: str, text: str) -> None:
    target = normalize_whatsapp_target_id(customer_phone, "group" if is_group_jid(customer_phone) else "contact")
    payload = {
        "number": target,
        "text": text,
        "textMessage": {"text": text},
    }

    url = f"{EVOLUTION_API_URL}/message/sendText/{instance_name}"

    async with httpx.AsyncClient(timeout=30) as client:
        response = await client.post(url, json=payload, headers=evolution_headers())

    if response.status_code >= 400:
        raise HTTPException(
            status_code=502,
            detail={
                "message": "Evolution API rejected the outgoing message.",
                "status_code": response.status_code,
                "response": response.text,
            },
        )


def _list_from_evolution_response(value: Any) -> list[dict[str, Any]]:
    if isinstance(value, list):
        return [item for item in value if isinstance(item, dict)]
    if not isinstance(value, dict):
        return []
    for key in ("groups", "contacts", "chats", "response", "data", "value"):
        nested = value.get(key)
        if isinstance(nested, list):
            return [item for item in nested if isinstance(item, dict)]
        if isinstance(nested, dict):
            nested_list = _list_from_evolution_response(nested)
            if nested_list:
                return nested_list
    return []


def _target_id_from_evolution(item: dict[str, Any]) -> str:
    key = item.get("key") if isinstance(item.get("key"), dict) else {}
    return str(
        item.get("remoteJid")
        or item.get("jid")
        or key.get("remoteJid")
        or item.get("number")
        or item.get("phone")
        or item.get("wuid")
        or item.get("id")
        or ""
    ).strip()


def _target_name_from_evolution(item: dict[str, Any], target_id: str) -> str:
    return clean_display_text(
        item.get("name")
        or item.get("pushName")
        or item.get("verifiedName")
        or item.get("notify")
        or item.get("subject")
        or target_id
    )


def _normalize_evolution_targets(items: list[dict[str, Any]], kind: str) -> list[dict[str, Any]]:
    result: list[dict[str, Any]] = []
    seen: set[str] = set()
    for item in items:
        target_id = normalize_whatsapp_target_id(_target_id_from_evolution(item), kind)
        if not target_id or target_id in seen:
            continue
        if kind == "contact" and is_group_jid(target_id):
            continue
        if kind == "group" and not is_group_jid(target_id):
            continue
        seen.add(target_id)
        result.append(
            {
                "id": target_id,
                "name": _target_name_from_evolution(item, target_id),
                "kind": kind,
                "participants_count": len(item.get("participants") or []) if isinstance(item.get("participants"), list) else None,
            }
        )
    return result


def _evolution_instance_name(item: dict[str, Any]) -> str:
    nested = item.get("instance") if isinstance(item.get("instance"), dict) else {}
    return str(
        item.get("name")
        or item.get("instanceName")
        or item.get("instance_name")
        or nested.get("instanceName")
        or nested.get("name")
        or ""
    ).strip()


def _evolution_instance_status(item: dict[str, Any]) -> str:
    nested = item.get("instance") if isinstance(item.get("instance"), dict) else {}
    return str(
        item.get("connectionStatus")
        or item.get("status")
        or nested.get("connectionStatus")
        or nested.get("status")
        or ""
    ).strip()


def _evolution_instance_disconnection_reason_code(item: dict[str, Any]) -> int | None:
    nested = item.get("instance") if isinstance(item.get("instance"), dict) else {}
    value = item.get("disconnectionReasonCode") or nested.get("disconnectionReasonCode")
    try:
        return int(value) if value is not None else None
    except (TypeError, ValueError):
        return None


def _evolution_instance_disconnection_text(item: dict[str, Any]) -> str:
    nested = item.get("instance") if isinstance(item.get("instance"), dict) else {}
    return str(
        item.get("disconnectionObject")
        or item.get("disconnectionReason")
        or nested.get("disconnectionObject")
        or nested.get("disconnectionReason")
        or ""
    )


def _evolution_instance_needs_logout_reset(item: dict[str, Any]) -> bool:
    reason = _evolution_instance_disconnection_text(item).lower()
    return _evolution_instance_disconnection_reason_code(item) == 401 and (
        "device_removed" in reason
        or "conflict" in reason
        or "stream errored" in reason
    )


def _evolution_instance_message_count(item: dict[str, Any]) -> int:
    count = item.get("_count") if isinstance(item.get("_count"), dict) else {}
    nested_count = item.get("count") if isinstance(item.get("count"), dict) else {}
    value = count.get("Message") or count.get("message") or nested_count.get("Message") or nested_count.get("message") or 0
    try:
        return int(value or 0)
    except (TypeError, ValueError):
        return 0


def _registered_instance_names(db: Session) -> list[str]:
    names: list[str] = []
    seen: set[str] = set()

    def add(value: Any) -> None:
        name = str(value or "").strip()
        if not name or name in seen:
            return
        names.append(name)
        seen.add(name)

    for agent in db.query(Agent.instance_token).all():
        add(agent[0])
    for channel in db.query(Channel.instance_name).all():
        add(channel[0])
    return names


def _instance_status_map(raw_instances: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
    result: dict[str, dict[str, Any]] = {}
    for item in raw_instances:
        name = _evolution_instance_name(item)
        if not name:
            continue
        result[name] = {
            "status": _evolution_instance_status(item),
            "needsLogoutReset": _evolution_instance_needs_logout_reset(item),
            "disconnectionReasonCode": _evolution_instance_disconnection_reason_code(item),
            "disconnectionReason": _evolution_instance_disconnection_text(item),
            "messages": _evolution_instance_message_count(item),
        }
    return result


async def fetch_evolution_instances() -> list[dict[str, Any]]:
    response = await evolution_request("GET", "/instance/fetchInstances")
    raw = response.get("value", response) if isinstance(response, dict) else response
    return [item for item in raw if isinstance(item, dict)] if isinstance(raw, list) else []


async def fetch_evolution_instance(instance_name: str) -> dict[str, Any] | None:
    target = str(instance_name or "").strip()
    if not target:
        return None
    for item in await fetch_evolution_instances():
        if _evolution_instance_name(item) == target:
            return item
    return None


async def ensure_evolution_instance(target_id: int | str, instance_name: str) -> Any:
    try:
        return await create_evolution_instance(target_id, instance_name)
    except HTTPException as exc:
        if _is_evolution_instance_exists_error(exc):
            logger.warning("Evolution instance %s already exists; continuing to QR.", instance_name)
            return {"status": "exists", "instance": instance_name}
        raise


async def connect_evolution_instance(instance_name: str, target_id: int | str | None = None) -> Any:
    instance = await fetch_evolution_instance(instance_name)
    if instance is None and target_id is not None:
        logger.warning("Evolution instance %s missing before QR; creating it.", instance_name)
        await ensure_evolution_instance(target_id, instance_name)
        instance = await fetch_evolution_instance(instance_name)
    if instance and _evolution_instance_needs_logout_reset(instance):
        logger.warning("Resetting stale Evolution auth session before QR for %s", instance_name)
        if target_id is not None:
            await reset_evolution_instance(target_id, instance_name)
        else:
            await disconnect_evolution_instance(instance_name)
    try:
        return await evolution_request("GET", f"/instance/connect/{quote(str(instance_name), safe='')}")
    except HTTPException as exc:
        if target_id is not None and _is_evolution_missing_instance_error(exc):
            logger.warning("Evolution instance %s missing during QR; creating it.", instance_name)
            await ensure_evolution_instance(target_id, instance_name)
            return await evolution_request("GET", f"/instance/connect/{quote(str(instance_name), safe='')}")
        raise


async def fetch_whatsapp_groups(instance_name: str) -> list[dict[str, Any]]:
    response = await evolution_request(
        "GET",
        f"/group/fetchAllGroups/{quote(str(instance_name), safe='')}?getParticipants=false",
    )
    return _normalize_evolution_targets(_list_from_evolution_response(response), "group")


async def fetch_whatsapp_contacts(instance_name: str) -> list[dict[str, Any]]:
    response = await evolution_request(
        "POST",
        f"/chat/findContacts/{quote(str(instance_name), safe='')}",
        json={"where": {}},
    )
    return _normalize_evolution_targets(_list_from_evolution_response(response), "contact")


async def fetch_whatsapp_chats(instance_name: str) -> dict[str, list[dict[str, Any]]]:
    response = await evolution_request(
        "POST",
        f"/chat/findChats/{quote(str(instance_name), safe='')}",
        json={"where": {}},
    )
    items = _list_from_evolution_response(response)
    return {
        "contacts": _normalize_evolution_targets(items, "contact"),
        "groups": _normalize_evolution_targets(items, "group"),
    }


def _whatsapp_target_looks_usable(target_id: str, kind: str) -> bool:
    if kind == "group":
        return is_group_jid(target_id)
    return len(re.sub(r"\D+", "", target_id)) >= 8


def _add_known_whatsapp_target(
    buckets: dict[str, list[dict[str, Any]]],
    value: Any,
    name: Any,
    kind: str | None,
    source: str,
) -> None:
    resolved_kind = "group" if is_group_jid(str(value or "")) or str(kind or "").strip().lower() == "group" else "contact"
    target_id = normalize_whatsapp_target_id(str(value or ""), resolved_kind)
    if not target_id or not _whatsapp_target_looks_usable(target_id, resolved_kind):
        return
    bucket = buckets["groups" if resolved_kind == "group" else "contacts"]
    if any(item["id"] == target_id for item in bucket):
        return
    bucket.append(
        {
            "id": target_id,
            "name": clean_display_text(str(name or "").strip() or target_id),
            "kind": resolved_kind,
            "source": source,
        }
    )


def _known_agent_whatsapp_targets(db: Session, agent: Agent) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
    buckets: dict[str, list[dict[str, Any]]] = {"contacts": [], "groups": []}

    tools = (
        db.query(ToolDefinition)
        .filter(ToolDefinition.agent_id == agent.id, ToolDefinition.tool_type == "whatsapp_notify")
        .order_by(ToolDefinition.id.desc())
        .all()
    )
    for tool in tools:
        config = tool.config or {}
        config_kind = config.get("target_kind")
        for target in config.get("targets") or []:
            if not isinstance(target, dict):
                continue
            _add_known_whatsapp_target(
                buckets,
                target.get("id") or target.get("target_id") or target.get("number"),
                target.get("name") or target.get("target_name"),
                target.get("kind") or config_kind,
                "config",
            )
        _add_known_whatsapp_target(
            buckets,
            config.get("target_id") or config.get("number"),
            config.get("target_name") or tool.name,
            config_kind,
            "config",
        )

    conversations = (
        db.query(Conversation)
        .filter(Conversation.agent_id == agent.id)
        .order_by(Conversation.updated_at.desc())
        .limit(300)
        .all()
    )
    for conversation in conversations:
        state = conversation.state if isinstance(conversation.state, dict) else {}
        phone = conversation.customer_phone
        if str(phone or "").startswith(("site-", "agent-test-", "test-", "pub-")):
            continue
        kind = state.get("chat_kind") or ("group" if is_group_jid(phone) else "contact")
        _add_known_whatsapp_target(
            buckets,
            phone,
            state.get("customer_name") or state.get("push_name") or state.get("sender_name") or phone,
            kind,
            "conversation",
        )

    return buckets["contacts"], buckets["groups"]


def _merge_whatsapp_targets(
    fetched: list[dict[str, Any]],
    fallback: list[dict[str, Any]],
    kind: str,
) -> list[dict[str, Any]]:
    merged: list[dict[str, Any]] = []
    seen: set[str] = set()
    for item in [*(fetched or []), *(fallback or [])]:
        if not isinstance(item, dict):
            continue
        target_id = normalize_whatsapp_target_id(item.get("id") or item.get("target_id") or item.get("number"), kind)
        if not target_id or target_id in seen:
            continue
        if kind == "contact" and is_group_jid(target_id):
            continue
        if not _whatsapp_target_looks_usable(target_id, kind):
            continue
        seen.add(target_id)
        merged.append(
            {
                **item,
                "id": target_id,
                "name": clean_display_text(item.get("name") or item.get("target_name") or target_id),
                "kind": kind,
            }
        )
    return merged


def _normalize_whatsapp_notify_config(config: dict[str, Any]) -> dict[str, Any]:
    normalized = dict(config or {})
    trigger_mode = str(normalized.get("trigger_mode") or "handoff_or_qualified").strip().lower()
    if trigger_mode not in {"handoff_or_qualified", "human_request", "qualified_lead", "deal_closed", "manual"}:
        trigger_mode = "handoff_or_qualified"
    normalized["trigger_mode"] = trigger_mode
    target_kind = "group" if str(normalized.get("target_kind") or "").strip().lower() == "group" else "contact"
    if is_group_jid(normalized.get("target_id")):
        target_kind = "group"
    target_id = normalize_whatsapp_target_id(normalized.get("target_id"), target_kind)
    normalized["target_id"] = target_id
    normalized["target_kind"] = target_kind
    if target_id and not normalized.get("target_name"):
        normalized["target_name"] = target_id

    targets: list[dict[str, Any]] = []
    for item in normalized.get("targets") or []:
        if not isinstance(item, dict):
            continue
        item_kind = "group" if str(item.get("kind") or target_kind).strip().lower() == "group" or is_group_jid(item.get("id")) else "contact"
        item_id = normalize_whatsapp_target_id(item.get("id") or item.get("target_id") or item.get("number"), item_kind)
        if not item_id:
            continue
        targets.append(
            {
                "id": item_id,
                "name": clean_display_text(item.get("name") or item.get("target_name") or item_id),
                "kind": item_kind,
            }
        )
    if target_id and not any(item["id"] == target_id for item in targets):
        targets.insert(
            0,
            {
                "id": target_id,
                "name": clean_display_text(normalized.get("target_name") or target_id),
                "kind": target_kind,
            },
        )
    normalized["targets"] = targets
    normalized["allow_dynamic_target"] = bool(normalized.get("allow_dynamic_target", False))
    return normalized


def _copy_whatsapp_targets_payload(payload: dict[str, Any]) -> dict[str, Any]:
    return {
        "contacts": [dict(item) for item in payload.get("contacts") or [] if isinstance(item, dict)],
        "groups": [dict(item) for item in payload.get("groups") or [] if isinstance(item, dict)],
        "group_config": dict(payload.get("group_config") or {}),
        "errors": list(payload.get("errors") or []),
        "cached": bool(payload.get("cached", False)),
        "loaded_at": payload.get("loaded_at"),
    }


def _filter_whatsapp_targets_payload(payload: dict[str, Any], query: str | None) -> dict[str, Any]:
    result = _copy_whatsapp_targets_payload(payload)
    needle = str(query or "").strip().lower()
    if not needle:
        return result

    def matches(item: dict[str, Any]) -> bool:
        haystack = f"{item.get('name') or ''} {item.get('id') or ''}".lower()
        return needle in haystack

    result["contacts"] = [item for item in result["contacts"] if matches(item)]
    result["groups"] = [item for item in result["groups"] if matches(item)]
    result["query"] = needle
    return result


def _cached_whatsapp_targets(agent_id: int) -> dict[str, Any] | None:
    cached = _WHATSAPP_TARGETS_CACHE.get(agent_id)
    if not cached:
        return None
    ttl = max(5.0, float(os.getenv("WHATSAPP_TARGET_CACHE_TTL_SECONDS", "90")))
    loaded_at, payload = cached
    if perf_counter() - loaded_at > ttl:
        _WHATSAPP_TARGETS_CACHE.pop(agent_id, None)
        return None
    result = _copy_whatsapp_targets_payload(payload)
    result["cached"] = True
    return result


def _whatsapp_group_config(agent: Agent) -> dict[str, Any]:
    handoff = agent.handoff_config or {}
    config = handoff.get("whatsapp_groups") if isinstance(handoff.get("whatsapp_groups"), dict) else {}
    enabled = [normalize_whatsapp_target_id(str(item), "group") for item in config.get("enabled_group_ids", []) if str(item).strip()]
    return {"enabled_group_ids": enabled, "updated_at": config.get("updated_at")}


def _set_whatsapp_group_config(agent: Agent, enabled_group_ids: list[str]) -> None:
    handoff = dict(agent.handoff_config or {})
    handoff["whatsapp_groups"] = {
        "enabled_group_ids": [normalize_whatsapp_target_id(str(item), "group") for item in enabled_group_ids if str(item).strip()],
        "updated_at": datetime.utcnow().isoformat(),
    }
    agent.handoff_config = handoff


def _conversation_instance(conversation: Conversation, agent: Agent | None = None) -> str | None:
    state = conversation.state or {}
    return state.get("whatsapp_instance") or (agent.instance_token if agent else None)


def _set_human_pause_state(conversation: Conversation, ticket_id: int | None = None, paused: bool = True) -> None:
    state = dict(conversation.state or {})
    handoff = dict(state.get("human_handoff") or {})
    handoff.update(
        {
            "requested": paused,
            "paused": paused,
            "ticket_id": ticket_id or handoff.get("ticket_id"),
            "ticket_status": "in_progress" if paused else "finished",
            "reason": handoff.get("reason") or ("manual_pause" if paused else None),
            "source": handoff.get("source") or ("manual_pause" if paused else None),
            "updated_at": datetime.utcnow().isoformat(),
        }
    )
    state["human_handoff"] = handoff
    conversation.state = state


@app.get("/health")
def health() -> dict[str, str]:
    return {"status": "ok"}


@app.get("/favicon.ico")
def favicon() -> FileResponse:
    return FileResponse(STATIC_DIR / "system-manager-logo.png", media_type="image/png")


HTML_NO_STORE_HEADERS = {"Cache-Control": "no-store, max-age=0"}


@app.get("/")
@app.get("/admin")
def admin() -> FileResponse:
    return FileResponse(STATIC_DIR / "admin.html", headers=HTML_NO_STORE_HEADERS)


PUBLIC_TEST_BASE_ENV_KEYS = (
    "PUBLIC_CHAT_BASE_URL",
    "PUBLIC_TEST_BASE_URL",
    "APP_PUBLIC_BASE_URL",
    "CLOUDFLARE_TUNNEL_URL",
    "NGROK_URL",
    "WEBHOOK_BASE_URL",
)
LOCAL_PUBLIC_HOSTS = {"localhost", "127.0.0.1", "0.0.0.0", "::1", "host.docker.internal"}


def _clean_base_url(value: str | None) -> str:
    return (value or "").strip().rstrip("/")


def _public_url_host(value: str) -> str:
    try:
        return (urlparse(value).hostname or "").lower()
    except Exception:
        return ""


def _is_local_public_url(value: str) -> bool:
    host = _public_url_host(value)
    return not host or host in LOCAL_PUBLIC_HOSTS or host.endswith(".local")


def _configured_public_test_base_url() -> tuple[str, str | None]:
    for key in PUBLIC_TEST_BASE_ENV_KEYS:
        value = _clean_base_url(os.getenv(key))
        if value:
            return value, key
    return "", None


def _request_base_url(request: Request) -> str:
    origin = _clean_base_url(request.headers.get("origin"))
    if origin:
        return origin
    return _clean_base_url(f"{request.url.scheme}://{request.url.netloc}")


SHARED_CHAT_LINK_STATUSES = {"active", "paused", "archived"}


def _shared_chat_base_url(request: Request) -> tuple[str, str, bool]:
    configured_base, source_env = _configured_public_test_base_url()
    request_base = _request_base_url(request)
    if configured_base and not _is_local_public_url(configured_base):
        base_url = configured_base
        source = source_env or "configured"
    else:
        base_url = request_base
        source = "current_origin"
    return base_url, source, _is_local_public_url(base_url)


def _safe_query_first(query: Any) -> Any | None:
    try:
        item = query.first()
    except Exception:
        return None
    return item if isinstance(getattr(item, "id", None), int) else None


def get_shared_chat_link_by_slug(db: Session, slug: str) -> SharedChatLink | None:
    clean_slug = normalize_public_slug(slug, "")
    if not clean_slug:
        return None
    try:
        return _safe_query_first(db.query(SharedChatLink).filter(SharedChatLink.slug == clean_slug))
    except Exception:
        return None


def shared_chat_link_slug_available(db: Session, slug: str, current_id: int | None = None) -> bool:
    clean_slug = normalize_public_slug(slug, "")
    if not clean_slug:
        return False
    try:
        query = db.query(SharedChatLink).filter(SharedChatLink.slug == clean_slug)
        if current_id is not None:
            query = query.filter(SharedChatLink.id != current_id)
        return query.first() is None
    except Exception:
        return False


def unique_shared_chat_link_slug(db: Session, raw: Any, agent: Agent, current_id: int | None = None) -> str:
    base = normalize_public_slug(raw, getattr(agent, "public_slug", None) or f"agente-{agent.id}")
    if is_reserved_public_slug(base):
        base = f"{base}-chat"
    candidate = base[:PUBLIC_SLUG_MAX_LENGTH].rstrip("-") or f"agente-{agent.id}"
    suffix = 2
    while not shared_chat_link_slug_available(db, candidate, current_id):
        suffix_text = f"-{suffix}"
        candidate = f"{base[:PUBLIC_SLUG_MAX_LENGTH - len(suffix_text)].rstrip('-')}{suffix_text}"
        suffix += 1
    return candidate


def ensure_agent_default_shared_chat_link(db: Session, agent: Agent) -> SharedChatLink | None:
    if not hasattr(db, "bind"):
        return None
    try:
        existing = _safe_query_first(
            db.query(SharedChatLink)
            .filter(SharedChatLink.agent_id == agent.id, SharedChatLink.archived_at.is_(None))
            .order_by(SharedChatLink.id.asc())
        )
    except Exception:
        return None
    if existing:
        return existing
    slug = unique_shared_chat_link_slug(db, getattr(agent, "public_slug", None) or getattr(agent, "name", None), agent)
    link = SharedChatLink(
        agent_id=agent.id,
        name="Link principal",
        slug=slug,
        status="active",
        config=dict(getattr(agent, "shared_chat_config", None) or {}),
    )
    db.add(link)
    db.commit()
    db.refresh(link)
    return link


def resolve_shared_chat_target(db: Session, public_ref: str, create_default_link: bool = False) -> tuple[Agent | None, SharedChatLink | None]:
    link = get_shared_chat_link_by_slug(db, public_ref)
    if link:
        agent = db.get(Agent, link.agent_id)
        return agent, link
    agent = get_agent_by_public_ref(db, public_ref)
    if not agent:
        return None, None
    link = ensure_agent_default_shared_chat_link(db, agent) if create_default_link else None
    return agent, link


def ensure_shared_chat_link_available(link: SharedChatLink | None) -> None:
    if not link:
        return
    status = str(getattr(link, "status", "") or "active").strip().lower()
    if getattr(link, "archived_at", None) or status == "archived":
        raise HTTPException(status_code=403, detail={"code": "shared_chat_link_archived", "message": "Este link de Chat Compartilhado foi arquivado."})
    if status == "paused":
        raise HTTPException(status_code=403, detail={"code": "shared_chat_link_paused", "message": "Este link de Chat Compartilhado esta pausado."})


def shared_chat_link_url(link: SharedChatLink | None, agent: Agent | None, request: Request) -> str:
    base_url, _, _ = _shared_chat_base_url(request)
    slug = getattr(link, "slug", None) or getattr(agent, "public_slug", None)
    if slug:
        return f"{base_url}/share/{slug}"
    return f"{base_url}/chat/{getattr(agent, 'id', '')}"


def public_test_link_payload(agent_or_id: Agent | int, request: Request, link: SharedChatLink | None = None) -> dict[str, Any]:
    base_url, source, is_local = _shared_chat_base_url(request)
    if hasattr(agent_or_id, "id"):
        agent_id = int(getattr(agent_or_id, "id"))
        public_slug = getattr(agent_or_id, "public_slug", None)
    else:
        agent_id = int(agent_or_id)
        public_slug = None
    public_ref = getattr(link, "slug", None) or public_slug or str(agent_id)
    if getattr(link, "slug", None):
        path = f"/share/{public_ref}"
    elif public_slug:
        path = f"/{public_slug}"
    else:
        path = f"/chat/{agent_id}"
    url = f"{base_url}{path}"
    return {
        "url": url,
        "link_id": getattr(link, "id", None),
        "link_name": clean_display_text(getattr(link, "name", None)),
        "link_status": getattr(link, "status", None) or "active",
        "shared_chat_link_id": getattr(link, "id", None),
        "agent_id": agent_id,
        "public_slug": public_slug,
        "public_ref": public_ref,
        "share_url": f"{base_url}/share/{public_ref}" if public_ref else url,
        "legacy_url": f"{base_url}/chat/{agent_id}",
        "can_share": not is_local,
        "requires_tunnel": is_local,
        "source": source,
        "tunnel_configured": bool(source != "current_origin" and not is_local),
        "message": (
            "Link publico pronto para compartilhar."
            if not is_local
            else "Este sistema esta rodando em localhost ou host interno. Configure PUBLIC_CHAT_BASE_URL ou PUBLIC_TEST_BASE_URL para compartilhar fora desta maquina."
        ),
    }


def _public_asset_url(request: Request, path: str) -> str:
    configured_base, _ = _configured_public_test_base_url()
    base_url = configured_base if configured_base and not _is_local_public_url(configured_base) else _request_base_url(request)
    return f"{base_url}{path}"


def _public_chat_html_response(agent: Agent, request: Request, link: SharedChatLink | None = None) -> HTMLResponse:
    html_text = (STATIC_DIR / "chat.html").read_text(encoding="utf-8")
    title = f"{clean_display_text(agent.name)} - Atendimento System Manager"
    description = clean_display_text(agent.description) or "Converse com o assistente oficial da System Manager."
    image_url = _public_asset_url(request, "/static/system-manager-logo.png")
    canonical_url = public_test_link_payload(agent, request, link)["url"]
    replacements = {
        r"<title>.*?</title>": f"<title>{html.escape(title)}</title>",
        r'(<meta name="description" content=")[^"]*(">)': html.escape(description),
        r'(<meta property="og:title" content=")[^"]*(">)': html.escape(title),
        r'(<meta property="og:description" content=")[^"]*(">)': html.escape(description),
        r'(<meta property="og:image" content=")[^"]*(">)': html.escape(image_url),
        r'(<meta property="og:url" content=")[^"]*(">)': html.escape(canonical_url),
        r'(<meta name="twitter:title" content=")[^"]*(">)': html.escape(title),
        r'(<meta name="twitter:description" content=")[^"]*(">)': html.escape(description),
        r'(<meta name="twitter:image" content=")[^"]*(">)': html.escape(image_url),
    }
    for pattern, replacement in replacements.items():
        if pattern.startswith("<title>"):
            html_text = re.sub(pattern, replacement, html_text, count=1, flags=re.S)
        else:
            html_text = re.sub(pattern, lambda match, value=replacement: f"{match.group(1)}{value}{match.group(2)}", html_text, count=1, flags=re.S)
    return HTMLResponse(html_text, headers=HTML_NO_STORE_HEADERS)


@app.get("/chat/{agent_id}")
def public_chat_page(agent_id: str, request: Request, db: Session = Depends(get_db)) -> HTMLResponse:
    agent = get_agent_by_public_ref(db, agent_id)
    if not agent:
        raise HTTPException(status_code=404, detail="Agente nao encontrado.")
    link = ensure_agent_default_shared_chat_link(db, agent)
    return _public_chat_html_response(agent, request, link)


@app.get("/share/{link_slug}")
def public_shared_chat_page(link_slug: str, request: Request, db: Session = Depends(get_db)) -> HTMLResponse:
    agent, link = resolve_shared_chat_target(db, link_slug, create_default_link=True)
    if not agent:
        raise HTTPException(status_code=404, detail="Chat compartilhado nao encontrado.")
    return _public_chat_html_response(agent, request, link)


@app.get("/{agent_slug}")
def public_chat_page_by_slug(agent_slug: str, request: Request, db: Session = Depends(get_db)) -> HTMLResponse:
    if agent_slug.isdigit() or is_reserved_public_slug(agent_slug):
        raise HTTPException(status_code=404, detail="Agente nao encontrado.")
    agent = get_agent_by_public_ref(db, agent_slug)
    if not agent:
        raise HTTPException(status_code=404, detail="Agente nao encontrado.")
    link = ensure_agent_default_shared_chat_link(db, agent)
    return _public_chat_html_response(agent, request, link)


@app.get("/api/chat/{agent_id}/meta")
def public_chat_meta(agent_id: str, db: Session = Depends(get_db)) -> dict[str, Any]:
    agent, link = resolve_shared_chat_target(db, agent_id, create_default_link=True)
    if not agent:
        raise HTTPException(status_code=404, detail="Agente nao encontrado.")
    config = public_shared_chat_config_payload(agent, link)
    link_status = str(getattr(link, "status", "") or "active").strip().lower() if link else "active"
    link_archived = bool(getattr(link, "archived_at", None)) or link_status == "archived"
    return {
        "id": agent.id,
        "public_slug": agent.public_slug,
        "name": clean_display_text(agent.name),
        "description": clean_display_text(agent.description),
        "online": bool(agent.is_published and link_status == "active" and not link_archived),
        "brand_name": "System Manager",
        "logo_url": "/static/system-manager-logo.png",
        "shared_chat": config,
        "link_status": "archived" if link_archived else link_status,
        "shared_chat_link": shared_chat_link_payload(link, agent) if link else None,
    }


@app.get("/api/agents/{agent_id}/public-test-link")
def get_agent_public_test_link(agent_id: int, request: Request, db: Session = Depends(get_db)) -> dict[str, Any]:
    agent = db.get(Agent, agent_id)
    if not agent:
        raise HTTPException(status_code=404, detail="Agente nao encontrado.")
    link = ensure_agent_default_shared_chat_link(db, agent)
    return public_test_link_payload(agent, request, link)


SHARED_CHAT_REQUEST_TYPES = [
    "Dúvida",
    "Erro no chat",
    "Resposta incorreta",
    "Quero falar com uma pessoa",
    "Problema técnico",
    "Sugestão",
    "Sugestão de melhoria",
    "Outro",
]

SHARED_CHAT_PRESETS = {
    "low": {
        "max_messages_per_conversation": 10,
        "max_messages_per_session": 20,
        "max_messages_per_visitor": 30,
        "max_messages_per_ip": 60,
        "max_chars_per_message": 500,
        "max_total_chars_per_conversation": 3000,
    },
    "medium": {
        "max_messages_per_conversation": 30,
        "max_messages_per_session": 60,
        "max_messages_per_visitor": 90,
        "max_messages_per_ip": 180,
        "max_chars_per_message": 1000,
        "max_total_chars_per_conversation": 10000,
    },
    "high": {
        "max_messages_per_conversation": 100,
        "max_messages_per_session": 200,
        "max_messages_per_visitor": 300,
        "max_messages_per_ip": 600,
        "max_chars_per_message": 2000,
        "max_total_chars_per_conversation": 30000,
    },
}

SHARED_CHAT_LIMIT_MAX = {
    "max_messages_per_conversation": 500,
    "max_messages_per_session": 1000,
    "max_messages_per_visitor": 1500,
    "max_messages_per_ip": 3000,
    "max_chars_per_message": 8000,
    "max_total_chars_per_conversation": 100000,
}

SHARED_CHAT_DEFAULT_WARNING = "Você está chegando ao limite de mensagens desta conversa."
SHARED_CHAT_DEFAULT_LIMIT = "Este chat atingiu o limite de uso configurado. Para continuar o atendimento, entre em contato com nossa equipe."
SHARED_CHAT_DEFAULT_CHAR_LIMIT = "Sua mensagem ultrapassou o limite de caracteres permitido. Reduza o texto para continuar."


def _as_bool(value: Any, default: bool = False) -> bool:
    if isinstance(value, bool):
        return value
    if value is None:
        return default
    text = str(value).strip().lower()
    if text in {"1", "true", "sim", "yes", "on"}:
        return True
    if text in {"0", "false", "nao", "não", "no", "off"}:
        return False
    return default


def _limited_text(value: Any, fallback: str, limit: int = 420) -> str:
    text = clean_display_text(str(value or "").strip()) or ""
    return text[:limit] if text else fallback


def _shared_limit_int(raw: Any, default: int, key: str, strict: bool, errors: list[str]) -> int:
    try:
        value = int(raw)
    except (TypeError, ValueError):
        if strict:
            errors.append(f"{key} deve ser um numero inteiro.")
        return default
    max_value = SHARED_CHAT_LIMIT_MAX[key]
    if value <= 0:
        if strict:
            errors.append(f"{key} deve ser maior que zero.")
        return default
    if value > max_value:
        if strict:
            errors.append(f"{key} excede o limite maximo permitido ({max_value}).")
        return max_value
    return value


def normalize_shared_chat_config(value: Any, strict: bool = False) -> dict[str, Any]:
    raw = value if isinstance(value, dict) else {}
    errors: list[str] = []
    preset = str(raw.get("preset") or "medium").strip().lower()
    if preset not in {*SHARED_CHAT_PRESETS.keys(), "custom"}:
        if strict:
            errors.append("Preset de limite invalido.")
        preset = "medium"
    base = dict(SHARED_CHAT_PRESETS.get(preset) or SHARED_CHAT_PRESETS["medium"])
    limits_raw = raw.get("limits") if isinstance(raw.get("limits"), dict) else raw
    if preset == "custom":
        for key, default in list(base.items()):
            base[key] = _shared_limit_int(limits_raw.get(key, default), default, key, strict, errors)
    contact_raw = raw.get("contact") if isinstance(raw.get("contact"), dict) else {}
    ui_raw = raw.get("ui") if isinstance(raw.get("ui"), dict) else {}
    messages_raw = raw.get("messages") if isinstance(raw.get("messages"), dict) else {}
    request_types = [
        item for item in SHARED_CHAT_REQUEST_TYPES if item in set(contact_raw.get("request_types") or SHARED_CHAT_REQUEST_TYPES)
    ] or list(SHARED_CHAT_REQUEST_TYPES)
    if errors:
        raise HTTPException(status_code=400, detail={"message": "Configuração de limites inválida.", "errors": errors})
    return {
        "preset": preset,
        "limits": base,
        "ui": {
            "show_character_counter": _as_bool(ui_raw.get("show_character_counter"), True),
            "show_near_limit_warning": _as_bool(ui_raw.get("show_near_limit_warning"), True),
            "warning_threshold_ratio": 0.8,
        },
        "messages": {
            "near_limit": _limited_text(messages_raw.get("near_limit"), SHARED_CHAT_DEFAULT_WARNING),
            "limit_reached": _limited_text(messages_raw.get("limit_reached"), SHARED_CHAT_DEFAULT_LIMIT),
            "char_limit": _limited_text(messages_raw.get("char_limit"), SHARED_CHAT_DEFAULT_CHAR_LIMIT),
        },
        "contact": {
            "enabled": _as_bool(contact_raw.get("enabled"), True),
            "allow_error_report": _as_bool(contact_raw.get("allow_error_report"), True),
            "require_name": _as_bool(contact_raw.get("require_name"), False),
            "require_email": _as_bool(contact_raw.get("require_email"), True),
            "require_phone": _as_bool(contact_raw.get("require_phone"), False),
            "destination_email": clean_display_text(contact_raw.get("destination_email")),
            "request_types": request_types,
        },
    }


def shared_chat_effective_config(agent: Agent, link: SharedChatLink | None = None) -> dict[str, Any]:
    source = getattr(link, "config", None) if link else None
    return normalize_shared_chat_config(source or getattr(agent, "shared_chat_config", None))


def public_shared_chat_config_payload(agent: Agent, link: SharedChatLink | None = None) -> dict[str, Any]:
    payload = shared_chat_effective_config(agent, link)
    payload["contact"] = dict(payload["contact"])
    payload["contact"].pop("destination_email", None)
    return payload


def shared_chat_link_payload(link: SharedChatLink, agent: Agent | None = None, request: Request | None = None) -> dict[str, Any]:
    return {
        "id": link.id,
        "agent_id": link.agent_id,
        "agent_name": clean_display_text(getattr(agent, "name", None)),
        "name": clean_display_text(link.name),
        "slug": link.slug,
        "status": link.status,
        "config": normalize_shared_chat_config(link.config or {}),
        "url": shared_chat_link_url(link, agent, request) if request is not None else f"/share/{link.slug}",
        "created_at": link.created_at.isoformat() if getattr(link, "created_at", None) else None,
        "updated_at": link.updated_at.isoformat() if getattr(link, "updated_at", None) else None,
        "archived_at": link.archived_at.isoformat() if getattr(link, "archived_at", None) else None,
    }


def _public_conversation_key(session_id: str, shared_chat_link_id: int | None = None) -> str:
    scope = f"link:{shared_chat_link_id}:{session_id}" if shared_chat_link_id else session_id
    digest = hashlib.sha256(scope.encode("utf-8")).hexdigest()[:24]
    return f"pub-{digest}"


def _shared_chat_session_id(body: dict[str, Any]) -> str:
    session_id = str(body.get("session_id") or body.get("visitor_id") or secrets.token_urlsafe(16)).strip()
    return session_id[:160] or secrets.token_urlsafe(16)


def _shared_chat_ip_hash(request: Request) -> str | None:
    headers = getattr(request, "headers", {}) or {}
    forwarded = str(headers.get("x-forwarded-for") or "").split(",")[0].strip()
    host = forwarded or getattr(getattr(request, "client", None), "host", None)
    if not host:
        return None
    salt = os.getenv("SHARED_CHAT_IP_HASH_SALT") or os.getenv("SECRET_KEY") or "shared-chat"
    return hashlib.sha256(f"{salt}:{host}".encode("utf-8")).hexdigest()[:48]


def _shared_chat_request_context(request: Request, body: dict[str, Any]) -> dict[str, Any]:
    session_id = _shared_chat_session_id(body)
    visitor_id = str(body.get("visitor_id") or session_id).strip()[:160] or session_id
    return {
        "session_id": session_id,
        "visitor_id": visitor_id,
        "ip_hash": _shared_chat_ip_hash(request),
        "url": clean_display_text(body.get("url") or getattr(getattr(request, "url", None), "_url", "") or ""),
    }


def _shared_chat_customer_name(body: dict[str, Any], current: Any = None) -> str:
    incoming = (clean_display_text(body.get("customer_name") or body.get("name")) or "")[:120]
    placeholders = {"visitante", "visitante em teste", "teste interno"}
    if incoming and incoming.strip().lower() not in placeholders:
        return incoming
    existing = (clean_display_text(current) or "")[:120]
    return existing or "Visitante"


def _conversation_user_usage(conversation: Conversation) -> dict[str, int]:
    messages = [
        item
        for item in (getattr(conversation, "history", None) or [])
        if isinstance(item, dict) and item.get("role") == "user" and item.get("content")
    ]
    state = getattr(conversation, "state", None) or {}
    shared = state.get("shared_chat") if isinstance(state, dict) else {}
    shared = shared if isinstance(shared, dict) else {}
    reset_messages = int(shared.get("usage_reset_user_count") or 0)
    reset_chars = int(shared.get("usage_reset_char_count") or 0)
    total_messages = len(messages)
    total_chars = sum(len(str(item.get("content") or "")) for item in messages)
    return {
        "messages": max(0, total_messages - reset_messages),
        "chars": max(0, total_chars - reset_chars),
    }


def _conversation_user_usage_totals(conversation: Conversation) -> dict[str, int]:
    messages = [
        item
        for item in (getattr(conversation, "history", None) or [])
        if isinstance(item, dict) and item.get("role") == "user" and item.get("content")
    ]
    return {
        "messages": len(messages),
        "chars": sum(len(str(item.get("content") or "")) for item in messages),
    }


def _shared_chat_limit_exception(code: str, message: str, usage: dict[str, Any]) -> None:
    raise HTTPException(
        status_code=429,
        detail={
            "code": code,
            "message": message,
            "usage": usage,
            "contact_enabled": True,
        },
    )


def validate_shared_chat_message(agent: Agent, conversation: Conversation, user_message: str, config: dict[str, Any]) -> dict[str, Any]:
    limits = config["limits"]
    messages = config["messages"]
    usage = _conversation_user_usage(conversation)
    max_messages = int(limits["max_messages_per_conversation"])
    max_chars = int(limits["max_chars_per_message"])
    max_total = int(limits["max_total_chars_per_conversation"])
    if len(user_message) > max_chars:
        _shared_chat_limit_exception(
            "message_char_limit",
            messages["char_limit"],
            {"message_chars": len(user_message), "max_chars_per_message": max_chars},
        )
    if usage["messages"] >= max_messages:
        _shared_chat_limit_exception(
            "conversation_message_limit",
            messages["limit_reached"],
            {"messages_used": usage["messages"], "max_messages_per_conversation": max_messages},
        )
    if usage["chars"] + len(user_message) > max_total:
        _shared_chat_limit_exception(
            "conversation_char_limit",
            messages["limit_reached"],
            {"chars_used": usage["chars"], "message_chars": len(user_message), "max_total_chars_per_conversation": max_total},
        )
    next_messages = usage["messages"] + 1
    next_chars = usage["chars"] + len(user_message)
    warn_at = max(1, int(max_messages * float(config["ui"].get("warning_threshold_ratio") or 0.8)))
    return {
        "messages_used": next_messages,
        "messages_remaining": max(0, max_messages - next_messages),
        "max_messages_per_conversation": max_messages,
        "chars_used": next_chars,
        "chars_remaining": max(0, max_total - next_chars),
        "max_total_chars_per_conversation": max_total,
        "max_chars_per_message": max_chars,
        "near_limit": bool(config["ui"].get("show_near_limit_warning") and next_messages >= warn_at),
        "near_limit_message": messages["near_limit"],
    }


def validate_shared_chat_event_window(db: Session, agent: Agent, context: dict[str, Any], config: dict[str, Any]) -> None:
    limits = config["limits"]
    since = datetime.utcnow() - timedelta(hours=1)
    scopes = [
        ("session_id", context.get("session_id"), int(limits.get("max_messages_per_session") or 0), "session_message_limit"),
        ("visitor_id", context.get("visitor_id"), int(limits.get("max_messages_per_visitor") or 0), "visitor_message_limit"),
        ("ip_hash", context.get("ip_hash"), int(limits.get("max_messages_per_ip") or 0), "ip_message_limit"),
    ]
    for column_name, value, limit, code in scopes:
        if not value or limit <= 0:
            continue
        try:
            column = getattr(SharedChatUsageEvent, column_name)
            query = db.query(func.count(SharedChatUsageEvent.id)).filter(
                SharedChatUsageEvent.agent_id == agent.id,
                SharedChatUsageEvent.event_type == "message",
                SharedChatUsageEvent.created_at >= since,
                column == value,
            )
            if context.get("shared_chat_link_id"):
                query = query.filter(SharedChatUsageEvent.shared_chat_link_id == int(context["shared_chat_link_id"]))
            count = query.scalar()
            if int(count or 0) >= limit:
                _shared_chat_limit_exception(code, config["messages"]["limit_reached"], {column_name: value, "limit": limit})
        except HTTPException:
            raise
        except Exception:
            continue


def record_shared_chat_usage_event(
    db: Session,
    agent: Agent,
    conversation: Conversation | None,
    context: dict[str, Any],
    event_type: str,
    source: str,
    chars_count: int = 0,
    metadata: dict[str, Any] | None = None,
) -> None:
    db.add(
        SharedChatUsageEvent(
            agent_id=agent.id,
            shared_chat_link_id=context.get("shared_chat_link_id"),
            conversation_id=conversation.id if conversation else None,
            session_id=context.get("session_id"),
            visitor_id=context.get("visitor_id"),
            ip_hash=context.get("ip_hash"),
            event_type=event_type,
            source=source,
            chars_count=max(0, int(chars_count or 0)),
            metadata_json=metadata or {},
        )
    )


async def _shared_chat_message(
    agent_id: str,
    request: Request,
    db: Session,
    source: str,
    require_published: bool,
    demo: bool = False,
) -> dict[str, Any]:
    agent, link = resolve_shared_chat_target(db, agent_id, create_default_link=True)
    if not agent:
        raise HTTPException(status_code=404, detail="Agente não encontrado")
    ensure_shared_chat_link_available(link)
    if require_published and not agent.is_published:
        raise HTTPException(status_code=403, detail="Este agente ainda não está publicado.")

    try:
        body = await request.json()
    except Exception:
        raise HTTPException(status_code=400, detail="Envie um JSON válido.")
    if not isinstance(body, dict):
        raise HTTPException(status_code=400, detail="Envie um JSON válido.")

    raw_message = str(body.get("message") or "").strip()
    user_message = sanitize_user_input(raw_message) if raw_message else ""
    if not user_message:
        raise HTTPException(status_code=400, detail="Informe a mensagem.")
    config = shared_chat_effective_config(agent, link)
    max_chars = int(config["limits"]["max_chars_per_message"])
    if len(user_message) > max_chars:
        _shared_chat_limit_exception(
            "message_char_limit",
            config["messages"]["char_limit"],
            {"message_chars": len(user_message), "max_chars_per_message": max_chars},
        )
    context = _shared_chat_request_context(request, body)
    if link:
        context["shared_chat_link_id"] = link.id
        context["shared_chat_link_slug"] = link.slug
        context["shared_chat_link_name"] = link.name

    conversation = service_get_or_create_conversation(
        db,
        agent.id,
        _public_conversation_key(context["session_id"], shared_chat_link_id=link.id if link else None),
    )
    state = dict(conversation.state or {})
    state["source"] = source
    state["customer_name"] = _shared_chat_customer_name(body, state.get("customer_name"))
    state["shared_chat"] = {
        **(state.get("shared_chat") if isinstance(state.get("shared_chat"), dict) else {}),
        "link_id": link.id if link else None,
        "link_slug": link.slug if link else None,
        "link_name": clean_display_text(link.name) if link else None,
        "link_status": link.status if link else "active",
        "session_id": context["session_id"],
        "visitor_id": context["visitor_id"],
        "customer_name": state["customer_name"],
        "demo": bool(demo),
        "channel_label": "Chat Compartilhado",
        "updated_at": datetime.utcnow().isoformat(),
    }
    if link:
        conversation.shared_chat_link_id = link.id
    conversation.state = state
    db.add(conversation)
    db.commit()

    validate_shared_chat_event_window(db, agent, context, config)
    usage = validate_shared_chat_message(agent, conversation, user_message, config)
    run = AgentRun(
        agent_id=agent.id,
        conversation_id=conversation.id,
        status="started",
        input={
            "message": user_message,
            "source": source,
            "session_id": context["session_id"],
            "shared_chat_link_id": link.id if link else None,
            "customer_name": state["customer_name"],
        },
        is_test=False,
    )
    db.add(run)
    db.commit()
    db.refresh(run)

    try:
        hydrate_previous_context(db, conversation)
        append_conversation_message(db, conversation, "user", user_message)
        db.commit()

        ai_response = (await runtime_get_ai_response(agent, conversation, user_message, run_id=run.id) or "").strip()
        if not ai_response:
            ai_response = "Recebi sua mensagem, mas não consegui gerar uma resposta agora. Tente novamente."

        append_conversation_message(db, conversation, "assistant", ai_response)
        flush_pending_conversation_archive(db, conversation, agent)
        record_shared_chat_usage_event(
            db,
            agent,
            conversation,
            context,
            "message",
            source,
            chars_count=len(user_message),
            metadata={"usage": usage, "demo": bool(demo), "shared_chat_link_id": link.id if link else None},
        )
        run.status = "done"
        run.output = {"message": ai_response, "source": source, "usage": usage, "shared_chat_link_id": link.id if link else None}
        db.add(run)
        db.commit()
        return {
            "reply": ai_response,
            "usage": usage,
            "limits": config["limits"],
            "channel_label": "Chat Compartilhado",
            "status": "answered",
        }
    except Exception as exc:
        logger.exception("public_chat_message agent_id=%s error: %s", agent_id, exc)
        run.status = "error"
        run.output = {"error": str(exc)}
        db.add(run)
        db.commit()
        raise HTTPException(status_code=500, detail="Erro ao gerar resposta do agente.")


@app.post("/api/chat/{agent_id}/public")
async def public_chat_message(agent_id: str, request: Request, db: Session = Depends(get_db)) -> dict[str, Any]:
    return await _shared_chat_message(agent_id, request, db, "shared_chat", True, demo=False)
    agent = get_agent_by_public_ref(db, agent_id)
    if not agent:
        raise HTTPException(status_code=404, detail="Agente não encontrado")
    if not agent.is_published:
        raise HTTPException(status_code=403, detail="Este agente ainda não está publicado.")

    try:
        body = await request.json()
    except Exception:
        raise HTTPException(status_code=400, detail="Envie um JSON válido.")

    raw_message = str(body.get("message") or "").strip()
    user_message = sanitize_user_input(raw_message) if raw_message else ""
    if not user_message:
        raise HTTPException(status_code=400, detail="Informe a mensagem.")
    if len(user_message) > 4000:
        raise HTTPException(status_code=400, detail="Mensagem muito longa.")

    session_id = str(body.get("session_id") or secrets.token_urlsafe(16)).strip()[:160]
    conversation = service_get_or_create_conversation(db, agent.id, _public_conversation_key(session_id))
    state = dict(conversation.state or {})
    state.setdefault("source", "public_test")
    state.setdefault("customer_name", "Visitante")
    conversation.state = state
    db.add(conversation)
    db.commit()

    run = AgentRun(
        agent_id=agent.id,
        conversation_id=conversation.id,
        status="started",
        input={"message": user_message, "source": "public_test", "session_id": session_id},
        is_test=False,
    )
    db.add(run)
    db.commit()
    db.refresh(run)

    try:
        hydrate_previous_context(db, conversation)
        append_conversation_message(db, conversation, "user", user_message)
        db.commit()

        ai_response = (await runtime_get_ai_response(agent, conversation, user_message, run_id=run.id) or "").strip()
        if not ai_response:
            ai_response = "Recebi sua mensagem, mas não consegui gerar uma resposta agora. Tente novamente."

        append_conversation_message(db, conversation, "assistant", ai_response)
        flush_pending_conversation_archive(db, conversation, agent)
        run.status = "done"
        run.output = {"message": ai_response, "source": "public_test"}
        db.add(run)
        db.commit()
        return {"reply": ai_response}
    except Exception as exc:
        logger.exception("public_chat_message agent_id=%s error: %s", agent_id, exc)
        run.status = "error"
        run.output = {"error": str(exc)}
        db.add(run)
        db.commit()
        raise HTTPException(status_code=500, detail="Erro ao gerar resposta do agente.")


@app.post("/api/chat/{agent_id}/demo")
async def public_chat_demo_message(agent_id: str, request: Request, db: Session = Depends(get_db)) -> dict[str, Any]:
    return await _shared_chat_message(agent_id, request, db, "shared_chat_demo", False, demo=True)


def _valid_email(value: str) -> bool:
    return bool(re.match(r"^[^@\s]+@[^@\s]+\.[^@\s]+$", value or ""))


def _shared_chat_contact_tags(request_type: str, origin: str) -> list[dict[str, str]]:
    tags = [{"name": "Chat Compartilhado", "color": "#2563eb"}]
    if request_type in {"Sugestão", "Sugestão de melhoria"}:
        tags.append({"name": request_type, "color": "#7c3aed"})
    elif request_type in {"Erro no chat", "Resposta incorreta", "Problema técnico"}:
        tags.append({"name": "Erro no chat", "color": "#dc2626"})
    elif request_type == "Quero falar com uma pessoa":
        tags.append({"name": "Contato humano", "color": "#16a34a"})
    if origin.startswith("limit_"):
        tags.append({"name": "Limite atingido", "color": "#f59e0b"})
    return tags


def _shared_chat_contact_summary(agent: Agent, conversation: Conversation, data: dict[str, str]) -> str:
    history = "\n".join(
        f"- {item.get('role')}: {clean_display_text(item.get('content'))}"
        for item in (conversation.history or [])[-6:]
        if isinstance(item, dict) and item.get("content")
    )
    parts = [
        f"Solicitação do Chat Compartilhado - {data['request_type']}",
        f"Agente: {clean_display_text(agent.name)}",
        f"Nome: {data.get('name') or 'Não informado'}",
        f"E-mail: {data.get('email') or 'Não informado'}",
        f"Telefone: {data.get('phone') or 'Não informado'}",
        f"Origem: {data.get('origin') or 'contact_button'}",
        f"Mensagem: {data['message']}",
    ]
    if history:
        parts.append("Contexto recente:\n" + history)
    return "\n".join(parts)[:5000]


@app.post("/api/chat/{agent_id}/contact")
async def submit_public_chat_contact(agent_id: str, request: Request, db: Session = Depends(get_db)) -> dict[str, Any]:
    agent, link = resolve_shared_chat_target(db, agent_id, create_default_link=True)
    if not agent:
        raise HTTPException(status_code=404, detail="Agente não encontrado.")
    ensure_shared_chat_link_available(link)
    try:
        body = await request.json()
    except Exception:
        raise HTTPException(status_code=400, detail="Envie um JSON válido.")
    if not isinstance(body, dict):
        raise HTTPException(status_code=400, detail="Envie um JSON válido.")
    if str(body.get("website") or "").strip():
        return {"status": "received"}

    config = shared_chat_effective_config(agent, link)
    if not config["contact"]["enabled"]:
        raise HTTPException(status_code=403, detail="Contato indisponível neste chat.")
    context = _shared_chat_request_context(request, body)
    if link:
        context["shared_chat_link_id"] = link.id
        context["shared_chat_link_slug"] = link.slug
        context["shared_chat_link_name"] = link.name
    conversation = service_get_or_create_conversation(
        db,
        agent.id,
        _public_conversation_key(context["session_id"], shared_chat_link_id=link.id if link else None),
    )
    state = dict(conversation.state or {})
    contact_count = int(state.get("shared_chat_contact_count") or 0)
    if contact_count >= 5:
        raise HTTPException(status_code=429, detail={"message": "Recebemos muitas solicitações desta sessão. Tente novamente mais tarde."})

    request_type = clean_display_text(body.get("request_type")) or "Dúvida"
    if request_type not in SHARED_CHAT_REQUEST_TYPES:
        request_type = "Outro"
    name = (clean_display_text(body.get("name")) or "")[:120]
    email = (clean_display_text(body.get("email")) or "")[:255]
    phone = (clean_display_text(body.get("phone")) or "")[:80]
    message = sanitize_user_input(body.get("message"), max_chars=2500)
    if config["contact"]["require_name"] and not name:
        raise HTTPException(status_code=400, detail="Informe seu nome.")
    if config["contact"]["require_email"] and not email:
        raise HTTPException(status_code=400, detail="Informe seu e-mail.")
    if email and not _valid_email(email):
        raise HTTPException(status_code=400, detail="Informe um e-mail válido.")
    if config["contact"]["require_phone"] and not phone:
        raise HTTPException(status_code=400, detail="Informe seu telefone.")
    if not message:
        raise HTTPException(status_code=400, detail="Informe a mensagem.")

    origin = clean_display_text(body.get("origin")) or "contact_button"
    data = {
        "name": name,
        "email": email,
        "phone": phone,
        "request_type": request_type,
        "message": message,
        "origin": origin,
        "url": clean_display_text(body.get("url") or context.get("url")) or "",
    }
    state["source"] = state.get("source") or "shared_chat"
    state["customer_name"] = _shared_chat_customer_name({"name": name}, state.get("customer_name"))
    state["shared_chat_contact_count"] = contact_count + 1
    state["shared_chat"] = {
        **(state.get("shared_chat") if isinstance(state.get("shared_chat"), dict) else {}),
        "link_id": link.id if link else None,
        "link_slug": link.slug if link else None,
        "link_name": clean_display_text(link.name) if link else None,
        "link_status": link.status if link else "active",
        "session_id": context["session_id"],
        "visitor_id": context["visitor_id"],
        "channel_label": "Chat Compartilhado",
        "last_contact_type": request_type,
        "updated_at": datetime.utcnow().isoformat(),
    }
    if link:
        conversation.shared_chat_link_id = link.id
    conversation.state = state
    db.add(conversation)

    ticket = Ticket(
        conversation_id=conversation.id,
        agent_id=agent.id,
        status="unanswered",
        summary=_shared_chat_contact_summary(agent, conversation, data),
        tags=_shared_chat_contact_tags(request_type, origin),
        ticket_metadata={
            "created_by": "shared_chat_contact",
            "source": "shared_chat_contact",
            "origin": origin,
            "request_type": request_type,
            "visitor_name": name,
            "visitor_email": email,
            "visitor_phone": phone,
            "session_id": context["session_id"],
            "visitor_id": context["visitor_id"],
            "ip_hash": context.get("ip_hash"),
            "url": data["url"],
            "shared_chat_link_id": link.id if link else None,
            "shared_chat_link_slug": link.slug if link else None,
        },
    )
    db.add(ticket)
    db.flush()
    record_shared_chat_usage_event(
        db,
        agent,
        conversation,
        context,
        "contact",
        "shared_chat_contact",
        chars_count=len(message),
        metadata={"request_type": request_type, "origin": origin, "ticket_id": ticket.id, "shared_chat_link_id": link.id if link else None},
    )
    db.commit()
    db.refresh(ticket)
    return {
        "status": "received",
        "message": "Recebemos sua solicitação. Nossa equipe entrará em contato em breve.",
        "ticket_id": ticket.id,
    }


def site_agent_api_url(agent_id: int) -> str:
    return f"{WEBHOOK_BASE_URL}/api/site-agents/{agent_id}/chat"


def site_conversation_key(session_id: str) -> str:
    digest = hashlib.sha256(session_id.encode("utf-8")).hexdigest()[:24]
    return f"site-{digest}"


def agent_test_conversation_key(session_id: str) -> str:
    digest = hashlib.sha256(session_id.encode("utf-8")).hexdigest()[:24]
    return f"test-{digest}"


def is_agent_test_conversation(conversation: Conversation) -> bool:
    state = conversation.state if isinstance(conversation.state, dict) else {}
    return conversation.customer_phone.startswith("test-") or state.get("source") == "agent_test"


def conversation_channel(conversation: Conversation) -> str:
    state = conversation.state if isinstance(conversation.state, dict) else {}
    source = str(state.get("source") or "").strip().lower()
    if source == "agent_test" or conversation.customer_phone.startswith("test-"):
        return "agent_test"
    if source in {"public", "public_test", "shared_chat", "shared_chat_demo", "shared_chat_contact"} or conversation.customer_phone.startswith("pub-"):
        return "public"
    if source == "site" or conversation.customer_phone.startswith("site-"):
        return "site"
    return "whatsapp"


def _without_agent_test_conversations(query: Any) -> Any:
    return query.filter(~Conversation.customer_phone.like("test-%"))


def site_run_status(conversation: Conversation) -> str:
    handoff = (conversation.state or {}).get("human_handoff") or {}
    return "handoff_requested" if isinstance(handoff, dict) and handoff.get("requested") else "answered"


def agent_chat_run_output(ai_response: str, conversation: Conversation, source: str) -> dict[str, Any]:
    state = conversation.state or {}
    return {
        "message": ai_response,
        "source": source,
        "objective_state": state.get("objectives"),
        "human_handoff": state.get("human_handoff"),
        "sources": state.get("last_sources", []),
        "tool_decisions": state.get("tool_decisions", []),
        "conditional_prompts": state.get("conditional_prompts", {}),
        "whatsapp_notifications": state.get("whatsapp_notifications", []),
        "last_whatsapp_notification": state.get("last_whatsapp_notification"),
        "last_whatsapp_notification_error": state.get("last_whatsapp_notification_error"),
        "whatsapp_notifications_blocked": state.get("whatsapp_notifications_blocked", []),
    }


def site_run_output(ai_response: str, conversation: Conversation) -> dict[str, Any]:
    return agent_chat_run_output(ai_response, conversation, "site")


async def _maybe_create_handoff_ticket_and_notify(
    db: Session,
    agent: Agent,
    conversation: Conversation,
    reason: str,
) -> dict[str, Any]:
    ticket = maybe_create_handoff_ticket(db, agent, conversation, reason)
    if not ticket:
        return {"ticket": None, "whatsapp_notify": {"sent": False, "skipped": "ticket não criado"}}

    ticket_data = ticket_payload(ticket, db)
    try:
        notify_result = await _notify_whatsapp_after_ticket_creation(
            db,
            agent,
            conversation,
            {"ticket": ticket_data},
            {"reason": reason, "summary": ticket_data.get("summary") or reason, "trigger_type": "human_request"},
        )
        db.commit()
        return {"ticket": ticket_data, "whatsapp_notify": notify_result}
    except Exception as exc:
        db.rollback()
        state = dict(conversation.state or {})
        state["last_whatsapp_notification_error"] = {
            "reason": str(exc)[:300],
            "updated_at": datetime.utcnow().isoformat(),
        }
        conversation.state = state
        db.add(conversation)
        db.commit()
        logger.warning(
            "WhatsApp notify after handoff ticket failed agent=%s conversation=%s: %s",
            agent.id,
            conversation.id,
            exc,
        )
        return {"ticket": ticket_data, "whatsapp_notify": {"sent": False, "error": str(exc)}}


async def _maybe_notify_site_conversion(
    db: Session,
    agent: Agent,
    conversation: Conversation,
    user_message: str,
) -> dict[str, Any]:
    try:
        result = await maybe_notify_whatsapp_site_conversion(db, agent, conversation, user_message)
        db.commit()
        return result
    except Exception as exc:
        db.rollback()
        state = dict(conversation.state or {})
        state["last_whatsapp_notification_error"] = {
            "source": "site_conversion",
            "reason": str(exc)[:300],
            "updated_at": datetime.utcnow().isoformat(),
        }
        conversation.state = state
        db.add(conversation)
        db.commit()
        logger.warning(
            "WhatsApp site conversion notify failed agent=%s conversation=%s: %s",
            agent.id,
            conversation.id,
            exc,
        )
        return {"sent": False, "error": str(exc), "source": "site_conversion"}


def _agent_test_session_payload(conversation: Conversation, db: Session, include_messages: bool = False) -> dict[str, Any]:
    state = conversation.state if isinstance(conversation.state, dict) else {}
    session_id = str(state.get("test_session_id") or conversation.customer_phone).strip()
    last_run = (
        db.query(AgentRun)
        .filter(AgentRun.conversation_id == conversation.id)
        .order_by(AgentRun.id.desc())
        .first()
    )
    payload: dict[str, Any] = {
        "id": conversation.id,
        "conversation_id": conversation.id,
        "agent_id": conversation.agent_id,
        "session_id": session_id,
        "name": clean_display_text(state.get("test_session_name") or state.get("customer_name") or "Teste interno"),
        "messages_count": len(conversation.history or []),
        "preview": "\n".join(
            f"{m.get('role')}: {clean_display_text(m.get('content'))}" for m in (conversation.history or [])[-4:]
        ),
        "last_run_id": last_run.id if last_run else None,
        "last_status": last_run.status if last_run else "pronto",
        "latency_ms": last_run.latency_ms if last_run else None,
        "created_at": conversation.created_at.isoformat(),
        "updated_at": conversation.updated_at.isoformat(),
    }
    if include_messages:
        payload["messages"] = [
            {
                "id": message.id,
                "role": message.role,
                "content": clean_display_text(message.content),
                "created_at": message.created_at.isoformat(),
            }
            for message in db.query(Message).filter(Message.conversation_id == conversation.id).order_by(Message.id.asc()).all()
        ]
    return payload


@app.get("/api/agents")
def list_agents(db: Session = Depends(get_db)) -> list[dict[str, Any]]:
    agents = db.query(Agent).order_by(Agent.id.desc()).all()
    return [
        {
            "id": agent.id,
            "name": clean_display_text(agent.name),
            "public_slug": agent.public_slug,
            "instance_token": agent.instance_token,
            **agent_config_payload(agent),
            "system_prompt": clean_display_text(agent.system_prompt),
            "ai_model": agent.ai_model,
            "has_api_key": bool(agent.ai_api_key),
            "temperature": agent.temperature,
            "setup_status": agent.setup_status,
            "is_published": bool(agent.is_published),
            "setup_progress": agent.setup_progress or {},
            "shared_chat_config": normalize_shared_chat_config(agent.shared_chat_config or {}),
            "published_at": agent.published_at.isoformat() if agent.published_at else None,
            "webhook": f"{WEBHOOK_BASE_URL}/webhook/{agent.id}",
            "site_api": site_agent_api_url(agent.id),
        }
        for agent in agents
    ]


@app.get("/api/agents/{agent_id}")
def get_agent(agent_id: int, db: Session = Depends(get_db)) -> dict[str, Any]:
    agent = db.get(Agent, agent_id)
    if not agent:
        raise HTTPException(status_code=404, detail="Agente não encontrado.")
    return {
        "id": agent.id,
        "name": clean_display_text(agent.name),
        "public_slug": agent.public_slug,
        "instance_token": agent.instance_token,
        **agent_config_payload(agent),
        "system_prompt": clean_display_text(agent.system_prompt),
        "ai_model": agent.ai_model,
        "has_api_key": bool(agent.ai_api_key),
        "temperature": agent.temperature,
        "setup_status": agent.setup_status,
        "is_published": bool(agent.is_published),
        "setup_progress": agent.setup_progress or {},
        "shared_chat_config": normalize_shared_chat_config(agent.shared_chat_config or {}),
        "published_at": agent.published_at.isoformat() if agent.published_at else None,
        "webhook": f"{WEBHOOK_BASE_URL}/webhook/{agent.id}",
        "site_api": site_agent_api_url(agent.id),
    }


@app.post("/api/site-agents/{agent_id}/chat")
async def site_agent_chat(agent_id: str, request: Request, db: Session = Depends(get_db)) -> dict[str, Any]:
    agent = get_agent_by_public_ref(db, agent_id)
    if not agent:
        raise HTTPException(status_code=404, detail="Agente não encontrado.")

    try:
        body = await request.json()
    except Exception:
        raise HTTPException(status_code=400, detail="Envie um JSON valido.")
    if not isinstance(body, dict):
        raise HTTPException(status_code=400, detail="Envie um JSON com message.")
    if not agent.is_published:
        return JSONResponse({"status": "skipped", "reason": "agent_not_published", "agent_id": agent.id}, status_code=202)

    raw_message = str(body.get("message") or body.get("text") or "").strip()
    user_message = sanitize_user_input(raw_message)
    if not user_message:
        raise HTTPException(status_code=400, detail="Informe a mensagem.")
    config = shared_chat_effective_config(agent)
    max_chars = int(config["limits"]["max_chars_per_message"])
    if len(user_message) > max_chars:
        _shared_chat_limit_exception(
            "message_char_limit",
            config["messages"]["char_limit"],
            {"message_chars": len(user_message), "max_chars_per_message": max_chars},
        )

    session_id = str(body.get("session_id") or body.get("visitor_id") or secrets.token_urlsafe(16)).strip()
    session_id = session_id[:160] or secrets.token_urlsafe(16)
    context = _shared_chat_request_context(request, {**body, "session_id": session_id})
    session_id = context["session_id"]
    conversation = service_get_or_create_conversation(db, agent.id, site_conversation_key(session_id))
    state = dict(conversation.state or {})
    state["source"] = "site"
    state["site_session_id"] = session_id
    state["site_visitor_id"] = context["visitor_id"]
    if body.get("customer_name"):
        state["customer_name"] = clean_display_text(str(body.get("customer_name")))[:120]
    elif not state.get("customer_name"):
        state["customer_name"] = "Visitante do site"
    if isinstance(body.get("metadata"), dict):
        state["site_metadata"] = {
            str(key)[:60]: clean_display_text(str(value))[:240]
            for key, value in body["metadata"].items()
            if value is not None
        }
    conversation.state = state
    db.add(conversation)
    db.commit()

    validate_shared_chat_event_window(db, agent, context, config)
    usage = validate_shared_chat_message(agent, conversation, user_message, config)

    started = perf_counter()
    run = AgentRun(
        agent_id=agent.id,
        conversation_id=conversation.id,
        status="started",
        input={"message": user_message, "source": "site", "session_id": session_id, "visitor_id": context["visitor_id"]},
    )
    db.add(run)
    db.commit()
    db.refresh(run)

    try:
        hydrate_previous_context(db, conversation)
        append_conversation_message(db, conversation, "user", user_message)
        db.commit()

        ai_response = (await runtime_get_ai_response(agent, conversation, user_message, run_id=run.id) or "").strip()
        if not ai_response:
            ai_response = "Recebi sua mensagem, mas não consegui gerar uma resposta agora. Pode enviar de novo?"
        append_conversation_message(db, conversation, "assistant", ai_response)
        flush_pending_conversation_archive(db, conversation, agent)
        record_shared_chat_usage_event(
            db,
            agent,
            conversation,
            context,
            "message",
            "site",
            chars_count=len(user_message),
            metadata={"usage": usage, "channel": "site"},
        )
        handoff_result = await _maybe_create_handoff_ticket_and_notify(
            db,
            agent,
            conversation,
            "Repasse humano solicitado ou identificado pelo agente do site.",
        )
        site_conversion_notify = await _maybe_notify_site_conversion(db, agent, conversation, user_message)
        run.status = site_run_status(conversation)
        run.output = site_run_output(ai_response, conversation)
        run.output["usage"] = usage
        run.output["handoff_ticket"] = handoff_result
        run.output["site_conversion_notify"] = site_conversion_notify
        run.latency_ms = int((perf_counter() - started) * 1000)
        db.add(run)
        db.commit()
    except Exception as exc:
        db.rollback()
        run.status = "failed"
        run.error = str(exc)
        run.latency_ms = int((perf_counter() - started) * 1000)
        db.add(run)
        db.commit()
        logger.error("Site agent chat failed agent=%s: %s", agent.id, exc, exc_info=True)
        raise HTTPException(status_code=502, detail=f"Falha ao responder: {exc}") from exc

    return {
        "status": run.status,
        "agent_id": agent.id,
        "conversation_id": conversation.id,
        "session_id": session_id,
        "reply": ai_response,
        "message": ai_response,
        "usage": usage,
        "limits": config["limits"],
    }


@app.post("/api/agents/{agent_id}/test")
@app.post("/api/agents/{agent_id}/test-chat")
async def test_agent_chat(agent_id: int, request: Request, db: Session = Depends(get_db)) -> dict[str, Any]:
    agent = db.get(Agent, agent_id)
    if not agent:
        raise HTTPException(status_code=404, detail="Agente não encontrado.")

    try:
        body = await request.json()
    except Exception:
        raise HTTPException(status_code=400, detail="Envie um JSON valido.")
    if not isinstance(body, dict):
        raise HTTPException(status_code=400, detail="Envie um JSON com message.")

    raw_message = str(body.get("message") or body.get("text") or "").strip()
    user_message = sanitize_user_input(raw_message)
    if not user_message:
        raise HTTPException(status_code=400, detail="Informe a mensagem.")
    if len(user_message) > 4000:
        raise HTTPException(status_code=400, detail="Mensagem muito longa.")

    session_id = str(body.get("session_id") or secrets.token_urlsafe(16)).strip()
    session_id = session_id[:160] or secrets.token_urlsafe(16)
    conversation = service_get_or_create_conversation(db, agent.id, agent_test_conversation_key(session_id))
    state = dict(conversation.state or {})
    state["source"] = "agent_test"
    state["test_session_id"] = session_id
    state["customer_name"] = body.get("customer_name") or "Teste interno"
    conversation.state = state
    db.add(conversation)
    db.commit()

    started = perf_counter()
    run = AgentRun(
        agent_id=agent.id,
        conversation_id=conversation.id,
        status="started",
        input={"message": user_message, "source": "agent_test", "session_id": session_id},
        is_test=True,
    )
    db.add(run)
    db.commit()
    db.refresh(run)

    try:
        hydrate_previous_context(db, conversation)
        append_conversation_message(db, conversation, "user", user_message)
        db.commit()

        ai_response = (await runtime_get_ai_response(agent, conversation, user_message, run_id=run.id) or "").strip()
        if not ai_response:
            ai_response = "Recebi sua mensagem, mas não consegui gerar uma resposta agora. Pode enviar de novo?"
        append_conversation_message(db, conversation, "assistant", ai_response)
        flush_pending_conversation_archive(db, conversation, agent)
        handoff_result = await _maybe_create_handoff_ticket_and_notify(
            db,
            agent,
            conversation,
            "Repasse humano solicitado em teste do agente.",
        )
        site_conversion_notify = await _maybe_notify_site_conversion(db, agent, conversation, user_message)
        run.status = site_run_status(conversation)
        run.output = agent_chat_run_output(ai_response, conversation, "agent_test")
        run.output["handoff_ticket"] = handoff_result
        run.output["site_conversion_notify"] = site_conversion_notify
        run.latency_ms = int((perf_counter() - started) * 1000)
        db.add(run)
        db.commit()
    except Exception as exc:
        db.rollback()
        run.status = "failed"
        run.error = str(exc)
        run.latency_ms = int((perf_counter() - started) * 1000)
        db.add(run)
        db.commit()
        logger.error("Agent test chat failed agent=%s: %s", agent.id, exc, exc_info=True)
        raise HTTPException(status_code=502, detail=f"Falha ao testar agente: {exc}") from exc

    output = run.output or {}
    return {
        "status": run.status,
        "agent_id": agent.id,
        "conversation_id": conversation.id,
        "run_id": run.id,
        "session_id": session_id,
        "latency_ms": run.latency_ms,
        "reply": ai_response,
        "message": ai_response,
        "runtime": {
            "objective_state": output.get("objective_state"),
            "human_handoff": output.get("human_handoff"),
            "sources": output.get("sources") or [],
            "tool_decisions": output.get("tool_decisions") or [],
            "conditional_prompts": output.get("conditional_prompts") or {},
            "last_whatsapp_notification": output.get("last_whatsapp_notification"),
            "last_whatsapp_notification_error": output.get("last_whatsapp_notification_error"),
            "whatsapp_notifications_blocked": output.get("whatsapp_notifications_blocked") or [],
            "handoff_ticket": output.get("handoff_ticket"),
            "site_conversion_notify": output.get("site_conversion_notify"),
        },
    }


@app.get("/api/agents/{agent_id}/test-sessions")
def list_agent_test_sessions(agent_id: int, db: Session = Depends(get_db)) -> list[dict[str, Any]]:
    if not db.get(Agent, agent_id):
        raise HTTPException(status_code=404, detail="Agente não encontrado.")
    conversations = (
        db.query(Conversation)
        .filter(Conversation.agent_id == agent_id, Conversation.customer_phone.like("test-%"))
        .order_by(Conversation.updated_at.desc())
        .limit(100)
        .all()
    )
    return [_agent_test_session_payload(conversation, db) for conversation in conversations if is_agent_test_conversation(conversation)]


@app.post("/api/agents/{agent_id}/test-sessions")
async def create_agent_test_session(agent_id: int, request: Request, db: Session = Depends(get_db)) -> dict[str, Any]:
    agent = db.get(Agent, agent_id)
    if not agent:
        raise HTTPException(status_code=404, detail="Agente não encontrado.")
    try:
        body = await request.json()
    except Exception:
        body = {}
    if not isinstance(body, dict):
        body = {}
    session_id = str(body.get("session_id") or secrets.token_urlsafe(18)).strip()
    session_id = session_id[:160] or secrets.token_urlsafe(18)
    conversation = service_get_or_create_conversation(db, agent.id, agent_test_conversation_key(session_id))
    state = dict(conversation.state or {})
    state["source"] = "agent_test"
    state["test_session_id"] = session_id
    state["customer_name"] = clean_display_text(body.get("name") or "Teste interno")
    state["test_session_name"] = clean_display_text(body.get("name") or "Teste interno")
    conversation.state = state
    db.add(conversation)
    db.commit()
    db.refresh(conversation)
    return _agent_test_session_payload(conversation, db, include_messages=True)


@app.get("/api/agents/{agent_id}/test-sessions/{session_id}")
def get_agent_test_session(agent_id: int, session_id: str, db: Session = Depends(get_db)) -> dict[str, Any]:
    if not db.get(Agent, agent_id):
        raise HTTPException(status_code=404, detail="Agente não encontrado.")
    conversation = (
        db.query(Conversation)
        .filter(Conversation.agent_id == agent_id, Conversation.customer_phone == agent_test_conversation_key(session_id))
        .first()
    )
    if not conversation or not is_agent_test_conversation(conversation):
        raise HTTPException(status_code=404, detail="Sessão de teste não encontrada.")
    return _agent_test_session_payload(conversation, db, include_messages=True)


@app.delete("/api/agents/{agent_id}/test-sessions")
def delete_agent_test_sessions(agent_id: int, db: Session = Depends(get_db)) -> dict[str, Any]:
    if not db.get(Agent, agent_id):
        raise HTTPException(status_code=404, detail="Agente não encontrado.")
    candidates = (
        db.query(Conversation)
        .filter(Conversation.agent_id == agent_id, Conversation.customer_phone.like("test-%"))
        .all()
    )
    conversations = [conversation for conversation in candidates if is_agent_test_conversation(conversation)]
    deleted = _delete_conversations(db, conversations)
    return {"status": "deleted", "agent_id": agent_id, "deleted_count": deleted}


@app.delete("/api/agents/{agent_id}/test-sessions/{session_id}")
def delete_agent_test_session(agent_id: int, session_id: str, db: Session = Depends(get_db)) -> dict[str, Any]:
    if not db.get(Agent, agent_id):
        raise HTTPException(status_code=404, detail="Agente não encontrado.")
    conversation = (
        db.query(Conversation)
        .filter(Conversation.agent_id == agent_id, Conversation.customer_phone == agent_test_conversation_key(session_id))
        .first()
    )
    if not conversation or not is_agent_test_conversation(conversation):
        raise HTTPException(status_code=404, detail="Sessão de teste não encontrada.")
    deleted = _delete_conversations(db, [conversation])
    return {"status": "deleted", "agent_id": agent_id, "session_id": session_id, "deleted_count": deleted}


@app.post("/api/agent-builder/preview")
async def agent_builder_preview(request: Request) -> dict[str, Any]:
    body = await request.json()
    description = (body.get("description") or body.get("prompt") or "").strip()
    if not description:
        raise HTTPException(status_code=400, detail="Informe uma descrição para o agente.")
    source_context = await builder_source_context(body.get("kb_sources") or body.get("sources") or [])
    try:
        return await build_agent_blueprint(
            description=description,
            model=(body.get("ai_model") or "").strip() or None,
            api_key=(body.get("ai_api_key") or "").strip() or None,
            temperature=normalize_temperature(body.get("temperature", 0.2), 0.2),
            source_context=source_context,
        )
    except Exception as exc:
        logger.error("Agent builder failed: %s", exc, exc_info=True)
        raise HTTPException(status_code=502, detail=f"Auto-Builder falhou: {exc}") from exc


@app.post("/api/agents")
async def create_agent(request: Request, db: Session = Depends(get_db)) -> dict[str, Any]:
    body = await request.json()
    instance_name = slugify_instance(body.get("instance_name") or body.get("name") or "")
    explicit_public_slug = "public_slug" in body

    if not instance_name:
        raise HTTPException(status_code=400, detail="Informe o nome da instância.")
    if explicit_public_slug:
        public_slug = validate_public_slug_or_400(body.get("public_slug"))
        if not public_slug_available(db, public_slug):
            raise HTTPException(status_code=409, detail="Slug publico ja esta em uso por outro agente.")
    else:
        public_slug = unique_public_slug(db, body.get("name") or instance_name)

    agent = Agent(
        name=body["name"].strip(),
        public_slug=public_slug,
        instance_token=instance_name,
        description=(body.get("description") or "").strip() or None,
        system_prompt=body["system_prompt"].strip(),
        ai_model=body.get("ai_model", "gpt-4o-mini").strip(),
        ai_api_key=(body.get("ai_api_key") or "").strip() or None,
        temperature=normalize_temperature(body.get("temperature", 0.7)),
        negative_rules=normalize_negative_rules(body.get("negative_rules")),
        conversation_goals=normalize_conversation_goals(body.get("conversation_goals")),
        final_goal=(body.get("final_goal") or "").strip() or None,
        handoff_config=normalize_handoff_config(body.get("handoff_config")),
        shared_chat_config=normalize_shared_chat_config(body.get("shared_chat_config"), strict=True),
        setup_status="draft",
        is_published=False,
        setup_progress={},
    )
    db.add(agent)
    db.commit()
    db.refresh(agent)
    ensure_agent_default_shared_chat_link(db, agent)
    agent_id = agent.id

    try:
        evolution = await create_evolution_instance(agent_id, instance_name)
    except Exception as exc:
        logger.error("Evolution instance creation failed for agent %s: %s", agent_id, exc)
        db.delete(agent)
        db.commit()
        raise

    post_create_error = None
    try:
        await apply_agent_tools(db, agent, body)
        apply_agent_conditional_prompts(db, agent_id, body)
        source_payload = body.get("kb_sources") or body.get("sources") or []
        tools = body.get("tools") if isinstance(body.get("tools"), dict) else {}
        kb_tool = tools.get("knowledge_base") if isinstance(tools.get("knowledge_base"), dict) else {}
        if source_payload:
            if not kb_tool.get("enabled"):
                await add_kb_sources(db, agent, source_payload)
            enable_agent_knowledge_access(db, agent)
    except Exception as exc:
        db.rollback()
        post_create_error = str(exc)
        logger.error("post-create agent config failed for agent %s: %s", agent_id, exc, exc_info=True)

    logger.info("Agent created id=%s name=%r instance=%s", agent_id, agent.name, instance_name)
    return {"agent_id": agent_id, "instance": instance_name, "evolution": evolution, "post_create_error": post_create_error}


@app.put("/api/agents/{agent_id}")
async def update_agent(agent_id: int, request: Request, db: Session = Depends(get_db)) -> dict[str, Any]:
    agent = db.get(Agent, agent_id)
    if not agent:
        raise HTTPException(status_code=404, detail="Agent not found.")

    body = await request.json()
    if body.get("name"):
        agent.name = body["name"].strip()
    if "public_slug" in body:
        set_agent_public_slug(db, agent, body.get("public_slug"))
    if "instance_name" in body or "instance_token" in body:
        instance_name = slugify_instance(body.get("instance_name") or body.get("instance_token") or "")
        if not instance_name:
            raise HTTPException(status_code=400, detail="Informe o nome interno do agente.")
        if instance_name != agent.instance_token:
            existing_agent = (
                db.query(Agent)
                .filter(Agent.id != agent.id, Agent.instance_token == instance_name)
                .first()
            )
            existing_channel = db.query(Channel).filter(Channel.instance_name == instance_name).first()
            if existing_agent or existing_channel:
                raise HTTPException(status_code=409, detail="Nome interno já está em uso.")
            agent.instance_token = instance_name
    if "description" in body:
        agent.description = (body.get("description") or "").strip() or None
    if body.get("ai_model"):
        agent.ai_model = body["ai_model"].strip()
    if body.get("ai_api_key"):
        agent.ai_api_key = body["ai_api_key"].strip()
    if body.get("system_prompt"):
        agent.system_prompt = body["system_prompt"].strip()
    if body.get("temperature") is not None:
        agent.temperature = normalize_temperature(body["temperature"])
    if "negative_rules" in body:
        agent.negative_rules = normalize_negative_rules(body.get("negative_rules"))
    if "conversation_goals" in body:
        agent.conversation_goals = normalize_conversation_goals(body.get("conversation_goals"))
    if "final_goal" in body:
        agent.final_goal = (body.get("final_goal") or "").strip() or None
    if "handoff_config" in body:
        agent.handoff_config = normalize_handoff_config(body.get("handoff_config"))
    if "shared_chat_config" in body:
        agent.shared_chat_config = normalize_shared_chat_config(body.get("shared_chat_config"), strict=True)

    db.add(agent)
    db.commit()
    await apply_agent_tools(db, agent, body)
    apply_agent_conditional_prompts(db, agent.id, body)
    return {
        "status": "saved",
        "agent_id": agent.id,
        "instance_token": agent.instance_token,
        "public_slug": agent.public_slug,
        "ai_model": agent.ai_model,
        "has_api_key": bool(agent.ai_api_key),
        "setup_status": agent.setup_status,
        "is_published": bool(agent.is_published),
        "shared_chat_config": normalize_shared_chat_config(agent.shared_chat_config or {}),
    }


@app.post("/api/agents/{agent_id}/shared-chat/reset-limits")
def reset_agent_shared_chat_limits(agent_id: int, db: Session = Depends(get_db)) -> dict[str, Any]:
    agent = db.get(Agent, agent_id)
    if not agent:
        raise HTTPException(status_code=404, detail="Agente não encontrado.")
    reset_at = datetime.utcnow().isoformat()
    conversations = (
        db.query(Conversation)
        .filter(Conversation.agent_id == agent.id, Conversation.customer_phone.like("pub-%"))
        .all()
    )
    for conversation in conversations:
        totals = _conversation_user_usage_totals(conversation)
        state = dict(conversation.state or {})
        shared = dict(state.get("shared_chat") or {})
        shared.update(
            {
                "usage_reset_at": reset_at,
                "usage_reset_user_count": totals["messages"],
                "usage_reset_char_count": totals["chars"],
            }
        )
        state["shared_chat"] = shared
        conversation.state = state
        db.add(conversation)
    events_deleted = (
        db.query(SharedChatUsageEvent)
        .filter(SharedChatUsageEvent.agent_id == agent.id)
        .delete(synchronize_session=False)
    )
    config = normalize_shared_chat_config(agent.shared_chat_config or {})
    config["last_usage_reset_at"] = reset_at
    agent.shared_chat_config = config
    db.add(agent)
    db.commit()
    return {
        "status": "reset",
        "agent_id": agent.id,
        "conversations_reset": len(conversations),
        "events_deleted": int(events_deleted or 0),
        "reset_at": reset_at,
    }


def _shared_chat_link_admin_payload(link: SharedChatLink, db: Session, request: Request | None = None) -> dict[str, Any]:
    agent = db.get(Agent, link.agent_id)
    conversations = (
        db.query(Conversation)
        .filter(Conversation.shared_chat_link_id == link.id)
        .order_by(Conversation.updated_at.desc())
        .limit(500)
        .all()
    )
    users = {
        clean_display_text((conversation.state or {}).get("customer_name"))
        for conversation in conversations
        if isinstance(conversation.state, dict) and clean_display_text((conversation.state or {}).get("customer_name"))
    }
    payload = shared_chat_link_payload(link, agent, request)
    payload.update(
        {
            "agent_name": clean_display_text(agent.name) if agent else None,
            "conversations_count": len(conversations),
            "users_count": len(users),
            "users": sorted(users)[:20],
            "last_activity_at": conversations[0].updated_at.isoformat() if conversations else None,
        }
    )
    return payload


def _get_shared_chat_link_or_404(db: Session, link_id: int) -> SharedChatLink:
    link = db.get(SharedChatLink, link_id)
    if not link:
        raise HTTPException(status_code=404, detail="Link de Chat Compartilhado não encontrado.")
    return link


@app.get("/api/shared-chat-links")
def list_shared_chat_links(
    request: Request,
    agent_id: int | None = None,
    include_archived: bool = False,
    db: Session = Depends(get_db),
) -> list[dict[str, Any]]:
    query = db.query(SharedChatLink)
    if agent_id is not None:
        query = query.filter(SharedChatLink.agent_id == agent_id)
    if not include_archived:
        query = query.filter(SharedChatLink.archived_at.is_(None), SharedChatLink.status != "archived")
    links = query.order_by(SharedChatLink.created_at.desc(), SharedChatLink.id.desc()).all()
    return [_shared_chat_link_admin_payload(link, db, request) for link in links]


@app.post("/api/shared-chat-links")
async def create_shared_chat_link(request: Request, db: Session = Depends(get_db)) -> dict[str, Any]:
    body = await request.json()
    if not isinstance(body, dict):
        raise HTTPException(status_code=400, detail="Envie um JSON válido.")
    agent = db.get(Agent, int(body.get("agent_id") or 0))
    if not agent:
        raise HTTPException(status_code=404, detail="Agente não encontrado.")
    name = clean_display_text(body.get("name") or "Novo link")[:160] or "Novo link"
    slug = unique_shared_chat_link_slug(db, body.get("slug") or name or agent.public_slug, agent)
    status = str(body.get("status") or "active").strip().lower()
    if status not in {"active", "paused"}:
        status = "active"
    config_source = body.get("config") if "config" in body else body.get("shared_chat_config", agent.shared_chat_config or {})
    link = SharedChatLink(
        agent_id=agent.id,
        name=name,
        slug=slug,
        status=status,
        config=normalize_shared_chat_config(config_source, strict=True),
        archived_at=None,
    )
    db.add(link)
    db.commit()
    db.refresh(link)
    return _shared_chat_link_admin_payload(link, db, request)


@app.get("/api/shared-chat-links/{link_id}")
def get_shared_chat_link(link_id: int, request: Request, db: Session = Depends(get_db)) -> dict[str, Any]:
    return _shared_chat_link_admin_payload(_get_shared_chat_link_or_404(db, link_id), db, request)


@app.put("/api/shared-chat-links/{link_id}")
async def update_shared_chat_link(link_id: int, request: Request, db: Session = Depends(get_db)) -> dict[str, Any]:
    link = _get_shared_chat_link_or_404(db, link_id)
    body = await request.json()
    if not isinstance(body, dict):
        raise HTTPException(status_code=400, detail="Envie um JSON válido.")
    agent = db.get(Agent, link.agent_id)
    if body.get("name") is not None:
        link.name = clean_display_text(body.get("name"))[:160] or link.name
    if body.get("slug") is not None and agent:
        next_slug = normalize_public_slug(body.get("slug"), "")
        if not next_slug:
            raise HTTPException(status_code=400, detail="Informe um slug válido.")
        if is_reserved_public_slug(next_slug):
            raise HTTPException(status_code=400, detail="Slug reservado.")
        if not shared_chat_link_slug_available(db, next_slug, link.id):
            raise HTTPException(status_code=409, detail="Este link já está em uso.")
        link.slug = next_slug
    if body.get("status") is not None:
        status = str(body.get("status") or "").strip().lower()
        if status not in SHARED_CHAT_LINK_STATUSES:
            raise HTTPException(status_code=400, detail="Status inválido.")
        link.status = status
        link.archived_at = datetime.utcnow() if status == "archived" else None
    if "config" in body or "shared_chat_config" in body:
        link.config = normalize_shared_chat_config(body.get("config", body.get("shared_chat_config")), strict=True)
    link.updated_at = datetime.utcnow()
    db.add(link)
    db.commit()
    db.refresh(link)
    return _shared_chat_link_admin_payload(link, db, request)


@app.delete("/api/shared-chat-links/{link_id}")
def delete_shared_chat_link(link_id: int, request: Request, db: Session = Depends(get_db)) -> dict[str, Any]:
    link = _get_shared_chat_link_or_404(db, link_id)
    link.status = "archived"
    link.archived_at = datetime.utcnow()
    link.updated_at = datetime.utcnow()
    db.add(link)
    db.commit()
    db.refresh(link)
    payload = _shared_chat_link_admin_payload(link, db, request)
    payload["status"] = "archived"
    return payload


@app.post("/api/shared-chat-links/{link_id}/pause")
def pause_shared_chat_link(link_id: int, request: Request, db: Session = Depends(get_db)) -> dict[str, Any]:
    link = _get_shared_chat_link_or_404(db, link_id)
    link.status = "paused"
    link.updated_at = datetime.utcnow()
    db.add(link)
    db.commit()
    db.refresh(link)
    return _shared_chat_link_admin_payload(link, db, request)


@app.post("/api/shared-chat-links/{link_id}/activate")
def activate_shared_chat_link(link_id: int, request: Request, db: Session = Depends(get_db)) -> dict[str, Any]:
    link = _get_shared_chat_link_or_404(db, link_id)
    link.status = "active"
    link.archived_at = None
    link.updated_at = datetime.utcnow()
    db.add(link)
    db.commit()
    db.refresh(link)
    return _shared_chat_link_admin_payload(link, db, request)


@app.post("/api/shared-chat-links/{link_id}/reset-limits")
def reset_shared_chat_link_limits(link_id: int, request: Request, db: Session = Depends(get_db)) -> dict[str, Any]:
    link = _get_shared_chat_link_or_404(db, link_id)
    reset_at = datetime.utcnow().isoformat()
    conversations = (
        db.query(Conversation)
        .filter(Conversation.shared_chat_link_id == link.id)
        .all()
    )
    for conversation in conversations:
        totals = _conversation_user_usage_totals(conversation)
        state = dict(conversation.state or {})
        shared = dict(state.get("shared_chat") or {})
        shared.update(
            {
                "usage_reset_at": reset_at,
                "usage_reset_user_count": totals["messages"],
                "usage_reset_char_count": totals["chars"],
            }
        )
        state["shared_chat"] = shared
        conversation.state = state
        db.add(conversation)
    events_deleted = (
        db.query(SharedChatUsageEvent)
        .filter(SharedChatUsageEvent.shared_chat_link_id == link.id)
        .delete(synchronize_session=False)
    )
    config = normalize_shared_chat_config(link.config or {})
    config["last_usage_reset_at"] = reset_at
    link.config = config
    link.updated_at = datetime.utcnow()
    db.add(link)
    db.commit()
    payload = _shared_chat_link_admin_payload(link, db, request)
    payload.update({"reset_status": "reset", "conversations_reset": len(conversations), "events_deleted": int(events_deleted or 0), "reset_at": reset_at})
    return payload


@app.get("/api/shared-chat-links/{link_id}/conversations")
def list_shared_chat_link_conversations(link_id: int, db: Session = Depends(get_db)) -> list[dict[str, Any]]:
    link = _get_shared_chat_link_or_404(db, link_id)
    conversations = (
        db.query(Conversation)
        .filter(Conversation.shared_chat_link_id == link.id)
        .order_by(Conversation.updated_at.desc())
        .limit(200)
        .all()
    )
    return _conversation_summary_list(conversations, db)


async def agent_readiness_payload(db: Session, agent: Agent) -> dict[str, Any]:
    warnings: list[dict[str, Any]] = []
    try:
        instance_statuses = _instance_status_map(await fetch_evolution_instances())
    except Exception as exc:
        instance_statuses = {}
        warnings.append({
            "stage": "channels",
            "field": "whatsapp",
            "message": f"Não foi possível consultar o WhatsApp agora: {exc}",
            "severity": "warning",
        })
    payload = compute_readiness(db, agent, instance_statuses)
    if warnings:
        payload["warnings"] = [*(payload.get("warnings") or []), *warnings]
    apply_setup_progress(agent, payload)
    db.add(agent)
    db.commit()
    return payload


@app.get("/api/agents/{agent_id}/readiness")
async def get_agent_readiness(agent_id: int, db: Session = Depends(get_db)) -> dict[str, Any]:
    agent = db.get(Agent, agent_id)
    if not agent:
        raise HTTPException(status_code=404, detail="Agente não encontrado.")
    return await agent_readiness_payload(db, agent)


@app.get("/api/agents/{agent_id}/kb-health")
def get_agent_kb_health(agent_id: int, db: Session = Depends(get_db)) -> dict[str, Any]:
    if not db.get(Agent, agent_id):
        raise HTTPException(status_code=404, detail="Agente não encontrado.")
    return compute_kb_health(db, agent_id)


@app.post("/api/agents/{agent_id}/publish")
async def publish_agent(agent_id: int, db: Session = Depends(get_db)) -> dict[str, Any]:
    agent = db.get(Agent, agent_id)
    if not agent:
        raise HTTPException(status_code=404, detail="Agente não encontrado.")
    readiness = await agent_readiness_payload(db, agent)
    if not readiness.get("can_publish"):
        raise HTTPException(status_code=409, detail="Resolva as pendências obrigatórias antes de publicar.")
    agent.is_published = True
    agent.setup_status = "production"
    agent.published_at = datetime.utcnow()
    db.add(agent)
    db.commit()
    readiness = await agent_readiness_payload(db, agent)
    return {"status": "published", "agent_id": agent.id, "readiness": readiness}


@app.post("/api/agents/{agent_id}/unpublish")
async def unpublish_agent(agent_id: int, db: Session = Depends(get_db)) -> dict[str, Any]:
    agent = db.get(Agent, agent_id)
    if not agent:
        raise HTTPException(status_code=404, detail="Agente não encontrado.")
    agent.is_published = False
    agent.setup_status = "paused"
    db.add(agent)
    db.commit()
    readiness = await agent_readiness_payload(db, agent)
    return {"status": "paused", "agent_id": agent.id, "readiness": readiness}


@app.post("/api/agents/{agent_id}/test-message")
async def test_agent_message(agent_id: int, request: Request, db: Session = Depends(get_db)) -> dict[str, Any]:
    agent = db.get(Agent, agent_id)
    if not agent:
        raise HTTPException(status_code=404, detail="Agente não encontrado.")
    if not (agent.ai_api_key or "").strip():
        raise HTTPException(status_code=400, detail="Configure a chave de IA antes de testar.")
    try:
        body = await request.json()
    except Exception:
        raise HTTPException(status_code=400, detail="Envie um JSON valido.")
    raw_message = str((body or {}).get("message") or (body or {}).get("text") or "").strip()
    message = sanitize_user_input(raw_message)
    if not message:
        raise HTTPException(status_code=400, detail="Informe a mensagem.")
    if len(message) > 4000:
        raise HTTPException(status_code=400, detail="Mensagem muito longa.")

    recent_count = (
        db.query(func.count(AgentRun.id))
        .filter(AgentRun.agent_id == agent.id, AgentRun.is_test.is_(True))
        .scalar()
        or 0
    )
    if recent_count >= 20:
        raise HTTPException(status_code=429, detail="Limite do sandbox atingido: 20 mensagens de teste.")

    started = perf_counter()
    run = AgentRun(agent_id=agent.id, status="started", input={"message": message, "source": "sandbox"}, is_test=True)
    db.add(run)
    db.commit()
    db.refresh(run)
    try:
        response = await acompletion(
            model=agent.ai_model,
            api_key=agent.ai_api_key,
            messages=[
                {"role": "system", "content": agent.system_prompt or "Você ? um assistente prestativo."},
                {"role": "user", "content": message},
            ],
            temperature=agent.temperature,
        )
        choice = (getattr(response, "choices", None) or [{}])[0]
        msg = choice.get("message") if isinstance(choice, dict) else getattr(choice, "message", {})
        reply = (getattr(msg, "content", None) or (msg.get("content") if isinstance(msg, dict) else "") or "").strip()
        usage = getattr(response, "usage", None)
        if hasattr(usage, "model_dump"):
            usage = usage.model_dump()
        elif usage is None:
            usage = {}
        run.status = "finished"
        run.output = {"response": reply, "source": "sandbox"}
        run.token_usage = usage if isinstance(usage, dict) else {}
        run.latency_ms = int((perf_counter() - started) * 1000)
        db.add(run)
        db.commit()
    except Exception as exc:
        db.rollback()
        run.status = "failed"
        run.error = str(exc)
        run.latency_ms = int((perf_counter() - started) * 1000)
        db.add(run)
        db.commit()
        raise HTTPException(status_code=502, detail=f"Falha ao testar agente: {exc}") from exc

    readiness = await agent_readiness_payload(db, agent)
    return {
        "status": run.status,
        "agent_id": agent.id,
        "response": (run.output or {}).get("response") or "",
        "latency_ms": run.latency_ms,
        "tokens": run.token_usage or {},
        "run_id": run.id,
        "sandbox_count": recent_count + 1,
        "sandbox_limit": 20,
        "readiness": readiness,
    }


@app.delete("/api/agents/{agent_id}")
def delete_agent(agent_id: int, db: Session = Depends(get_db)) -> dict[str, Any]:
    agent = db.get(Agent, agent_id)
    if not agent:
        raise HTTPException(status_code=404, detail="Agent not found.")

    conversation_ids = [row.id for row in db.query(Conversation.id).filter(Conversation.agent_id == agent_id).all()]
    tool_ids = [row.id for row in db.query(ToolDefinition.id).filter(ToolDefinition.agent_id == agent_id).all()]
    kb_ids = [row.id for row in db.query(KnowledgeBase.id).filter(KnowledgeBase.agent_id == agent_id).all()]
    source_ids = (
        [row.id for row in db.query(KnowledgeBaseSource.id).filter(KnowledgeBaseSource.kb_id.in_(kb_ids)).all()]
        if kb_ids
        else []
    )
    document_ids = (
        [row.id for row in db.query(KnowledgeBaseDocument.id).filter(KnowledgeBaseDocument.kb_id.in_(kb_ids)).all()]
        if kb_ids
        else []
    )

    db.query(Channel).filter(Channel.agent_id == agent_id).update({Channel.agent_id: None}, synchronize_session=False)
    db.query(Squad).filter(Squad.manager_agent_id == agent_id).update({Squad.manager_agent_id: None}, synchronize_session=False)
    db.query(Squad).filter(Squad.fallback_agent_id == agent_id).update({Squad.fallback_agent_id: None}, synchronize_session=False)
    db.query(TableRecord).filter(TableRecord.created_by_agent_id == agent_id).update(
        {TableRecord.created_by_agent_id: None},
        synchronize_session=False,
    )
    db.query(MemoryItem).filter(MemoryItem.agent_id == agent_id).update({MemoryItem.agent_id: None}, synchronize_session=False)
    db.query(AgentRun).filter(AgentRun.agent_id == agent_id).update({AgentRun.agent_id: None}, synchronize_session=False)
    db.query(Ticket).filter(Ticket.agent_id == agent_id).update({Ticket.agent_id: None}, synchronize_session=False)
    if conversation_ids:
        db.query(AgentRun).filter(AgentRun.conversation_id.in_(conversation_ids)).update(
            {AgentRun.conversation_id: None},
            synchronize_session=False,
        )
        db.query(Ticket).filter(Ticket.conversation_id.in_(conversation_ids)).update(
            {Ticket.conversation_id: None},
            synchronize_session=False,
        )
        db.query(TableRecord).filter(TableRecord.conversation_id.in_(conversation_ids)).update(
            {TableRecord.conversation_id: None},
            synchronize_session=False,
        )
        db.query(MemoryItem).filter(MemoryItem.conversation_id.in_(conversation_ids)).update(
            {MemoryItem.conversation_id: None},
            synchronize_session=False,
        )
    if tool_ids:
        db.query(ToolInvocation).filter(ToolInvocation.tool_id.in_(tool_ids)).update(
            {ToolInvocation.tool_id: None},
            synchronize_session=False,
        )
    for trigger in db.query(WorkflowTrigger).all():
        if str((trigger.condition_json or {}).get("agent_id")) == str(agent_id):
            db.delete(trigger)
    db.query(SquadMember).filter(SquadMember.agent_id == agent_id).delete(synchronize_session=False)
    if document_ids:
        db.query(KnowledgeBaseChunk).filter(KnowledgeBaseChunk.document_id.in_(document_ids)).delete(synchronize_session=False)
        db.query(KnowledgeBaseDocument).filter(KnowledgeBaseDocument.id.in_(document_ids)).delete(synchronize_session=False)
    if source_ids:
        db.query(KnowledgeBaseSource).filter(KnowledgeBaseSource.id.in_(source_ids)).delete(synchronize_session=False)
    if kb_ids:
        db.query(KnowledgeBase).filter(KnowledgeBase.id.in_(kb_ids)).delete(synchronize_session=False)
    if tool_ids:
        db.query(ToolDefinition).filter(ToolDefinition.id.in_(tool_ids)).delete(synchronize_session=False)

    db.delete(agent)
    db.commit()
    return {"status": "deleted", "agent_id": agent_id}


@app.get("/api/agents/{agent_id}/conditional-prompts")
def list_agent_conditional_prompts(agent_id: int, db: Session = Depends(get_db)) -> list[dict[str, Any]]:
    if not db.get(Agent, agent_id):
        raise HTTPException(status_code=404, detail="Agente não encontrado.")
    items = db.query(WorkflowTrigger).order_by(WorkflowTrigger.id.asc()).all()
    return [
        conditional_prompt_payload(item)
        for item in items
        if (item.condition_json or {}).get("agent_id") in (agent_id, str(agent_id))
        and (item.action_json or {}).get("type", "inject_prompt") == "inject_prompt"
    ]


@app.post("/api/agents/{agent_id}/conditional-prompts")
async def create_agent_conditional_prompt(agent_id: int, request: Request, db: Session = Depends(get_db)) -> dict[str, Any]:
    if not db.get(Agent, agent_id):
        raise HTTPException(status_code=404, detail="Agente não encontrado.")
    body = await request.json()
    name = (body.get("name") or "").strip()
    prompt = (body.get("prompt") or "").strip()
    condition = body.get("condition") or {}
    if not name:
        raise HTTPException(status_code=400, detail="Informe o nome da regra.")
    if not prompt:
        raise HTTPException(status_code=400, detail="Informe o prompt condicional.")
    if not isinstance(condition, dict):
        raise HTTPException(status_code=400, detail="Condição inválida.")
    condition["agent_id"] = agent_id
    item = WorkflowTrigger(
        name=name,
        condition_json=condition,
        action_json={"type": "inject_prompt", "prompt": prompt},
        enabled=bool(body.get("enabled", True)),
    )
    db.add(item)
    db.commit()
    db.refresh(item)
    return {"id": item.id, "status": "created"}


@app.put("/api/conditional-prompts/{trigger_id}")
async def update_conditional_prompt(trigger_id: int, request: Request, db: Session = Depends(get_db)) -> dict[str, Any]:
    item = db.get(WorkflowTrigger, trigger_id)
    if not item:
        raise HTTPException(status_code=404, detail="Regra não encontrada.")
    body = await request.json()
    if "name" in body:
        item.name = (body.get("name") or item.name).strip()
    if "enabled" in body:
        item.enabled = bool(body.get("enabled"))
    if "condition" in body:
        condition = body.get("condition") or {}
        if not isinstance(condition, dict):
            raise HTTPException(status_code=400, detail="Condição inválida.")
        current_agent_id = (item.condition_json or {}).get("agent_id")
        condition["agent_id"] = condition.get("agent_id") or current_agent_id
        item.condition_json = condition
    if "prompt" in body:
        item.action_json = {"type": "inject_prompt", "prompt": (body.get("prompt") or "").strip()}
    db.add(item)
    db.commit()
    return {"status": "saved", "id": item.id}


@app.delete("/api/conditional-prompts/{trigger_id}")
def delete_conditional_prompt(trigger_id: int, db: Session = Depends(get_db)) -> dict[str, Any]:
    item = db.get(WorkflowTrigger, trigger_id)
    if not item:
        raise HTTPException(status_code=404, detail="Regra não encontrada.")
    db.delete(item)
    db.commit()
    return {"status": "deleted"}


@app.post("/api/agents/{agent_id}/connect")
async def connect_agent(agent_id: int, db: Session = Depends(get_db)) -> Any:
    agent = db.get(Agent, agent_id)
    if not agent:
        raise HTTPException(status_code=404, detail="Agent not found.")

    return await connect_evolution_instance(agent.instance_token, agent.id)


@app.post("/api/agents/{agent_id}/disconnect")
@app.delete("/api/agents/{agent_id}/disconnect")
async def disconnect_agent(agent_id: int, db: Session = Depends(get_db)) -> Any:
    agent = db.get(Agent, agent_id)
    if not agent:
        raise HTTPException(status_code=404, detail="Agent not found.")
    return await disconnect_evolution_instance(agent.instance_token, agent.id)


@app.get("/api/agents/{agent_id}/whatsapp/groups")
async def list_agent_whatsapp_groups(agent_id: int, db: Session = Depends(get_db)) -> dict[str, Any]:
    agent = db.get(Agent, agent_id)
    if not agent:
        raise HTTPException(status_code=404, detail="Agente não encontrado.")
    config = _whatsapp_group_config(agent)
    enabled = set(config["enabled_group_ids"])
    groups = await fetch_whatsapp_groups(agent.instance_token)
    for group in groups:
        group["reply_enabled"] = group["id"] in enabled
    return {"groups": groups, "config": config}


@app.put("/api/agents/{agent_id}/whatsapp/groups")
async def update_agent_whatsapp_groups(agent_id: int, request: Request, db: Session = Depends(get_db)) -> dict[str, Any]:
    agent = db.get(Agent, agent_id)
    if not agent:
        raise HTTPException(status_code=404, detail="Agente não encontrado.")
    body = await request.json()
    enabled = body.get("enabled_group_ids") or body.get("enabled_groups") or []
    if not isinstance(enabled, list):
        raise HTTPException(status_code=400, detail="Lista de grupos invalida.")
    enabled_group_ids = [str(item).strip() for item in enabled if str(item).strip()]
    _set_whatsapp_group_config(agent, enabled_group_ids)
    db.add(agent)
    db.commit()
    try:
        await set_evolution_groups_ignore(agent.instance_token, not bool(enabled_group_ids))
    except Exception as exc:
        logger.warning("Não foi possível ajustar groupsIgnore no Evolution para %s: %s", agent.instance_token, exc)
    return {"status": "saved", "enabled_group_ids": enabled_group_ids}


@app.get("/api/agents/{agent_id}/whatsapp/targets")
async def list_agent_whatsapp_targets(
    agent_id: int,
    q: str | None = None,
    refresh: bool = False,
    db: Session = Depends(get_db),
) -> dict[str, Any]:
    agent = db.get(Agent, agent_id)
    if not agent:
        raise HTTPException(status_code=404, detail="Agente não encontrado.")

    if not refresh:
        cached = _cached_whatsapp_targets(agent_id)
        if cached:
            return _filter_whatsapp_targets_payload(cached, q)

    target_timeout = max(0.75, float(os.getenv("WHATSAPP_TARGET_FETCH_TIMEOUT_SECONDS", "2")))
    contact_timeout = max(0.75, float(os.getenv("WHATSAPP_CONTACT_FETCH_TIMEOUT_SECONDS", str(target_timeout))))
    chat_timeout = max(0.75, float(os.getenv("WHATSAPP_CHAT_FETCH_TIMEOUT_SECONDS", str(target_timeout))))
    group_timeout = max(0.2, float(os.getenv("WHATSAPP_GROUP_FETCH_TIMEOUT_SECONDS", "0.7")))
    fetched_contacts, fetched_groups, fetched_chats = await asyncio.gather(
        asyncio.wait_for(fetch_whatsapp_contacts(agent.instance_token), timeout=contact_timeout),
        asyncio.wait_for(fetch_whatsapp_groups(agent.instance_token), timeout=group_timeout),
        asyncio.wait_for(fetch_whatsapp_chats(agent.instance_token), timeout=chat_timeout),
        return_exceptions=True,
    )
    errors: list[str] = []
    contacts = [] if isinstance(fetched_contacts, Exception) else fetched_contacts
    groups = [] if isinstance(fetched_groups, Exception) else fetched_groups
    if isinstance(fetched_contacts, Exception):
        errors.append(f"contacts: {type(fetched_contacts).__name__}")
    if isinstance(fetched_groups, Exception):
        errors.append(f"groups: {type(fetched_groups).__name__}")
    if isinstance(fetched_chats, Exception):
        errors.append(f"chats: {type(fetched_chats).__name__}")
    elif isinstance(fetched_chats, dict):
        contacts = _merge_whatsapp_targets(contacts, fetched_chats.get("contacts") or [], "contact")
        groups = _merge_whatsapp_targets(groups, fetched_chats.get("groups") or [], "group")
    fallback_contacts, fallback_groups = _known_agent_whatsapp_targets(db, agent)
    contacts = _merge_whatsapp_targets(contacts, fallback_contacts, "contact")
    groups = _merge_whatsapp_targets(groups, fallback_groups, "group")
    config = _whatsapp_group_config(agent)
    enabled = set(config["enabled_group_ids"])
    for group in groups:
        group["reply_enabled"] = group["id"] in enabled
    payload = {
        "contacts": contacts,
        "groups": groups,
        "group_config": config,
        "errors": errors,
        "cached": False,
        "loaded_at": datetime.utcnow().isoformat(),
    }
    _WHATSAPP_TARGETS_CACHE[agent_id] = (perf_counter(), _copy_whatsapp_targets_payload(payload))
    return _filter_whatsapp_targets_payload(payload, q)


@app.post("/api/agents/{agent_id}/first-message")
async def send_agent_first_message(agent_id: int, request: Request, db: Session = Depends(get_db)) -> dict[str, Any]:
    agent = db.get(Agent, agent_id)
    if not agent:
        raise HTTPException(status_code=404, detail="Agente não encontrado.")
    body = await request.json()
    message = (body.get("message") or body.get("text") or "").strip()
    if not message:
        raise HTTPException(status_code=400, detail="Informe a mensagem.")

    contacts = body.get("contacts") if isinstance(body.get("contacts"), list) else await fetch_whatsapp_contacts(agent.instance_token)
    groups = body.get("groups") if isinstance(body.get("groups"), list) else await fetch_whatsapp_groups(agent.instance_token)
    targets = select_first_message_targets(
        contacts,
        groups,
        {
            "mode": body.get("mode") or "selected",
            "selected_ids": body.get("selected_ids") or [],
            "excluded_ids": body.get("excluded_ids") or [],
        },
    )
    max_targets = max(1, int(os.getenv("FIRST_MESSAGE_MAX_RECIPIENTS", "200")))
    targets = targets[:max_targets]
    if not targets:
        raise HTTPException(status_code=400, detail="Nenhum destinatario selecionado.")

    sent: list[dict[str, Any]] = []
    failed: list[dict[str, Any]] = []
    for target in targets:
        target_id = target["id"]
        try:
            await send_whatsapp_text_to_instance(agent.instance_token, target_id, message)
            conversation = service_get_or_create_conversation(db, agent.id, target_id)
            state = dict(conversation.state or {})
            state["whatsapp_instance"] = agent.instance_token
            state["chat_kind"] = "group" if is_group_jid(target_id) else "contact"
            conversation.state = state
            row = append_conversation_message(db, conversation, "assistant", message)
            row.message_metadata = {"source": "first_message", "target_kind": target.get("kind")}
            db.add(conversation)
            db.add(row)
            db.commit()
            sent.append(target)
        except Exception as exc:
            db.rollback()
            failed.append({"id": target_id, "name": target.get("name"), "error": str(exc)[:300]})

    return {"status": "sent", "sent_count": len(sent), "failed_count": len(failed), "sent": sent, "failed": failed}


@app.get("/api/diagnostics")
async def diagnostics(db: Session = Depends(get_db)) -> dict[str, Any]:
    try:
        raw_instances = await fetch_evolution_instances()
    except Exception as exc:
        raw_instances = []
        if ".railway.internal" in EVOLUTION_API_URL:
            evolution_error = (
                "EVOLUTION_API_URL usa dominio privado do Railway (*.railway.internal). "
                "Rodando localmente, configure a URL publica da Evolution API."
            )
        else:
            evolution_error = str(exc)
    else:
        evolution_error = None

    conversations = _without_agent_test_conversations(db.query(Conversation)).order_by(Conversation.updated_at.desc()).limit(200).all()
    events = db.query(WebhookEvent).order_by(WebhookEvent.id.desc()).limit(500).all()
    runs = db.query(AgentRun).order_by(AgentRun.id.desc()).limit(500).all()
    agent_names = {agent_id: clean_display_text(name) for agent_id, name in db.query(Agent.id, Agent.name).all()}
    conversation_ids = [c.id for c in conversations]
    open_tickets_by_conversation: dict[int, Ticket] = {}
    if conversation_ids:
        for ticket in (
            db.query(Ticket)
            .filter(Ticket.conversation_id.in_(conversation_ids), Ticket.status.in_(["unanswered", "in_progress"]))
            .order_by(Ticket.updated_at.desc(), Ticket.id.desc())
            .all()
        ):
            if ticket.conversation_id and ticket.conversation_id not in open_tickets_by_conversation:
                open_tickets_by_conversation[ticket.conversation_id] = ticket
    registered_names = _registered_instance_names(db)
    raw_by_name = {
        _evolution_instance_name(item): item
        for item in raw_instances
        if isinstance(item, dict) and _evolution_instance_name(item)
    }
    stale_instances = sorted(name for name in raw_by_name if name not in set(registered_names))

    return {
        "evolution_error": evolution_error,
        "instances": [
            {
                "name": name,
                "connectionStatus": _evolution_instance_status(raw_by_name[name]) if name in raw_by_name else "not_found",
                "disconnectionReasonCode": _evolution_instance_disconnection_reason_code(raw_by_name[name]) if name in raw_by_name else None,
                "disconnectionReason": _evolution_instance_disconnection_text(raw_by_name[name]) if name in raw_by_name else "",
                "needsLogoutReset": _evolution_instance_needs_logout_reset(raw_by_name[name]) if name in raw_by_name else False,
                "messages": _evolution_instance_message_count(raw_by_name[name]) if name in raw_by_name else 0,
                "registered": True,
            }
            for name in registered_names
        ],
        "stale_instances_hidden": len(stale_instances),
        "stale_instance_names": stale_instances[:20],
        "conversations": [
            {
                "id": c.id,
                "agent_id": c.agent_id,
                "agent_name": agent_names.get(c.agent_id),
                "customer_phone": c.customer_phone,
                "customer_name": (c.state or {}).get("customer_name") if isinstance(c.state, dict) else None,
                "source": (c.state or {}).get("source") if isinstance(c.state, dict) else None,
                "channel": conversation_channel(c),
                "shared_chat_link_id": c.shared_chat_link_id,
                "messages": len(c.history or []),
                "preview": "\n".join(
                    f"{m.get('role')}: {clean_display_text(m.get('content'))}" for m in (c.history or [])[-6:]
                ),
                "state": c.state or {},
                "ticket": ticket_payload(open_tickets_by_conversation[c.id], db) if c.id in open_tickets_by_conversation else None,
                "created_at": c.created_at.isoformat(),
                "updated_at": c.updated_at.isoformat(),
            }
            for c in conversations
        ],
        "events": [
            {
                "id": e.id,
                "agent_id": e.agent_id,
                "instance": e.instance,
                "event": e.event,
                "customer_phone": e.customer_phone,
                "message_text": clean_display_text(e.message_text),
                "audio_transcription": (e.payload or {}).get("_audio_transcription") if isinstance(e.payload, dict) else None,
                "status": e.status,
                "error": e.error,
                "created_at": e.created_at.isoformat(),
            }
            for e in events
        ],
        "runs": [
            {
                "id": r.id,
                "agent_id": r.agent_id,
                "conversation_id": r.conversation_id,
                "status": r.status,
                "latency_ms": r.latency_ms,
                "error": r.error,
                "input": r.input or {},
                "output": r.output or {},
                "created_at": r.created_at.isoformat(),
            }
            for r in runs
        ],
    }


@app.get("/api/channels")
def list_channels(db: Session = Depends(get_db)) -> list[dict[str, Any]]:
    channels = db.query(Channel).order_by(Channel.id.desc()).all()
    return [
        {
            "id": c.id,
            "name": clean_display_text(c.name),
            "description": clean_display_text(c.description),
            "instance_name": c.instance_name,
            "agent_id": c.agent_id,
            "agent_name": clean_display_text(c.agent.name) if c.agent else None,
            "custom_prompt": clean_display_text(c.custom_prompt),
            "reply_delay": c.reply_delay,
            "webhook": f"{WEBHOOK_BASE_URL}/webhook/channel/{c.id}",
            "created_at": c.created_at.isoformat(),
        }
        for c in channels
    ]


@app.post("/api/channels")
async def create_channel(request: Request, db: Session = Depends(get_db)) -> dict[str, Any]:
    body = await request.json()
    instance_name = slugify_instance(body.get("instance_name") or body.get("name") or "")

    if not instance_name:
        raise HTTPException(status_code=400, detail="Informe o nome da instância.")

    channel = Channel(
        name=body["name"].strip(),
        description=(body.get("description") or "").strip() or None,
        instance_name=instance_name,
        agent_id=body.get("agent_id") or None,
        custom_prompt=(body.get("custom_prompt") or "").strip() or None,
        reply_delay=int(body.get("reply_delay", 0)),
    )
    db.add(channel)
    db.commit()
    db.refresh(channel)

    try:
        evolution = await create_evolution_instance(f"channel:{channel.id}", instance_name)
    except Exception:
        db.delete(channel)
        db.commit()
        raise

    return {"channel_id": channel.id, "instance": instance_name, "evolution": evolution}


@app.put("/api/channels/{channel_id}")
async def update_channel(channel_id: int, request: Request, db: Session = Depends(get_db)) -> dict[str, Any]:
    channel = db.get(Channel, channel_id)
    if not channel:
        raise HTTPException(status_code=404, detail="Channel not found.")

    body = await request.json()
    if body.get("name"):
        channel.name = body["name"].strip()
    if "description" in body:
        channel.description = (body["description"] or "").strip() or None
    if "agent_id" in body:
        channel.agent_id = body["agent_id"] or None
    if "custom_prompt" in body:
        channel.custom_prompt = (body["custom_prompt"] or "").strip() or None
    if "reply_delay" in body:
        channel.reply_delay = int(body["reply_delay"])

    db.add(channel)
    db.commit()
    return {"status": "saved", "channel_id": channel.id}


@app.delete("/api/channels/{channel_id}")
def delete_channel(channel_id: int, db: Session = Depends(get_db)) -> dict[str, Any]:
    channel = db.get(Channel, channel_id)
    if not channel:
        raise HTTPException(status_code=404, detail="Channel not found.")
    db.delete(channel)
    db.commit()
    return {"status": "deleted"}


@app.post("/api/channels/{channel_id}/connect")
async def connect_channel(channel_id: int, db: Session = Depends(get_db)) -> Any:
    channel = db.get(Channel, channel_id)
    if not channel:
        raise HTTPException(status_code=404, detail="Channel not found.")
    return await connect_evolution_instance(channel.instance_name, f"channel:{channel.id}")


@app.post("/api/channels/{channel_id}/disconnect")
@app.delete("/api/channels/{channel_id}/disconnect")
async def disconnect_channel(channel_id: int, db: Session = Depends(get_db)) -> Any:
    channel = db.get(Channel, channel_id)
    if not channel:
        raise HTTPException(status_code=404, detail="Channel not found.")
    return await disconnect_evolution_instance(channel.instance_name, f"channel:{channel.id}")


@app.get("/api/knowledge-bases")
def list_knowledge_bases(agent_id: int | None = None, db: Session = Depends(get_db)) -> list[dict[str, Any]]:
    query = db.query(KnowledgeBase)
    if agent_id is not None:
        if not db.get(Agent, agent_id):
            raise HTTPException(status_code=404, detail="Agente não encontrado.")
        query = query.filter(KnowledgeBase.agent_id == agent_id)
    items = query.order_by(KnowledgeBase.id.desc()).all()
    return [
        {
            "id": item.id,
            "agent_id": item.agent_id,
            "name": clean_display_text(item.name),
            "description": clean_display_text(item.description),
            "tenant_id": item.tenant_id,
            "created_at": item.created_at.isoformat(),
        }
        for item in items
    ]


@app.post("/api/knowledge-bases")
async def create_knowledge_base(request: Request, db: Session = Depends(get_db)) -> dict[str, Any]:
    body = await request.json()
    name = (body.get("name") or "").strip()
    agent_id = body.get("agent_id")
    if not name:
        raise HTTPException(status_code=400, detail="Informe o nome da base.")
    if agent_id and not db.get(Agent, int(agent_id)):
        raise HTTPException(status_code=400, detail="Selecione um agente valido para esta base.")
    item = KnowledgeBase(
        agent_id=int(agent_id) if agent_id else None,
        name=name,
        description=(body.get("description") or "").strip() or None,
        tenant_id=(body.get("tenant_id") or "").strip() or None,
    )
    db.add(item)
    db.commit()
    db.refresh(item)
    return {"id": item.id, "status": "created"}


@app.put("/api/knowledge-bases/{kb_id}")
async def update_knowledge_base(kb_id: int, request: Request, db: Session = Depends(get_db)) -> dict[str, Any]:
    item = db.get(KnowledgeBase, kb_id)
    if not item:
        raise HTTPException(status_code=404, detail="Base não encontrada.")
    body = await request.json()
    if body.get("name"):
        item.name = body["name"].strip()
    if "description" in body:
        item.description = (body.get("description") or "").strip() or None
    if "tenant_id" in body:
        item.tenant_id = (body.get("tenant_id") or "").strip() or None
    if "agent_id" in body:
        agent_id = body.get("agent_id")
        if agent_id and not db.get(Agent, int(agent_id)):
            raise HTTPException(status_code=400, detail="Agente invalido.")
        item.agent_id = int(agent_id) if agent_id else None
    db.add(item)
    db.commit()
    db.refresh(item)
    return {"status": "saved", "id": item.id}


@app.delete("/api/knowledge-bases/{kb_id}")
def delete_knowledge_base(kb_id: int, db: Session = Depends(get_db)) -> dict[str, Any]:
    item = db.get(KnowledgeBase, kb_id)
    if not item:
        raise HTTPException(status_code=404, detail="Base não encontrada.")
    source_ids = [row.id for row in db.query(KnowledgeBaseSource.id).filter(KnowledgeBaseSource.kb_id == kb_id).all()]
    document_ids = [row.id for row in db.query(KnowledgeBaseDocument.id).filter(KnowledgeBaseDocument.kb_id == kb_id).all()]
    if document_ids:
        db.query(KnowledgeBaseChunk).filter(KnowledgeBaseChunk.document_id.in_(document_ids)).delete(synchronize_session=False)
        db.query(KnowledgeBaseDocument).filter(KnowledgeBaseDocument.id.in_(document_ids)).delete(synchronize_session=False)
    if source_ids:
        db.query(KnowledgeBaseSource).filter(KnowledgeBaseSource.id.in_(source_ids)).delete(synchronize_session=False)
    db.delete(item)
    db.commit()
    return {"status": "deleted", "kb_id": kb_id}


def _ecosystem_status_label(done: bool, warning: bool = False) -> str:
    if warning:
        return "attention"
    return "complete" if done else "incomplete"


@app.get("/api/ecosystem-status")
def ecosystem_status(db: Session = Depends(get_db)) -> dict[str, Any]:
    agents = db.query(Agent).all()
    channels = db.query(Channel).all()
    squads = db.query(Squad).all()
    knowledge_bases = db.query(KnowledgeBase).all()
    tables = db.query(DataTable).all()
    tools = db.query(ToolDefinition).all()
    teams = db.query(CRMTeam).all()
    attendants = db.query(CRMAttendant).all()
    tickets_total = db.query(func.count(Ticket.id)).scalar() or 0
    tickets_open = (
        db.query(func.count(Ticket.id))
        .filter(Ticket.status.in_(["unanswered", "in_progress"]))
        .scalar()
        or 0
    )
    table_records = db.query(func.count(TableRecord.id)).scalar() or 0
    kb_chunk_rows = (
        db.query(KnowledgeBaseDocument.kb_id, func.count(KnowledgeBaseChunk.id))
        .join(KnowledgeBaseChunk, KnowledgeBaseChunk.document_id == KnowledgeBaseDocument.id)
        .group_by(KnowledgeBaseDocument.kb_id)
        .all()
    )
    kb_chunks_by_id = {int(kb_id): int(count or 0) for kb_id, count in kb_chunk_rows}
    agent_kb_ids = {kb.agent_id: kb.id for kb in knowledge_bases if kb.agent_id}
    linked_channel_agent_ids = {channel.agent_id for channel in channels if channel.agent_id}

    setup_progress = {
        "tables": bool(tables),
        "knowledge_bases": bool(knowledge_bases),
        "agents": bool(agents),
        "whatsapp": bool(linked_channel_agent_ids or channels),
        "squads": bool(squads),
        "channels": bool(channels),
        "tools": bool([tool for tool in tools if tool.enabled]),
        "teams": bool(teams and attendants),
        "tested": bool(db.query(func.count(AgentRun.id)).filter(AgentRun.is_test.is_(True)).scalar() or 0),
        "published": bool([agent for agent in agents if agent.is_published]),
    }
    recommendations: list[dict[str, Any]] = []
    if not setup_progress["tables"]:
        recommendations.append({"type": "info", "module": "tables", "message": "Crie uma tabela para dados operacionais que os agentes possam consultar.", "action": "tables"})
    if not setup_progress["knowledge_bases"]:
        recommendations.append({"type": "warning", "module": "kb", "message": "Adicione uma base de conhecimento antes de publicar agentes.", "action": "kb"})
    if not setup_progress["agents"]:
        recommendations.append({"type": "warning", "module": "agents", "message": "Crie o primeiro agente para iniciar o ecossistema.", "action": "agents"})
    for agent in agents:
        kb_id = agent_kb_ids.get(agent.id)
        if not kb_id or not kb_chunks_by_id.get(kb_id):
            recommendations.append({
                "type": "warning",
                "module": "agents",
                "message": f"Agente '{clean_display_text(agent.name)}' ainda não tem conhecimento indexado.",
                "action": "kb",
                "agent_id": agent.id,
            })
            break
    if channels and any(not channel.agent_id for channel in channels):
        recommendations.append({"type": "warning", "module": "channels", "message": "Ha canais sem agente responsável.", "action": "channels"})
    if agents and not any(agent.is_published for agent in agents):
        recommendations.append({"type": "info", "module": "agents", "message": "Teste e publique ao menos um agente para entrar em produção.", "action": "agents"})
    if setup_progress["agents"] and setup_progress["knowledge_bases"] and setup_progress["channels"]:
        recommendations.append({"type": "success", "module": "ecosystem", "message": "Fundação principal configurada. Revise teste, times e publicação.", "action": "ecosystem"})

    modules = [
        {"id": "tables", "label": "Tabelas", "count": len(tables), "status": _ecosystem_status_label(bool(tables), bool(tables) and table_records == 0), "hint": f"{table_records} registros"},
        {"id": "kb", "label": "Conhecimento", "count": len(knowledge_bases), "status": _ecosystem_status_label(bool(knowledge_bases), bool(knowledge_bases) and not any(kb_chunks_by_id.values())), "hint": f"{sum(kb_chunks_by_id.values())} chunks"},
        {"id": "agents", "label": "Agentes", "count": len(agents), "status": _ecosystem_status_label(bool(agents), bool(agents) and not any(agent.is_published for agent in agents)), "hint": f"{sum(1 for agent in agents if agent.is_published)} publicados"},
        {"id": "squads", "label": "Squads", "count": len(squads), "status": _ecosystem_status_label(bool(squads)), "hint": f"{sum(1 for squad in squads if squad.manager_agent_id)} com gerente"},
        {"id": "tools", "label": "Ferramentas", "count": len(tools), "status": _ecosystem_status_label(bool(tools)), "hint": f"{sum(1 for tool in tools if tool.enabled)} ativas"},
        {"id": "channels", "label": "Canais", "count": len(channels), "status": _ecosystem_status_label(bool(channels), bool(channels) and any(not channel.agent_id for channel in channels)), "hint": f"{sum(1 for channel in channels if channel.agent_id)} vinculados"},
        {"id": "team", "label": "Atendimento", "count": len(attendants), "status": _ecosystem_status_label(bool(teams and attendants)), "hint": f"{len(teams)} times"},
        {"id": "tickets", "label": "Tickets", "count": int(tickets_total), "status": "complete" if not tickets_open else "attention", "hint": f"{int(tickets_open)} abertos"},
    ]

    return {
        "agents": {
            "total": len(agents),
            "published": sum(1 for agent in agents if agent.is_published),
            "with_kb": sum(1 for agent in agents if agent_kb_ids.get(agent.id)),
            "with_channels": len(linked_channel_agent_ids),
        },
        "squads": {"total": len(squads), "with_manager": sum(1 for squad in squads if squad.manager_agent_id), "with_fallback": sum(1 for squad in squads if squad.fallback_agent_id)},
        "channels": {"total": len(channels), "assigned": sum(1 for channel in channels if channel.agent_id)},
        "knowledge_bases": {"total": len(knowledge_bases), "with_chunks": sum(1 for kb in knowledge_bases if kb_chunks_by_id.get(kb.id)), "chunks": sum(kb_chunks_by_id.values())},
        "tables": {"total": len(tables), "records": int(table_records)},
        "tools": {"total": len(tools), "enabled": sum(1 for tool in tools if tool.enabled)},
        "teams": {"total": len(teams), "with_attendants": len({attendant.team_id for attendant in attendants if attendant.team_id})},
        "attendants": {"total": len(attendants), "available": sum(1 for attendant in attendants if attendant.available)},
        "tickets": {"open": int(tickets_open), "total": int(tickets_total)},
        "modules": modules,
        "recommendations": recommendations[:6],
        "setup_progress": setup_progress,
    }


def _sanitize_guide_context_value(value: Any, depth: int = 0) -> Any:
    if depth > 4:
        return sanitize_user_input(str(value), max_chars=220)
    if isinstance(value, bool) or value is None:
        return value
    if isinstance(value, (int, float)):
        return value
    if isinstance(value, str):
        return sanitize_user_input(value, max_chars=700)
    if isinstance(value, list):
        return [_sanitize_guide_context_value(item, depth + 1) for item in value[:24]]
    if isinstance(value, dict):
        clean: dict[str, Any] = {}
        for raw_key, item in list(value.items())[:60]:
            key = sanitize_user_input(str(raw_key), max_chars=80)
            if not key:
                continue
            if re.search(r"(api.?key|token|secret|senha|password|authorization|session)", key, re.IGNORECASE):
                clean[key] = "[protegido]"
                continue
            clean[key] = _sanitize_guide_context_value(item, depth + 1)
        return clean
    return sanitize_user_input(str(value), max_chars=500)


@app.post("/api/system-assistants/guide/chat")
async def system_guide_chat(request: Request, db: Session = Depends(get_db)) -> dict[str, Any]:
    try:
        body = await request.json()
    except Exception:
        raise HTTPException(status_code=400, detail="Envie um JSON válido.")
    if not isinstance(body, dict):
        raise HTTPException(status_code=400, detail="Envie um JSON válido.")
    message = sanitize_user_input(str(body.get("message") or "").strip())
    if not message:
        raise HTTPException(status_code=400, detail="Informe a mensagem.")
    history = body.get("history") if isinstance(body.get("history"), list) else []
    clean_history = [
        {"role": item.get("role"), "content": sanitize_user_input(str(item.get("content") or ""))}
        for item in history[-8:]
        if isinstance(item, dict) and item.get("role") in {"user", "assistant"} and item.get("content")
    ]
    raw_context = body.get("context") if isinstance(body.get("context"), dict) else {}
    screen_context = _sanitize_guide_context_value(raw_context) if raw_context else {}
    return await guide_chat(db, message, clean_history, screen_context)


def _short_history_text(value: Any, limit: int = 240) -> str:
    text = clean_display_text(value) or ""
    text = re.sub(r"\s+", " ", text).strip()
    return text if len(text) <= limit else text[: limit - 1].rstrip() + "..."


def _tester_history_messages(output: dict[str, Any]) -> list[dict[str, Any]]:
    messages: list[dict[str, Any]] = []
    for index, item in enumerate(output.get("transcripts") or [], start=1):
        if not isinstance(item, dict):
            continue
        scenario = item.get("scenario") if isinstance(item.get("scenario"), dict) else {}
        scenario_name = clean_display_text(scenario.get("name") or f"Cenario {index}")
        user_message = clean_display_text(scenario.get("message"))
        if user_message:
            messages.append(
                {
                    "role": "user",
                    "content": user_message,
                    "scenario": scenario_name,
                    "run_id": item.get("run_id"),
                    "conversation_id": item.get("conversation_id"),
                    "latency_ms": item.get("latency_ms"),
                }
            )
        reply = clean_display_text(item.get("reply"))
        if reply:
            messages.append(
                {
                    "role": "assistant",
                    "content": reply,
                    "scenario": scenario_name,
                    "run_id": item.get("run_id"),
                    "conversation_id": item.get("conversation_id"),
                    "latency_ms": item.get("latency_ms"),
                }
            )
        error = clean_display_text(item.get("error"))
        if error:
            messages.append(
                {
                    "role": "system",
                    "content": error,
                    "scenario": scenario_name,
                    "run_id": item.get("run_id"),
                    "conversation_id": item.get("conversation_id"),
                    "latency_ms": item.get("latency_ms"),
                }
            )
    return messages


def _system_tester_history_payload(run: SystemAssistantRun, agent_names: dict[int, str], include_detail: bool = False) -> dict[str, Any]:
    input_data = run.input or {}
    output = run.output or {}
    transcripts = output.get("transcripts") if isinstance(output.get("transcripts"), list) else []
    first_transcript = next((item for item in transcripts if isinstance(item, dict)), {})
    first_scenario = first_transcript.get("scenario") if isinstance(first_transcript.get("scenario"), dict) else {}
    failed_count = sum(1 for item in transcripts if isinstance(item, dict) and item.get("status") in {"failed", "error"})
    payload: dict[str, Any] = {
        "id": run.id,
        "run_id": run.id,
        "agent_id": run.agent_id,
        "agent_name": agent_names.get(run.agent_id or 0) or f"Agente {run.agent_id or '-'}",
        "status": run.status,
        "mode": input_data.get("mode") or output.get("mode") or "quick",
        "objective": _short_history_text(input_data.get("objective") or output.get("objective"), 180),
        "score": output.get("score"),
        "score_0_10": output.get("score_0_10"),
        "quality_label": output.get("quality_label") or "Sem avaliacao",
        "summary": _short_history_text(output.get("summary") or run.error or "Teste registrado.", 260),
        "first_message": _short_history_text(first_scenario.get("message") or "", 180),
        "result": _short_history_text(output.get("quality_label") or output.get("summary") or run.status, 180),
        "scenarios_count": len(transcripts) or len(input_data.get("scenarios") or []),
        "errors_count": failed_count + (1 if run.error else 0),
        "latency_ms": run.latency_ms,
        "created_at": run.created_at.isoformat(),
        "updated_at": run.updated_at.isoformat(),
    }
    if include_detail:
        payload.update(
            {
                "input": input_data,
                "output": output,
                "error": clean_display_text(run.error),
                "messages": _tester_history_messages(output),
                "metadata": {
                    "assistant_kind": run.assistant_kind,
                    "analysis_mode": output.get("analysis_mode"),
                    "model": output.get("model"),
                    "scenario_count": len(input_data.get("scenarios") or []),
                    "transcript_count": len(transcripts),
                },
            }
        )
    return payload


@app.get("/api/system-assistants/tester/history")
def system_tester_history(agent_id: int | None = None, limit: int = 20, offset: int = 0, db: Session = Depends(get_db)) -> dict[str, Any]:
    limit = max(1, min(int(limit or 20), 50))
    offset = max(0, int(offset or 0))
    if agent_id and not db.get(Agent, agent_id):
        raise HTTPException(status_code=404, detail="Agente não encontrado.")
    query = db.query(SystemAssistantRun).filter(SystemAssistantRun.assistant_kind == "tester")
    if agent_id:
        query = query.filter(SystemAssistantRun.agent_id == agent_id)
    total = query.count()
    rows = query.order_by(SystemAssistantRun.id.desc()).offset(offset).limit(limit).all()
    agent_names = {id_: clean_display_text(name) for id_, name in db.query(Agent.id, Agent.name).all()}
    return {
        "items": [_system_tester_history_payload(row, agent_names) for row in rows],
        "total": total,
        "limit": limit,
        "offset": offset,
    }


@app.get("/api/system-assistants/tester/history/{run_id}")
def system_tester_history_detail(run_id: int, db: Session = Depends(get_db)) -> dict[str, Any]:
    run = db.get(SystemAssistantRun, run_id)
    if not run or run.assistant_kind != "tester":
        raise HTTPException(status_code=404, detail="Teste não encontrado.")
    agent_names = {id_: clean_display_text(name) for id_, name in db.query(Agent.id, Agent.name).all()}
    return _system_tester_history_payload(run, agent_names, include_detail=True)


@app.post("/api/system-assistants/tester/run")
async def system_tester_run(request: Request, db: Session = Depends(get_db)) -> dict[str, Any]:
    try:
        body = await request.json()
    except Exception:
        raise HTTPException(status_code=400, detail="Envie um JSON válido.")
    if not isinstance(body, dict):
        raise HTTPException(status_code=400, detail="Envie um JSON válido.")
    agent_id = int(body.get("agent_id") or 0)
    agent = db.get(Agent, agent_id)
    if not agent:
        raise HTTPException(status_code=404, detail="Agente não encontrado.")
    mode = str(body.get("mode") or "quick").strip().lower()
    objective = sanitize_user_input(str(body.get("objective") or "").strip()) or None
    return await run_agent_test_suite(db, agent, mode=mode, objective=objective)


@app.post("/api/system-assistants/optimizer/preview")
async def system_optimizer_preview(request: Request, db: Session = Depends(get_db)) -> dict[str, Any]:
    try:
        body = await request.json()
    except Exception:
        raise HTTPException(status_code=400, detail="Envie um JSON válido.")
    if not isinstance(body, dict):
        raise HTTPException(status_code=400, detail="Envie um JSON válido.")
    agent_id = int(body.get("agent_id") or 0)
    agent = db.get(Agent, agent_id)
    if not agent:
        raise HTTPException(status_code=404, detail="Agente não encontrado.")
    tester_report = body.get("tester_report") if body.get("tester_report") is not None else body.get("report")
    customization_goal = body.get("customization_goal") or body.get("improvement_goal") or body.get("objective")
    return await optimizer_preview(db, agent, tester_report, customization_goal)


@app.post("/api/system-assistants/optimizer/apply")
async def system_optimizer_apply(request: Request, db: Session = Depends(get_db)) -> dict[str, Any]:
    try:
        body = await request.json()
    except Exception:
        raise HTTPException(status_code=400, detail="Envie um JSON válido.")
    if not isinstance(body, dict):
        raise HTTPException(status_code=400, detail="Envie um JSON válido.")
    agent_id = int(body.get("agent_id") or 0)
    agent = db.get(Agent, agent_id)
    if not agent:
        raise HTTPException(status_code=404, detail="Agente não encontrado.")
    patch = body.get("patch")
    if not isinstance(patch, dict):
        raise HTTPException(status_code=400, detail="Envie um patch válido.")
    sections = body.get("sections")
    if sections is not None and not isinstance(sections, list):
        raise HTTPException(status_code=400, detail="Envie sections como lista.")
    return apply_optimizer_patch(db, agent, patch, sections)


@app.get("/api/agents/{agent_id}/versions")
def get_agent_versions(agent_id: int, db: Session = Depends(get_db)) -> list[dict[str, Any]]:
    agent = db.get(Agent, agent_id)
    if not agent:
        raise HTTPException(status_code=404, detail="Agente não encontrado.")
    return list_agent_versions(db, agent, limit=30)


@app.post("/api/agents/{agent_id}/versions/{version_id}/restore")
def restore_agent_version_endpoint(agent_id: int, version_id: int, db: Session = Depends(get_db)) -> dict[str, Any]:
    agent = db.get(Agent, agent_id)
    if not agent:
        raise HTTPException(status_code=404, detail="Agente não encontrado.")
    version = db.get(AgentVersion, version_id)
    if not version or version.agent_id != agent.id:
        raise HTTPException(status_code=404, detail="Versão não encontrada.")
    try:
        return restore_agent_version(db, agent, version)
    except ValueError as exc:
        raise HTTPException(status_code=400, detail=str(exc))


@app.get("/api/data-tables")
def list_data_tables(db: Session = Depends(get_db)) -> list[dict[str, Any]]:
    items = db.query(DataTable).order_by(DataTable.id.desc()).all()
    record_counts = {
        table_id: count for table_id, count in db.query(TableRecord.table_id, func.count()).group_by(TableRecord.table_id).all()
    }
    return [
        {
            "id": item.id,
            "name": clean_display_text(item.name),
            "tenant_id": item.tenant_id,
            "schema_json": item.schema_json,
            "description": clean_display_text((item.schema_json or {}).get("description") or ""),
            "record_count": int(record_counts.get(item.id, 0) or 0),
            "created_at": item.created_at.isoformat(),
        }
        for item in items
    ]


def decode_import_file(import_file: dict[str, Any]) -> tuple[str, str, bytes]:
    name = str(import_file.get("name") or import_file.get("uri") or "arquivo").strip() or "arquivo"
    mime_type = str(import_file.get("mime_type") or import_file.get("type") or "").strip()
    content = import_file.get("content") or ""
    if not isinstance(content, str) or not content:
        return name, mime_type, b""
    if content.startswith("data:"):
        header, payload = content.split(",", 1)
        mime_type = mime_type or header.split(";", 1)[0].removeprefix("data:")
        return name, mime_type, base64.b64decode(payload)
    return name, mime_type, content.encode("utf-8")


def parse_csv_records(raw: bytes) -> list[dict[str, Any]]:
    text_value = raw.decode("utf-8-sig", errors="replace")
    if not text_value.strip():
        return []
    try:
        dialect = csv.Sniffer().sniff(text_value[:4096], delimiters=",;\t|")
    except csv.Error:
        dialect = csv.excel
    rows = list(csv.reader(io.StringIO(text_value), dialect=dialect))
    if not rows:
        lines = [line.strip() for line in text_value.splitlines() if line.strip()]
        return [{"texto": line} for line in lines]
    header_index = next((index for index, row in enumerate(rows) if any(str(value).strip() for value in row)), -1)
    if header_index < 0:
        return []
    return records_from_table_rows(rows[header_index], rows[header_index + 1 :])


def unique_table_headers(raw_headers: list[Any], width: int) -> list[str]:
    headers: list[str] = []
    seen: dict[str, int] = {}
    for index in range(width):
        raw = raw_headers[index] if index < len(raw_headers) else ""
        base = clean_display_text(str(raw or "").strip()) or f"coluna_{index + 1}"
        key = base.lower()
        seen[key] = seen.get(key, 0) + 1
        headers.append(base if seen[key] == 1 else f"{base}_{seen[key]}")
    return headers


def records_from_table_rows(header_row: list[Any], data_rows: list[list[Any]]) -> list[dict[str, Any]]:
    width = max([len(header_row), *[len(row) for row in data_rows]] or [0])
    headers = unique_table_headers(header_row, width)
    records: list[dict[str, Any]] = []
    for row in data_rows:
        record = {headers[index]: row[index] if index < len(row) else "" for index in range(width)}
        if any(str(value or "").strip() for value in record.values()):
            records.append(record)
    return records


def xlsx_column_index(reference: str) -> int:
    letters = re.sub(r"[^A-Z]", "", reference.upper())
    value = 0
    for letter in letters:
        value = value * 26 + (ord(letter) - ord("A") + 1)
    return max(value - 1, 0)


def xlsx_text(node: ET.Element, namespace: dict[str, str]) -> str:
    return "".join(text_node.text or "" for text_node in node.findall(".//s:t", namespace))


def parse_xlsx_records(raw: bytes) -> list[dict[str, Any]]:
    namespace = {"s": "http://schemas.openxmlformats.org/spreadsheetml/2006/main"}
    with zipfile.ZipFile(io.BytesIO(raw)) as workbook:
        shared_strings: list[str] = []
        if "xl/sharedStrings.xml" in workbook.namelist():
            shared_root = ET.fromstring(workbook.read("xl/sharedStrings.xml"))
            shared_strings = [xlsx_text(item, namespace) for item in shared_root.findall(".//s:si", namespace)]

        sheet_name = "xl/worksheets/sheet1.xml"
        if sheet_name not in workbook.namelist():
            sheet_name = next((name for name in workbook.namelist() if name.startswith("xl/worksheets/") and name.endswith(".xml")), "")
        if not sheet_name:
            return []

        sheet_root = ET.fromstring(workbook.read(sheet_name))
        table_rows: list[list[str]] = []
        for row in sheet_root.findall(".//s:sheetData/s:row", namespace):
            values: dict[int, str] = {}
            for cell in row.findall("s:c", namespace):
                reference = cell.attrib.get("r", "")
                index = xlsx_column_index(reference)
                value_node = cell.find("s:v", namespace)
                cell_type = cell.attrib.get("t")
                if cell_type == "s" and value_node is not None:
                    try:
                        values[index] = shared_strings[int(value_node.text or "0")]
                    except (ValueError, IndexError):
                        values[index] = ""
                elif cell_type == "inlineStr":
                    values[index] = xlsx_text(cell, namespace)
                else:
                    values[index] = value_node.text if value_node is not None and value_node.text is not None else ""
            if values:
                table_rows.append([values.get(index, "") for index in range(max(values) + 1)])

    first_index = next((index for index, row in enumerate(table_rows) if any(str(value).strip() for value in row)), -1)
    if first_index < 0:
        return []
    return records_from_table_rows(table_rows[first_index], table_rows[first_index + 1 :])


def parse_table_import(import_file: dict[str, Any] | None) -> list[dict[str, Any]]:
    if not import_file:
        return []
    name, mime_type, raw = decode_import_file(import_file)
    if not raw:
        return []
    lowered = name.lower()
    if lowered.endswith((".xlsx", ".xlsm")) or "spreadsheetml.sheet" in mime_type:
        return parse_xlsx_records(raw)
    if lowered.endswith(".xls"):
        raise HTTPException(status_code=400, detail="Envie a planilhá em .xlsx ou CSV.")
    return parse_csv_records(raw)


def infer_table_schema(records: list[dict[str, Any]]) -> dict[str, Any]:
    fields: list[dict[str, str]] = []
    keys: list[str] = []
    for record in records:
        for key in record.keys():
            if key not in keys:
                keys.append(key)
    for key in keys:
        values = [str(record.get(key) or "").strip() for record in records if str(record.get(key) or "").strip()]
        if values and all(value.lower() in {"true", "false", "sim", "nao", "não", "yes", "no"} for value in values):
            field_type = "boolean"
        elif values and all(re.fullmatch(r"-?\d+([.,]\d+)?", value) for value in values):
            field_type = "number"
        else:
            field_type = "string"
        fields.append({"name": key, "type": field_type})
    return {"fields": fields}


@app.post("/api/data-tables")
async def create_data_table(request: Request, db: Session = Depends(get_db)) -> dict[str, Any]:
    body = await request.json()
    name = (body.get("name") or "").strip()
    if not name:
        raise HTTPException(status_code=400, detail="Informe o nome da tabela.")
    records = body.get("records") if isinstance(body.get("records"), list) else []
    try:
        imported_records = parse_table_import(body.get("import_file"))
    except HTTPException:
        raise
    except Exception as exc:
        raise HTTPException(status_code=400, detail=f"Não foi possível ler o arquivo da tabela: {exc}") from exc
    if imported_records:
        records = imported_records
    max_records = int(os.getenv("TABLE_IMPORT_MAX_RECORDS", "5000"))
    records = [record for record in records if isinstance(record, dict)][:max_records]
    schema_json = body.get("schema_json") or {}
    if records and not (schema_json.get("fields") if isinstance(schema_json, dict) else None):
        schema_json = infer_table_schema(records)
    if isinstance(schema_json, dict) and isinstance(schema_json.get("fields"), list):
        schema_json = {
            **schema_json,
            "fields": [
                {**field, "type": "text"} if isinstance(field, dict) else field
                for field in schema_json.get("fields", [])
            ],
        }
    description = clean_display_text(body.get("description") or "").strip()
    if isinstance(schema_json, dict) and description:
        schema_json = {**schema_json, "description": description}
    item = DataTable(
        name=name,
        tenant_id=(body.get("tenant_id") or "").strip() or None,
        schema_json=schema_json,
    )
    db.add(item)
    db.flush()
    for record in records:
        db.add(TableRecord(table_id=item.id, data=record))
    db.commit()
    db.refresh(item)
    return {"id": item.id, "status": "created", "records_imported": len(records)}


def _table_payload(item: DataTable, db: Session, include_records: bool = False) -> dict[str, Any]:
    record_count = db.query(TableRecord).filter(TableRecord.table_id == item.id).count()
    payload = {
        "id": item.id,
        "name": clean_display_text(item.name),
        "tenant_id": item.tenant_id,
        "schema_json": item.schema_json or {},
        "description": clean_display_text((item.schema_json or {}).get("description") or ""),
        "record_count": int(record_count or 0),
        "created_at": item.created_at.isoformat(),
    }
    if include_records:
        payload["records"] = [
            {
                "id": record.id,
                "data": record.data or {},
                "created_by_agent_id": record.created_by_agent_id,
                "conversation_id": record.conversation_id,
                "created_at": record.created_at.isoformat(),
                "updated_at": record.updated_at.isoformat(),
            }
            for record in db.query(TableRecord).filter(TableRecord.table_id == item.id).order_by(TableRecord.id.asc()).limit(1000).all()
        ]
    return payload


def _normalize_table_field(raw: dict[str, Any]) -> dict[str, Any]:
    name = clean_display_text(raw.get("name") or raw.get("id") or "").strip()
    if not name:
        raise HTTPException(status_code=400, detail="Informe o nome da coluna.")
    field_type = str(raw.get("type") or "text").strip().lower()
    if field_type not in {"string", "number", "boolean", "date", "datetime", "email", "phone", "url", "text"}:
        field_type = "text"
    return {
        "name": name,
        "type": field_type,
        "description": clean_display_text(raw.get("description") or ""),
        "semantic_search": bool(raw.get("semantic_search", False)),
        "agent_visibility": clean_display_text(raw.get("agent_visibility") or raw.get("agent_instructions") or ""),
    }


def _table_schema_with_field(schema_json: dict[str, Any] | None, field: dict[str, Any]) -> dict[str, Any]:
    schema = dict(schema_json or {})
    fields = [item for item in schema.get("fields", []) if isinstance(item, dict)]
    fields = [item for item in fields if str(item.get("name") or "").lower() != field["name"].lower()]
    fields.append(field)
    schema["fields"] = fields
    return schema


@app.get("/api/data-tables/{table_id}")
def get_data_table(table_id: int, db: Session = Depends(get_db)) -> dict[str, Any]:
    item = db.get(DataTable, table_id)
    if not item:
        raise HTTPException(status_code=404, detail="Tabela não encontrada.")
    return _table_payload(item, db, include_records=True)


@app.put("/api/data-tables/{table_id}")
async def update_data_table(table_id: int, request: Request, db: Session = Depends(get_db)) -> dict[str, Any]:
    item = db.get(DataTable, table_id)
    if not item:
        raise HTTPException(status_code=404, detail="Tabela não encontrada.")
    body = await request.json()
    if body.get("name"):
        item.name = body["name"].strip()
    if "tenant_id" in body:
        item.tenant_id = (body.get("tenant_id") or "").strip() or None
    if "schema_json" in body and isinstance(body.get("schema_json"), dict):
        item.schema_json = body["schema_json"]
    if "description" in body:
        schema = dict(item.schema_json or {})
        schema["description"] = clean_display_text(body.get("description") or "").strip()
        item.schema_json = schema
    db.add(item)
    db.commit()
    db.refresh(item)
    return {"status": "saved", "table": _table_payload(item, db, include_records=True)}


@app.delete("/api/data-tables/{table_id}")
def delete_data_table(table_id: int, db: Session = Depends(get_db)) -> dict[str, Any]:
    item = db.get(DataTable, table_id)
    if not item:
        raise HTTPException(status_code=404, detail="Tabela não encontrada.")
    db.query(TableRecord).filter(TableRecord.table_id == table_id).delete(synchronize_session=False)
    db.delete(item)
    db.commit()
    return {"status": "deleted", "table_id": table_id}


@app.post("/api/data-tables/{table_id}/columns")
async def upsert_data_table_column(table_id: int, request: Request, db: Session = Depends(get_db)) -> dict[str, Any]:
    item = db.get(DataTable, table_id)
    if not item:
        raise HTTPException(status_code=404, detail="Tabela não encontrada.")
    body = await request.json()
    field = _normalize_table_field(body)
    item.schema_json = _table_schema_with_field(item.schema_json, field)
    for record in db.query(TableRecord).filter(TableRecord.table_id == table_id).all():
        data = dict(record.data or {})
        data.setdefault(field["name"], "")
        record.data = data
        db.add(record)
    db.add(item)
    db.commit()
    db.refresh(item)
    return {"status": "saved", "table": _table_payload(item, db, include_records=True)}


@app.delete("/api/data-tables/{table_id}/columns/{column_name}")
def delete_data_table_column(table_id: int, column_name: str, db: Session = Depends(get_db)) -> dict[str, Any]:
    item = db.get(DataTable, table_id)
    if not item:
        raise HTTPException(status_code=404, detail="Tabela não encontrada.")
    column = clean_display_text(column_name).strip()
    schema = dict(item.schema_json or {})
    schema["fields"] = [
        field for field in schema.get("fields", []) if not (isinstance(field, dict) and str(field.get("name") or "") == column)
    ]
    item.schema_json = schema
    for record in db.query(TableRecord).filter(TableRecord.table_id == table_id).all():
        data = dict(record.data or {})
        data.pop(column, None)
        record.data = data
        db.add(record)
    db.add(item)
    db.commit()
    db.refresh(item)
    return {"status": "deleted", "table": _table_payload(item, db, include_records=True)}


@app.post("/api/data-tables/{table_id}/records")
async def create_table_record(table_id: int, request: Request, db: Session = Depends(get_db)) -> dict[str, Any]:
    if not db.get(DataTable, table_id):
        raise HTTPException(status_code=404, detail="Tabela não encontrada.")
    body = await request.json()
    data = body.get("data") if isinstance(body.get("data"), dict) else body
    record = TableRecord(table_id=table_id, data=dict(data or {}))
    db.add(record)
    db.commit()
    db.refresh(record)
    return {"status": "created", "record": {"id": record.id, "data": record.data or {}}}


@app.put("/api/table-records/{record_id}")
async def update_table_record(record_id: int, request: Request, db: Session = Depends(get_db)) -> dict[str, Any]:
    record = db.get(TableRecord, record_id)
    if not record:
        raise HTTPException(status_code=404, detail="Registro não encontrado.")
    body = await request.json()
    data = body.get("data") if isinstance(body.get("data"), dict) else body
    record.data = dict(data or {})
    db.add(record)
    db.commit()
    db.refresh(record)
    return {"status": "saved", "record": {"id": record.id, "data": record.data or {}}}


@app.delete("/api/table-records/{record_id}")
def delete_table_record(record_id: int, db: Session = Depends(get_db)) -> dict[str, Any]:
    record = db.get(TableRecord, record_id)
    if not record:
        raise HTTPException(status_code=404, detail="Registro não encontrado.")
    table_id = record.table_id
    db.delete(record)
    db.commit()
    return {"status": "deleted", "record_id": record_id, "table_id": table_id}


def _squad_payload(item: Squad, db: Session, include_members: bool = False) -> dict[str, Any]:
    agent_names = {agent.id: clean_display_text(agent.name) for agent in db.query(Agent).all()}
    payload = {
        "id": item.id,
        "name": clean_display_text(item.name),
        "mode": item.mode,
        "manager_agent_id": item.manager_agent_id,
        "manager_agent_name": agent_names.get(item.manager_agent_id),
        "fallback_agent_id": item.fallback_agent_id,
        "fallback_agent_name": agent_names.get(item.fallback_agent_id),
        "config": item.config or {},
        "member_count": db.query(SquadMember).filter(SquadMember.squad_id == item.id).count(),
        "created_at": item.created_at.isoformat(),
    }
    if include_members:
        payload["members"] = [
            {
                "id": member.id,
                "agent_id": member.agent_id,
                "agent_name": agent_names.get(member.agent_id),
                "role": clean_display_text(member.role),
                "priority": member.priority,
                "routing_description": clean_display_text(member.routing_description),
                "config": member.config or {},
            }
            for member in db.query(SquadMember).filter(SquadMember.squad_id == item.id).order_by(SquadMember.priority.asc(), SquadMember.id.asc()).all()
        ]
    return payload


@app.get("/api/squads")
def list_squads(db: Session = Depends(get_db)) -> list[dict[str, Any]]:
    items = db.query(Squad).order_by(Squad.id.desc()).all()
    return [_squad_payload(item, db) for item in items]


@app.post("/api/squads")
async def create_squad(request: Request, db: Session = Depends(get_db)) -> dict[str, Any]:
    body = await request.json()
    name = (body.get("name") or "").strip()
    if not name:
        raise HTTPException(status_code=400, detail="Informe o nome do squad.")
    item = Squad(
        name=name,
        mode=(body.get("mode") or "hierarchical").strip(),
        manager_agent_id=body.get("manager_agent_id") or None,
        fallback_agent_id=body.get("fallback_agent_id") or None,
        config=body.get("config") or {},
    )
    db.add(item)
    db.commit()
    db.refresh(item)
    return {"id": item.id, "status": "created"}


def crm_team_payload(item: CRMTeam) -> dict[str, Any]:
    return {
        "id": item.id,
        "name": clean_display_text(item.name),
        "description": clean_display_text(item.description),
        "created_at": item.created_at.isoformat(),
    }


def crm_attendant_payload(item: CRMAttendant, teams: dict[int, CRMTeam] | None = None) -> dict[str, Any]:
    team = (teams or {}).get(item.team_id)
    return {
        "id": item.id,
        "team_id": item.team_id,
        "team_name": clean_display_text(team.name) if team else None,
        "name": clean_display_text(item.name),
        "email": clean_display_text(item.email),
        "available": bool(item.available),
        "weight": item.weight,
        "created_at": item.created_at.isoformat(),
    }


def crm_tag_payload(item: CRMTag) -> dict[str, Any]:
    return {
        "id": item.id,
        "name": clean_display_text(item.name),
        "description": clean_display_text(item.description),
        "color": item.color,
        "created_at": item.created_at.isoformat(),
    }


@app.get("/api/crm/teams")
def list_crm_teams(db: Session = Depends(get_db)) -> list[dict[str, Any]]:
    return [crm_team_payload(item) for item in db.query(CRMTeam).order_by(CRMTeam.name.asc()).all()]


@app.post("/api/crm/teams")
async def create_crm_team(request: Request, db: Session = Depends(get_db)) -> dict[str, Any]:
    body = await request.json()
    name = (body.get("name") or "").strip()
    if not name:
        raise HTTPException(status_code=400, detail="Informe o nome do time.")
    item = CRMTeam(name=name, description=(body.get("description") or "").strip() or None)
    db.add(item)
    db.commit()
    db.refresh(item)
    return {"id": item.id, "status": "created"}


@app.put("/api/crm/teams/{team_id}")
async def update_crm_team(team_id: int, request: Request, db: Session = Depends(get_db)) -> dict[str, Any]:
    item = db.get(CRMTeam, team_id)
    if not item:
        raise HTTPException(status_code=404, detail="Time não encontrado.")
    body = await request.json()
    if body.get("name"):
        item.name = body["name"].strip()
    if "description" in body:
        item.description = (body.get("description") or "").strip() or None
    db.add(item)
    db.commit()
    db.refresh(item)
    return {"status": "saved", "id": item.id}


@app.get("/api/squads/{squad_id}")
def get_squad(squad_id: int, db: Session = Depends(get_db)) -> dict[str, Any]:
    item = db.get(Squad, squad_id)
    if not item:
        raise HTTPException(status_code=404, detail="Squad não encontrado.")
    return _squad_payload(item, db, include_members=True)


@app.put("/api/squads/{squad_id}")
async def update_squad(squad_id: int, request: Request, db: Session = Depends(get_db)) -> dict[str, Any]:
    item = db.get(Squad, squad_id)
    if not item:
        raise HTTPException(status_code=404, detail="Squad não encontrado.")
    body = await request.json()
    if body.get("name"):
        item.name = body["name"].strip()
    if "mode" in body:
        mode = (body.get("mode") or item.mode).strip()
        if mode not in {"hierarchical", "horizontal"}:
            raise HTTPException(status_code=400, detail="Modo invalido.")
        item.mode = mode
    if "manager_agent_id" in body:
        item.manager_agent_id = int(body["manager_agent_id"]) if body.get("manager_agent_id") else None
    if "fallback_agent_id" in body:
        item.fallback_agent_id = int(body["fallback_agent_id"]) if body.get("fallback_agent_id") else None
    if "config" in body:
        item.config = body.get("config") if isinstance(body.get("config"), dict) else {}
    db.add(item)
    db.commit()
    db.refresh(item)
    return {"status": "saved", "squad": _squad_payload(item, db, include_members=True)}


@app.delete("/api/squads/{squad_id}")
def delete_squad(squad_id: int, db: Session = Depends(get_db)) -> dict[str, Any]:
    item = db.get(Squad, squad_id)
    if not item:
        raise HTTPException(status_code=404, detail="Squad não encontrado.")
    db.query(SquadMember).filter(SquadMember.squad_id == squad_id).delete(synchronize_session=False)
    db.delete(item)
    db.commit()
    return {"status": "deleted", "squad_id": squad_id}


@app.post("/api/squads/{squad_id}/members")
async def add_squad_member(squad_id: int, request: Request, db: Session = Depends(get_db)) -> dict[str, Any]:
    squad = db.get(Squad, squad_id)
    if not squad:
        raise HTTPException(status_code=404, detail="Squad não encontrado.")
    body = await request.json()
    agent_id = body.get("agent_id")
    if not agent_id or not db.get(Agent, int(agent_id)):
        raise HTTPException(status_code=400, detail="Selecione um agente valido.")
    existing = db.query(SquadMember).filter(SquadMember.squad_id == squad_id, SquadMember.agent_id == int(agent_id)).first()
    if existing:
        member = existing
    else:
        member = SquadMember(squad_id=squad_id, agent_id=int(agent_id), role="specialist")
    member.role = (body.get("role") or member.role or "specialist").strip()
    member.priority = max(1, int(body.get("priority") or member.priority or 100))
    member.routing_description = (body.get("routing_description") or "").strip() or None
    member.config = body.get("config") if isinstance(body.get("config"), dict) else {}
    db.add(member)
    db.commit()
    db.refresh(member)
    return {"status": "saved", "squad": _squad_payload(squad, db, include_members=True)}


@app.put("/api/squad-members/{member_id}")
async def update_squad_member(member_id: int, request: Request, db: Session = Depends(get_db)) -> dict[str, Any]:
    member = db.get(SquadMember, member_id)
    if not member:
        raise HTTPException(status_code=404, detail="Membro não encontrado.")
    body = await request.json()
    if "role" in body:
        member.role = (body.get("role") or member.role).strip()
    if "priority" in body:
        member.priority = max(1, int(body.get("priority") or member.priority))
    if "routing_description" in body:
        member.routing_description = (body.get("routing_description") or "").strip() or None
    if "config" in body:
        member.config = body.get("config") if isinstance(body.get("config"), dict) else {}
    db.add(member)
    db.commit()
    squad = db.get(Squad, member.squad_id)
    return {"status": "saved", "squad": _squad_payload(squad, db, include_members=True) if squad else None}


@app.delete("/api/squad-members/{member_id}")
def delete_squad_member(member_id: int, db: Session = Depends(get_db)) -> dict[str, Any]:
    member = db.get(SquadMember, member_id)
    if not member:
        raise HTTPException(status_code=404, detail="Membro não encontrado.")
    squad_id = member.squad_id
    db.delete(member)
    db.commit()
    squad = db.get(Squad, squad_id)
    return {"status": "deleted", "squad": _squad_payload(squad, db, include_members=True) if squad else None}


def _squad_builder_context(squad: Squad, db: Session) -> str:
    members = db.query(SquadMember).filter(SquadMember.squad_id == squad.id).order_by(SquadMember.priority.asc()).all()
    lines = [
        f"Squad: {squad.name}",
        f"Modo: {squad.mode}",
        "Objetivo: criar um agente com especializacao complementar, fronteiras claras e role facil de rotear.",
    ]
    for member in members:
        agent = db.get(Agent, member.agent_id)
        if not agent:
            continue
        lines.append(
            "\n".join(
                [
                    f"Agente existente: {agent.name}",
                    f"Role no squad: {member.role}",
                    f"Descrição de roteamento: {member.routing_description or agent.description or '-'}",
                    f"Prompt resumido: {(agent.system_prompt or '')[:700]}",
                ]
            )
        )
    return "\n\n".join(lines)


@app.post("/api/squads/{squad_id}/agent-builder/preview")
async def squad_agent_builder_preview(squad_id: int, request: Request, db: Session = Depends(get_db)) -> dict[str, Any]:
    squad = db.get(Squad, squad_id)
    if not squad:
        raise HTTPException(status_code=404, detail="Squad não encontrado.")
    body = await request.json()
    description = (body.get("description") or body.get("prompt") or "").strip()
    if not description:
        raise HTTPException(status_code=400, detail="Informe a descrição do agente.")
    squad_context = _squad_builder_context(squad, db)
    enriched_description = (
        f"Crie um novo agente para o Squad {squad.name}. Pedido do usuário: {description}. "
        "O agente deve complementar os membros atuais, evitar sobrepor funções existentes e ter fronteiras operacionais claras."
    )
    return await build_agent_blueprint(
        description=enriched_description,
        model=(body.get("ai_model") or "").strip() or None,
        api_key=(body.get("ai_api_key") or "").strip() or None,
        temperature=normalize_temperature(body.get("temperature", 0.2), 0.2),
        source_context=squad_context,
    )


@app.get("/api/crm/attendants")
def list_crm_attendants(db: Session = Depends(get_db)) -> list[dict[str, Any]]:
    teams = {item.id: item for item in db.query(CRMTeam).all()}
    return [
        crm_attendant_payload(item, teams)
        for item in db.query(CRMAttendant).order_by(CRMAttendant.name.asc()).all()
    ]


@app.post("/api/crm/attendants")
async def create_crm_attendant(request: Request, db: Session = Depends(get_db)) -> dict[str, Any]:
    body = await request.json()
    name = (body.get("name") or "").strip()
    team_id = body.get("team_id")
    if not name:
        raise HTTPException(status_code=400, detail="Informe o nome do atendente.")
    if not team_id or not db.get(CRMTeam, int(team_id)):
        raise HTTPException(status_code=400, detail="Selecione um CRM Team válido.")
    item = CRMAttendant(
        team_id=int(team_id),
        name=name,
        email=(body.get("email") or "").strip() or None,
        available=bool(body.get("available", True)),
        weight=max(1, int(body.get("weight") or 1)),
    )
    db.add(item)
    db.commit()
    db.refresh(item)
    return {"id": item.id, "status": "created"}


@app.put("/api/crm/attendants/{attendant_id}")
async def update_crm_attendant(attendant_id: int, request: Request, db: Session = Depends(get_db)) -> dict[str, Any]:
    item = db.get(CRMAttendant, attendant_id)
    if not item:
        raise HTTPException(status_code=404, detail="Atendente não encontrado.")
    body = await request.json()
    if "name" in body and body.get("name"):
        item.name = body["name"].strip()
    if "email" in body:
        item.email = (body.get("email") or "").strip() or None
    if "team_id" in body and body.get("team_id"):
        if not db.get(CRMTeam, int(body["team_id"])):
            raise HTTPException(status_code=400, detail="CRM Team inválido.")
        item.team_id = int(body["team_id"])
    if "available" in body:
        item.available = bool(body.get("available"))
    if "weight" in body:
        item.weight = max(1, int(body.get("weight") or 1))
    db.add(item)
    db.commit()
    return {"status": "saved", "id": item.id}


@app.delete("/api/crm/attendants/{attendant_id}")
def delete_crm_attendant(attendant_id: int, db: Session = Depends(get_db)) -> dict[str, Any]:
    item = db.get(CRMAttendant, attendant_id)
    if not item:
        raise HTTPException(status_code=404, detail="Atendente não encontrado.")
    conversation_ids = {
        row.conversation_id
        for row in db.query(Ticket.conversation_id)
        .filter(Ticket.assigned_attendant_id == attendant_id, Ticket.conversation_id.isnot(None))
        .all()
        if row.conversation_id
    }
    db.query(Ticket).filter(Ticket.assigned_attendant_id == attendant_id).update(
        {Ticket.assigned_attendant_id: None},
        synchronize_session=False,
    )
    if conversation_ids:
        for conversation in db.query(Conversation).filter(Conversation.id.in_(conversation_ids)).all():
            state = dict(conversation.state or {})
            handoff = dict(state.get("human_handoff") or {})
            if str(handoff.get("assigned_attendant_id") or "") == str(attendant_id):
                handoff["assigned_attendant_id"] = None
                handoff["updated_at"] = datetime.utcnow().isoformat()
                state["human_handoff"] = handoff
                conversation.state = state
                db.add(conversation)
    db.delete(item)
    db.commit()
    return {"status": "deleted", "attendant_id": attendant_id}


@app.get("/api/crm/tags")
def list_crm_tags(db: Session = Depends(get_db)) -> list[dict[str, Any]]:
    return [crm_tag_payload(item) for item in db.query(CRMTag).order_by(CRMTag.name.asc()).all()]


@app.post("/api/crm/tags")
async def create_crm_tag(request: Request, db: Session = Depends(get_db)) -> dict[str, Any]:
    body = await request.json()
    name = (body.get("name") or "").strip()
    if not name:
        raise HTTPException(status_code=400, detail="Informe o nome da tag.")
    color = (body.get("color") or "#7c3aed").strip()
    if not re.fullmatch(r"#[0-9a-fA-F]{6}", color):
        color = "#7c3aed"
    item = CRMTag(name=name, description=(body.get("description") or "").strip() or None, color=color)
    db.add(item)
    db.commit()
    db.refresh(item)
    return {"id": item.id, "status": "created"}


@app.put("/api/crm/tags/{tag_id}")
async def update_crm_tag(tag_id: int, request: Request, db: Session = Depends(get_db)) -> dict[str, Any]:
    item = db.get(CRMTag, tag_id)
    if not item:
        raise HTTPException(status_code=404, detail="Tag não encontrada.")
    body = await request.json()
    old_name = (item.name or "").strip()
    if body.get("name"):
        item.name = body["name"].strip()
    if "description" in body:
        item.description = (body.get("description") or "").strip() or None
    if "color" in body:
        color = (body.get("color") or item.color or "#1d4ed8").strip()
        item.color = color if re.fullmatch(r"#[0-9a-fA-F]{6}", color) else "#1d4ed8"
    if old_name and item.name != old_name:
        for ticket in db.query(Ticket).all():
            tags = ticket.tags or []
            if not isinstance(tags, list):
                continue
            changed = False
            next_tags = []
            for tag in tags:
                if isinstance(tag, dict):
                    if str(tag.get("id") or "") == str(tag_id) or str(tag.get("name") or "").strip().lower() == old_name.lower():
                        tag = {**tag, "id": tag_id, "name": item.name, "color": item.color}
                        changed = True
                elif str(tag or "").strip().lower() == old_name.lower():
                    tag = item.name
                    changed = True
                next_tags.append(tag)
            if changed:
                ticket.tags = next_tags
                db.add(ticket)
    db.add(item)
    db.commit()
    db.refresh(item)
    return {"status": "saved", "id": item.id}


@app.delete("/api/crm/tags/{tag_id}")
def delete_crm_tag(tag_id: int, db: Session = Depends(get_db)) -> dict[str, Any]:
    item = db.get(CRMTag, tag_id)
    if not item:
        raise HTTPException(status_code=404, detail="Tag não encontrada.")
    tag_name = (item.name or "").strip().lower()
    for ticket in db.query(Ticket).all():
        tags = ticket.tags or []
        if not isinstance(tags, list):
            continue
        filtered_tags = []
        changed = False
        for tag in tags:
            should_remove = False
            if isinstance(tag, dict):
                should_remove = str(tag.get("id") or "") == str(tag_id) or str(tag.get("name") or "").strip().lower() == tag_name
            else:
                should_remove = str(tag or "").strip().lower() == tag_name
            if should_remove:
                changed = True
            else:
                filtered_tags.append(tag)
        if changed:
            ticket.tags = filtered_tags
            db.add(ticket)
    db.delete(item)
    db.commit()
    return {"status": "deleted", "tag_id": tag_id}


@app.get("/api/tickets")
def list_tickets(status: str | None = None, db: Session = Depends(get_db)) -> list[dict[str, Any]]:
    query = db.query(Ticket)
    if status:
        query = query.filter(Ticket.status == status)
    return [
        ticket_payload(item, db)
        for item in query.order_by(Ticket.updated_at.desc(), Ticket.id.desc()).limit(200).all()
    ]


@app.put("/api/tickets/{ticket_id}")
async def update_ticket(ticket_id: int, request: Request, db: Session = Depends(get_db)) -> dict[str, Any]:
    item = db.get(Ticket, ticket_id)
    if not item:
        raise HTTPException(status_code=404, detail="Ticket não encontrado.")
    body = await request.json()
    status = (body.get("status") or item.status).strip().lower()
    if status == "closed":
        status = "finished"
    if status not in {"unanswered", "in_progress", "finished"}:
        raise HTTPException(status_code=400, detail="Status inválido.")
    if "assigned_attendant_id" in body:
        assigned = body.get("assigned_attendant_id")
        if assigned and not db.get(CRMAttendant, int(assigned)):
            raise HTTPException(status_code=400, detail="Atendente inválido.")
        item.assigned_attendant_id = int(assigned) if assigned else None
    if status == "in_progress" and not item.assigned_attendant_id:
        attendant = choose_round_robin_attendant(db, item.team_id)
        if attendant:
            item.assigned_attendant_id = attendant.id
    if status == "in_progress" and item.conversation_id:
        conversation = db.get(Conversation, item.conversation_id)
        if conversation:
            _set_human_pause_state(conversation, item.id, True)
            db.add(conversation)
    if "tags" in body and isinstance(body.get("tags"), list):
        item.tags = body["tags"]
    item.status = status
    if status == "finished":
        item.finished_at = item.finished_at or datetime.utcnow()
        if item.conversation_id:
            conversation = db.get(Conversation, item.conversation_id)
            if conversation:
                state = dict(conversation.state or {})
                handoff = dict(state.get("human_handoff") or {})
                handoff.update(
                    {
                        "requested": False,
                        "paused": False,
                        "ticket_id": item.id,
                        "ticket_status": "finished",
                        "updated_at": datetime.utcnow().isoformat(),
                    }
                )
                state["human_handoff"] = handoff
                conversation.state = state
                archive_conversation_snapshot(
                    db,
                    conversation,
                    "human_handoff_ticket_finished",
                    {"ticket_id": item.id, "status": status},
                )
                db.add(conversation)
    db.add(item)
    db.commit()
    db.refresh(item)
    return {"status": "saved", "ticket": ticket_payload(item, db)}


@app.delete("/api/tickets/{ticket_id}")
def delete_ticket(ticket_id: int, db: Session = Depends(get_db)) -> dict[str, Any]:
    item = db.get(Ticket, ticket_id)
    if not item:
        raise HTTPException(status_code=404, detail="Ticket não encontrado.")
    conversation = db.get(Conversation, item.conversation_id) if item.conversation_id else None
    if conversation:
        other_open_ticket = (
            db.query(Ticket)
            .filter(
                Ticket.conversation_id == conversation.id,
                Ticket.id != ticket_id,
                Ticket.status.in_(["unanswered", "in_progress"]),
            )
            .first()
        )
        state = dict(conversation.state or {})
        handoff = dict(state.get("human_handoff") or {})
        if other_open_ticket:
            if str(handoff.get("ticket_id") or "") == str(ticket_id) or not handoff.get("ticket_id"):
                handoff.update(
                    {
                        "requested": True,
                        "paused": True,
                        "ticket_id": other_open_ticket.id,
                        "ticket_status": other_open_ticket.status,
                        "team_id": other_open_ticket.team_id,
                        "assigned_attendant_id": other_open_ticket.assigned_attendant_id,
                        "updated_at": datetime.utcnow().isoformat(),
                    }
                )
                state["human_handoff"] = handoff
                conversation.state = state
                db.add(conversation)
        else:
            handoff.update(
                {
                    "requested": False,
                    "paused": False,
                    "ticket_id": None,
                    "ticket_status": "deleted",
                    "updated_at": datetime.utcnow().isoformat(),
                }
            )
            state["human_handoff"] = handoff
            conversation.state = state
            archive_conversation_snapshot(
                db,
                conversation,
                "human_handoff_ticket_deleted",
                {"ticket_id": ticket_id},
            )
            db.add(conversation)
    db.delete(item)
    db.commit()
    return {"status": "deleted", "ticket_id": ticket_id}


def _conversation_payload(conversation: Conversation, db: Session) -> dict[str, Any]:
    agent = db.get(Agent, conversation.agent_id)
    state = conversation.state or {}
    open_ticket = (
        db.query(Ticket)
        .filter(Ticket.conversation_id == conversation.id, Ticket.status.in_(["unanswered", "in_progress"]))
        .order_by(Ticket.updated_at.desc(), Ticket.id.desc())
        .first()
    )
    ticket_data = ticket_payload(open_ticket, db) if open_ticket else None
    return {
        "id": conversation.id,
        "agent_id": conversation.agent_id,
        "agent_name": clean_display_text(agent.name) if agent else None,
        "customer_phone": conversation.customer_phone,
        "customer_name": state.get("customer_name") if isinstance(state, dict) else None,
        "source": state.get("source") if isinstance(state, dict) else None,
        "channel": conversation_channel(conversation),
        "shared_chat_link_id": conversation.shared_chat_link_id,
        "state": state,
        "runtime": _conversation_runtime_payload(conversation, agent, ticket_data, db),
        "ticket": ticket_data,
        "created_at": conversation.created_at.isoformat(),
        "updated_at": conversation.updated_at.isoformat(),
        "messages": [
            {
                "id": message.id,
                "role": message.role,
                "content": clean_display_text(message.content),
                "metadata": message.message_metadata or {},
                "created_at": message.created_at.isoformat(),
            }
            for message in db.query(Message).filter(Message.conversation_id == conversation.id).order_by(Message.id.asc()).all()
        ],
    }


def _conversation_summary_payload(
    conversation: Conversation,
    db: Session,
    agent_names: dict[int, str] | None = None,
    tickets_by_conversation: dict[int, Ticket] | None = None,
) -> dict[str, Any]:
    state = conversation.state or {}
    agent_name = (agent_names or {}).get(conversation.agent_id)
    if agent_name is None:
        agent = db.get(Agent, conversation.agent_id)
        agent_name = clean_display_text(agent.name) if agent else None
    if tickets_by_conversation is None:
        open_ticket = (
            db.query(Ticket)
            .filter(Ticket.conversation_id == conversation.id, Ticket.status.in_(["unanswered", "in_progress"]))
            .order_by(Ticket.updated_at.desc(), Ticket.id.desc())
            .first()
        )
    else:
        open_ticket = tickets_by_conversation.get(conversation.id)
    return {
        "id": conversation.id,
        "agent_id": conversation.agent_id,
        "agent_name": agent_name,
        "customer_phone": conversation.customer_phone,
        "customer_name": state.get("customer_name") if isinstance(state, dict) else None,
        "source": state.get("source") if isinstance(state, dict) else None,
        "channel": conversation_channel(conversation),
        "shared_chat_link_id": conversation.shared_chat_link_id,
        "messages": len(conversation.history or []),
        "preview": "\n".join(
            f"{m.get('role')}: {clean_display_text(m.get('content'))}" for m in (conversation.history or [])[-6:]
        ),
        "state": state,
        "ticket": ticket_payload(open_ticket, db) if open_ticket else None,
        "created_at": conversation.created_at.isoformat(),
        "updated_at": conversation.updated_at.isoformat(),
    }


def _conversation_summary_list(conversations: list[Conversation], db: Session) -> list[dict[str, Any]]:
    if not conversations:
        return []
    agent_names = {agent_id: clean_display_text(name) for agent_id, name in db.query(Agent.id, Agent.name).all()}
    conversation_ids = [conversation.id for conversation in conversations]
    tickets_by_conversation: dict[int, Ticket] = {}
    for ticket in (
        db.query(Ticket)
        .filter(Ticket.conversation_id.in_(conversation_ids), Ticket.status.in_(["unanswered", "in_progress"]))
        .order_by(Ticket.updated_at.desc(), Ticket.id.desc())
        .all()
    ):
        if ticket.conversation_id and ticket.conversation_id not in tickets_by_conversation:
            tickets_by_conversation[ticket.conversation_id] = ticket
    return [
        _conversation_summary_payload(conversation, db, agent_names, tickets_by_conversation)
        for conversation in conversations
    ]


def _memory_value_text(value: Any) -> str:
    if isinstance(value, dict):
        raw = value.get("value") or value.get("text") or value.get("content") or value.get("summary")
        if raw is not None:
            return clean_display_text(raw)
    if isinstance(value, (list, tuple)):
        return ", ".join(_memory_value_text(item) for item in value if item is not None)
    return clean_display_text(value)


def _runtime_memory_entries(conversation: Conversation, agent: Agent | None, db: Session) -> tuple[list[dict[str, Any]], int]:
    state = conversation.state or {}
    configured = agent_memory_variables(db, agent) if agent else []
    expected_names = [item.get("name") for item in configured if item.get("name")]
    values: dict[str, Any] = {}
    for source in (state.get("agent_variables"), state.get("memory"), state.get("context")):
        if isinstance(source, dict):
            values.update(source)
    for item in (
        db.query(MemoryItem)
        .filter(MemoryItem.conversation_id == conversation.id)
        .order_by(MemoryItem.updated_at.desc(), MemoryItem.id.desc())
        .all()
    ):
        values.setdefault(item.key, item.value)

    ordered_names = [name for name in expected_names if name in values]
    ordered_names.extend(name for name in values.keys() if name not in ordered_names)
    entries = [
        {
            "name": name,
            "label": f"@{name}",
            "value": _memory_value_text(values.get(name)),
            "filled": bool(_memory_value_text(values.get(name))),
        }
        for name in ordered_names[:12]
    ]
    return entries, len(expected_names)


def _goal_text(goal: Any) -> str:
    if isinstance(goal, dict):
        return clean_display_text(goal.get("name") or goal.get("raw_text") or goal.get("description"))
    return clean_display_text(goal)


def _conversation_objectives(agent: Agent | None, state: dict[str, Any]) -> list[dict[str, Any]]:
    goals = (getattr(agent, "conversation_goals", None) or []) if agent else []
    progress = state.get("objectives") or state.get("objective_state") or {}
    progress_by_order: dict[str, dict[str, Any]] = {}
    if isinstance(progress, dict):
        for item in progress.get("items") or []:
            if isinstance(item, dict) and item.get("order") is not None:
                progress_by_order[str(item.get("order"))] = item
    result: list[dict[str, Any]] = []
    for index, goal in enumerate(goals[:8], start=1):
        label = _goal_text(goal)
        if not label:
            continue
        status = "pendente"
        note = "pendente"
        if isinstance(progress, dict):
            goal_order = str(goal.get("order") or index)
            item_state = progress_by_order.get(goal_order) or progress.get(goal_order) or progress.get(str(index)) or progress.get(label) or progress.get(label.lower())
            if isinstance(item_state, dict):
                status = clean_display_text(item_state.get("status") or item_state.get("state") or status)
                note = clean_display_text(item_state.get("note") or item_state.get("reason") or status)
            elif item_state:
                status = clean_display_text(item_state)
                note = status
        result.append({"order": index, "label": label, "status": status, "note": note})
    return result


def _conversation_tool_usage(conversation: Conversation, db: Session) -> list[dict[str, Any]]:
    run_ids = [
        row.id
        for row in db.query(AgentRun.id)
        .filter(AgentRun.conversation_id == conversation.id)
        .order_by(AgentRun.id.desc())
        .limit(12)
        .all()
    ]
    if not run_ids:
        return []
    return [
        {
            "id": item.id,
            "tool": clean_display_text(item.tool_name),
            "status": item.status,
            "latency_ms": item.latency_ms,
            "input": item.input or {},
            "output": item.output or {},
            "error": clean_display_text(item.error),
            "created_at": item.created_at.isoformat(),
        }
        for item in (
            db.query(ToolInvocation)
            .filter(ToolInvocation.run_id.in_(run_ids))
            .order_by(ToolInvocation.id.desc())
            .limit(10)
            .all()
        )
    ]


def _conversation_runtime_payload(conversation: Conversation, agent: Agent | None, ticket_data: dict[str, Any] | None, db: Session) -> dict[str, Any]:
    state = conversation.state or {}
    handoff = state.get("human_handoff") if isinstance(state.get("human_handoff"), dict) else {}
    memory_entries, expected_memory = _runtime_memory_entries(conversation, agent, db)
    conditionals = state.get("conditional_prompts") if isinstance(state.get("conditional_prompts"), dict) else {}
    return {
        "status": {
            "state": clean_display_text(handoff.get("reason") or handoff.get("source") or ("handoff" if handoff.get("requested") else "ativo")),
            "paused": bool(handoff.get("paused") or handoff.get("requested")),
            "memory_filled": sum(1 for item in memory_entries if item.get("filled")),
            "memory_expected": expected_memory or len(memory_entries),
            "ticket": ticket_data,
            "team_name": ticket_data.get("team_name") if ticket_data else clean_display_text(handoff.get("team_name") or handoff.get("team")),
            "updated_at": handoff.get("updated_at") or conversation.updated_at.isoformat(),
        },
        "objectives": _conversation_objectives(agent, state),
        "memory": {"entries": memory_entries},
        "tools": {"used": _conversation_tool_usage(conversation, db)},
        "conditionals": {"matched": conditionals.get("matched") or [], "updated_at": conditionals.get("updated_at")},
    }


def _delete_conversations(db: Session, conversations: list[Conversation]) -> int:
    conversation_ids = [item.id for item in conversations]
    if not conversation_ids:
        return 0

    db.query(Ticket).filter(Ticket.conversation_id.in_(conversation_ids)).update(
        {Ticket.conversation_id: None},
        synchronize_session=False,
    )
    db.query(AgentRun).filter(AgentRun.conversation_id.in_(conversation_ids)).update(
        {AgentRun.conversation_id: None},
        synchronize_session=False,
    )
    db.query(TableRecord).filter(TableRecord.conversation_id.in_(conversation_ids)).update(
        {TableRecord.conversation_id: None},
        synchronize_session=False,
    )
    db.query(SharedChatUsageEvent).filter(SharedChatUsageEvent.conversation_id.in_(conversation_ids)).update(
        {SharedChatUsageEvent.conversation_id: None},
        synchronize_session=False,
    )
    db.query(MemoryItem).filter(MemoryItem.conversation_id.in_(conversation_ids)).delete(synchronize_session=False)
    for conversation in conversations:
        db.delete(conversation)
    db.commit()
    return len(conversation_ids)


@app.get("/api/conversations")
def list_conversations(agent_id: int | None = None, db: Session = Depends(get_db)) -> list[dict[str, Any]]:
    query = _without_agent_test_conversations(db.query(Conversation))
    if agent_id is not None:
        query = query.filter(Conversation.agent_id == agent_id)
    conversations = query.order_by(Conversation.updated_at.desc()).limit(200).all()
    return _conversation_summary_list(conversations, db)


@app.get("/api/agents/{agent_id}/conversations")
def list_agent_conversations(agent_id: int, db: Session = Depends(get_db)) -> list[dict[str, Any]]:
    if not db.get(Agent, agent_id):
        raise HTTPException(status_code=404, detail="Agente não encontrado.")
    conversations = (
        _without_agent_test_conversations(db.query(Conversation))
        .filter(Conversation.agent_id == agent_id)
        .order_by(Conversation.updated_at.desc())
        .limit(200)
        .all()
    )
    return _conversation_summary_list(conversations, db)


@app.get("/api/conversations/{conversation_id}")
def get_conversation_detail(conversation_id: int, db: Session = Depends(get_db)) -> dict[str, Any]:
    conversation = db.get(Conversation, conversation_id)
    if not conversation:
        raise HTTPException(status_code=404, detail="Conversa não encontrada.")
    return _conversation_payload(conversation, db)


@app.delete("/api/conversations/{conversation_id}")
def delete_conversation(conversation_id: int, db: Session = Depends(get_db)) -> dict[str, Any]:
    conversation = db.get(Conversation, conversation_id)
    if not conversation:
        raise HTTPException(status_code=404, detail="Conversa não encontrada.")
    deleted = _delete_conversations(db, [conversation])
    return {"status": "deleted", "deleted_count": deleted}


@app.delete("/api/agents/{agent_id}/conversations")
def delete_agent_conversations(agent_id: int, db: Session = Depends(get_db)) -> dict[str, Any]:
    if not db.get(Agent, agent_id):
        raise HTTPException(status_code=404, detail="Agente não encontrado.")
    conversations = _without_agent_test_conversations(db.query(Conversation)).filter(Conversation.agent_id == agent_id).all()
    deleted = _delete_conversations(db, conversations)
    return {"status": "deleted", "agent_id": agent_id, "deleted_count": deleted}


@app.post("/api/conversations/{conversation_id}/pause-agent")
async def pause_agent_for_conversation(conversation_id: int, request: Request, db: Session = Depends(get_db)) -> dict[str, Any]:
    conversation = db.get(Conversation, conversation_id)
    if not conversation:
        raise HTTPException(status_code=404, detail="Conversa não encontrada.")
    try:
        body = await request.json()
    except Exception:
        body = {}
    if not isinstance(body, dict):
        body = {}

    ticket = None
    ticket_id = body.get("ticket_id")
    if ticket_id:
        ticket = db.get(Ticket, int(ticket_id))
        if not ticket or ticket.conversation_id != conversation.id:
            raise HTTPException(status_code=400, detail="Ticket não pertence a esta conversa.")
    if not ticket:
        ticket = (
            db.query(Ticket)
            .filter(Ticket.conversation_id == conversation.id, Ticket.status.in_(["unanswered", "in_progress"]))
            .order_by(Ticket.updated_at.desc(), Ticket.id.desc())
            .first()
        )
    if ticket:
        ticket.status = "in_progress"
        if body.get("assigned_attendant_id"):
            ticket.assigned_attendant_id = int(body["assigned_attendant_id"])
        db.add(ticket)

    _set_human_pause_state(conversation, ticket.id if ticket else None, True)
    db.add(conversation)
    db.commit()
    return {"status": "paused", "conversation": _conversation_payload(conversation, db)}


@app.post("/api/conversations/{conversation_id}/human-message")
async def send_human_message(conversation_id: int, request: Request, db: Session = Depends(get_db)) -> dict[str, Any]:
    conversation = db.get(Conversation, conversation_id)
    if not conversation:
        raise HTTPException(status_code=404, detail="Conversa não encontrada.")
    agent = db.get(Agent, conversation.agent_id)
    if not agent:
        raise HTTPException(status_code=404, detail="Agente não encontrado.")
    body = await request.json()
    message = (body.get("message") or body.get("text") or "").strip()
    if not message:
        raise HTTPException(status_code=400, detail="Informe a mensagem.")

    ticket = None
    ticket_id = body.get("ticket_id")
    if ticket_id:
        ticket = db.get(Ticket, int(ticket_id))
    if not ticket:
        ticket = (
            db.query(Ticket)
            .filter(Ticket.conversation_id == conversation.id, Ticket.status.in_(["unanswered", "in_progress"]))
            .order_by(Ticket.updated_at.desc(), Ticket.id.desc())
            .first()
        )
    if ticket:
        ticket.status = "in_progress"
        if body.get("assigned_attendant_id"):
            ticket.assigned_attendant_id = int(body["assigned_attendant_id"])
        db.add(ticket)

    _set_human_pause_state(conversation, ticket.id if ticket else None, True)
    instance_name = _conversation_instance(conversation, agent)
    if not instance_name:
        raise HTTPException(status_code=400, detail="Instância WhatsApp não encontrada para esta conversa.")

    await send_whatsapp_text_to_instance(instance_name, conversation.customer_phone, message)
    row = append_conversation_message(db, conversation, "human", message)
    row.message_metadata = {"source": "human", "ticket_id": ticket.id if ticket else None}
    db.add(conversation)
    db.add(row)
    db.commit()
    return {"status": "sent", "conversation": _conversation_payload(conversation, db)}


@app.post("/api/conversations/{conversation_id}/resume-agent")
async def resume_agent_for_conversation(conversation_id: int, db: Session = Depends(get_db)) -> dict[str, Any]:
    conversation = db.get(Conversation, conversation_id)
    if not conversation:
        raise HTTPException(status_code=404, detail="Conversa não encontrada.")
    _set_human_pause_state(conversation, None, False)
    tickets = (
        db.query(Ticket)
        .filter(Ticket.conversation_id == conversation.id, Ticket.status.in_(["unanswered", "in_progress"]))
        .all()
    )
    for ticket in tickets:
        ticket.status = "finished"
        ticket.finished_at = ticket.finished_at or datetime.utcnow()
        db.add(ticket)
    archive_conversation_snapshot(
        db,
        conversation,
        "conversation_resumed_handoff_finished",
        {"ticket_ids": [ticket.id for ticket in tickets]},
    )
    db.add(conversation)
    db.commit()
    return {"status": "resumed", "conversation": _conversation_payload(conversation, db)}


@app.get("/api/tools")
def list_tools(agent_id: int | None = None, db: Session = Depends(get_db)) -> list[dict[str, Any]]:
    supported_types = [
        "web_search",
        "knowledge_base",
        "data_table",
        "http_api",
        "python_runtime",
        "code_runtime",
        "ticket_creation",
        "contextual_memory",
        "whatsapp_notify",
    ]
    query = db.query(ToolDefinition).filter(ToolDefinition.tool_type.in_(supported_types))
    if agent_id is not None:
        if not db.get(Agent, agent_id):
            raise HTTPException(status_code=404, detail="Agente não encontrado.")
        query = query.filter(ToolDefinition.agent_id == agent_id)
    items = query.order_by(ToolDefinition.id.desc()).all()
    return [
        {
            "id": item.id,
            "agent_id": item.agent_id,
            "name": clean_display_text(item.name),
            "tool_type": item.tool_type,
            "description": clean_display_text(item.description),
            "enabled": item.enabled,
            "requires_approval": item.requires_approval,
            "config": item.config,
            "created_at": item.created_at.isoformat(),
        }
        for item in items
    ]


@app.post("/api/tools")
async def create_tool(request: Request, db: Session = Depends(get_db)) -> dict[str, Any]:
    body = await request.json()
    name = (body.get("name") or "").strip()
    agent_id = body.get("agent_id")
    if not name:
        raise HTTPException(status_code=400, detail="Informe o nome da ferramenta.")
    if not agent_id or not db.get(Agent, int(agent_id)):
        raise HTTPException(status_code=400, detail="Selecione um agente para esta ferramenta.")
    tool_type = (body.get("tool_type") or "knowledge_base").strip()
    config = body.get("config") or {}
    if tool_type == "whatsapp_notify":
        config = _normalize_whatsapp_notify_config(config)
    item = ToolDefinition(
        agent_id=int(agent_id),
        name=name,
        tool_type=tool_type,
        description=(body.get("description") or "").strip() or None,
        config=config,
        enabled=bool(body.get("enabled", True)),
        requires_approval=bool(body.get("requires_approval", False)),
    )
    if item.tool_type not in {
        "web_search",
        "knowledge_base",
        "data_table",
        "http_api",
        "python_runtime",
        "code_runtime",
        "ticket_creation",
        "contextual_memory",
        "whatsapp_notify",
    }:
        raise HTTPException(status_code=400, detail="Ferramenta indisponível.")
    db.add(item)
    db.commit()
    db.refresh(item)
    return {"id": item.id, "status": "created"}


@app.put("/api/tools/{tool_id}")
async def update_tool(tool_id: int, request: Request, db: Session = Depends(get_db)) -> dict[str, Any]:
    item = db.get(ToolDefinition, tool_id)
    if not item:
        raise HTTPException(status_code=404, detail="Ferramenta não encontrada.")
    body = await request.json()
    if "name" in body and body.get("name"):
        item.name = body["name"].strip()
    if "description" in body:
        item.description = (body.get("description") or "").strip() or None
    if "tool_type" in body and body.get("tool_type"):
        item.tool_type = body["tool_type"].strip()
    if "config" in body:
        config = body.get("config") or {}
        item.config = _normalize_whatsapp_notify_config(config) if item.tool_type == "whatsapp_notify" else config
    if "enabled" in body:
        item.enabled = bool(body.get("enabled"))
    if "requires_approval" in body:
        item.requires_approval = bool(body.get("requires_approval"))
    db.add(item)
    db.commit()
    return {"status": "saved", "id": item.id}


@app.delete("/api/tools/{tool_id}")
def delete_tool(tool_id: int, db: Session = Depends(get_db)) -> dict[str, Any]:
    item = db.get(ToolDefinition, tool_id)
    if not item:
        raise HTTPException(status_code=404, detail="Ferramenta não encontrada.")
    db.delete(item)
    db.commit()
    return {"status": "deleted"}


def _kb_sources_payload(kb_id: int, db: Session) -> list[dict[str, Any]]:
    items = (
        db.query(KnowledgeBaseSource)
        .filter(KnowledgeBaseSource.kb_id == kb_id)
        .order_by(KnowledgeBaseSource.id.asc())
        .all()
    )
    result: list[dict[str, Any]] = []
    for item in items:
        document_ids = [
            row.id
            for row in db.query(KnowledgeBaseDocument.id)
            .filter(KnowledgeBaseDocument.source_id == item.id)
            .all()
        ]
        chunks = (
            db.query(KnowledgeBaseChunk)
            .filter(KnowledgeBaseChunk.document_id.in_(document_ids))
            .all()
            if document_ids
            else []
        )
        result.append(
            {
                "id": item.id,
                "source_type": item.source_type,
                "uri": item.uri,
                "status": item.status,
                "metadata": item.source_metadata or {},
                "document_count": len(document_ids),
                "chunk_count": len(chunks),
                "token_count": sum(chunk.token_count or 0 for chunk in chunks),
                "embedding_count": sum(1 for chunk in chunks if chunk.embedding is not None),
                "last_ingested_at": item.last_ingested_at.isoformat() if item.last_ingested_at else None,
                "created_at": item.created_at.isoformat(),
            }
        )
    return result


def _kb_source_detail_payload(source: KnowledgeBaseSource, db: Session, limit: int = 80) -> dict[str, Any]:
    limit = max(1, min(int(limit or 80), 200))
    documents = (
        db.query(KnowledgeBaseDocument)
        .filter(KnowledgeBaseDocument.source_id == source.id)
        .order_by(KnowledgeBaseDocument.id.asc())
        .all()
    )
    document_ids = [item.id for item in documents]
    total_chunks = (
        db.query(KnowledgeBaseChunk)
        .filter(KnowledgeBaseChunk.document_id.in_(document_ids))
        .count()
        if document_ids
        else 0
    )
    remaining = limit
    document_payloads = []
    for document in documents:
        chunks = (
            db.query(KnowledgeBaseChunk)
            .filter(KnowledgeBaseChunk.document_id == document.id)
            .order_by(KnowledgeBaseChunk.id.asc())
            .limit(remaining)
            .all()
            if remaining > 0
            else []
        )
        remaining -= len(chunks)
        document_payloads.append(
            {
                "id": document.id,
                "title": clean_display_text(document.title),
                "metadata": document.document_metadata or {},
                "created_at": document.created_at.isoformat(),
                "chunks": [
                    {
                        "id": chunk.id,
                        "content": chunk.content,
                        "token_count": chunk.token_count or 0,
                        "metadata": chunk.chunk_metadata or {},
                        "created_at": chunk.created_at.isoformat(),
                    }
                    for chunk in chunks
                ],
            }
        )
    return {
        "id": source.id,
        "kb_id": source.kb_id,
        "source_type": source.source_type,
        "uri": source.uri,
        "status": source.status,
        "metadata": source.source_metadata or {},
        "document_count": len(documents),
        "chunk_count": total_chunks,
        "chunks_returned": sum(len(item["chunks"]) for item in document_payloads),
        "last_ingested_at": source.last_ingested_at.isoformat() if source.last_ingested_at else None,
        "created_at": source.created_at.isoformat(),
        "documents": document_payloads,
    }


@app.get("/api/knowledge-bases/{kb_id}/sources")
def list_kb_sources(kb_id: int, db: Session = Depends(get_db)) -> list[dict[str, Any]]:
    if not db.get(KnowledgeBase, kb_id):
        raise HTTPException(status_code=404, detail="Base não encontrada.")
    return _kb_sources_payload(kb_id, db)


def _kb_test_terms(query: str) -> set[str]:
    stop = {"para", "como", "qual", "quais", "onde", "quando", "porque", "sobre", "isso", "essa", "esse", "com", "sem", "uma", "um", "dos", "das"}
    return {term for term in re.findall(r"[a-zA-Z0-9À-ÿ_]{3,}", (query or "").lower()) if term not in stop}


def _lexical_kb_test_chunks(kb_id: int, query: str, db: Session, limit: int = 5) -> list[dict[str, Any]]:
    terms = _kb_test_terms(query)
    rows_query = (
        db.query(KnowledgeBaseChunk, KnowledgeBaseDocument, KnowledgeBaseSource)
        .join(KnowledgeBaseDocument, KnowledgeBaseDocument.id == KnowledgeBaseChunk.document_id)
        .outerjoin(KnowledgeBaseSource, KnowledgeBaseSource.id == KnowledgeBaseDocument.source_id)
        .filter(KnowledgeBaseDocument.kb_id == kb_id)
    )
    if terms:
        filters = []
        for term in list(terms)[:10]:
            pattern = f"%{term}%"
            filters.extend([
                KnowledgeBaseChunk.content.ilike(pattern),
                KnowledgeBaseDocument.title.ilike(pattern),
                KnowledgeBaseSource.uri.ilike(pattern),
            ])
        rows_query = rows_query.filter(or_(*filters))
    rows = rows_query.order_by(KnowledgeBaseChunk.id.desc()).limit(80).all()
    ranked: list[tuple[float, KnowledgeBaseChunk, KnowledgeBaseDocument, KnowledgeBaseSource | None]] = []
    phrase = " ".join(list(terms)[:4])
    for chunk, document, source in rows:
        content = chunk.content or ""
        haystack = f"{content} {document.title or ''} {source.uri if source else ''}".lower()
        score = sum(haystack.count(term) for term in terms)
        if phrase and phrase in haystack:
            score += 3
        if not terms:
            score = 1
        if score <= 0:
            continue
        ranked.append((float(score), chunk, document, source))
    ranked.sort(key=lambda item: item[0], reverse=True)
    return [
        {
            "type": "knowledge_base",
            "title": clean_display_text(document.title or (source.uri if source else "") or f"Documento {document.id}"),
            "snippet": clean_display_text((chunk.content or "")[:520]),
            "uri": source.uri if source else None,
            "document_id": document.id,
            "chunk_id": chunk.id,
            "score": round(score, 3),
            "retrieval": {"keyword_score": round(score, 3)},
        }
        for score, chunk, document, source in ranked[:limit]
    ]


@app.post("/api/knowledge-bases/{kb_id}/test-query")
async def test_knowledge_base_query(kb_id: int, request: Request, db: Session = Depends(get_db)) -> dict[str, Any]:
    kb = db.get(KnowledgeBase, kb_id)
    if not kb:
        raise HTTPException(status_code=404, detail="Base não encontrada.")
    try:
        body = await request.json()
    except Exception:
        raise HTTPException(status_code=400, detail="Envie um JSON valido.")
    query = sanitize_user_input(str((body or {}).get("query") or (body or {}).get("message") or "").strip())
    if not query:
        raise HTTPException(status_code=400, detail="Informe uma pergunta para testar.")
    if len(query) > 1000:
        raise HTTPException(status_code=400, detail="Pergunta muito longa.")

    context: dict[str, Any]
    if kb.agent_id:
        agent = db.get(Agent, kb.agent_id)
        if agent:
            context = await runtime_kb_context(db, agent, query, kb.name)
        else:
            context = {"sources": _lexical_kb_test_chunks(kb.id, query, db), "retrieval_mode": "keyword_fallback", "confidence": 0.0}
    else:
        context = {"sources": _lexical_kb_test_chunks(kb.id, query, db), "retrieval_mode": "keyword_fallback", "confidence": 0.0}

    chunks = []
    for source in (context.get("sources") or [])[:5]:
        retrieval = source.get("retrieval") or {}
        chunks.append({
            "chunk_id": source.get("chunk_id"),
            "document_id": source.get("document_id"),
            "title": clean_display_text(source.get("title") or "Fonte"),
            "uri": source.get("uri"),
            "snippet": clean_display_text(source.get("snippet") or ""),
            "score": retrieval.get("score", source.get("score")),
            "keyword_score": retrieval.get("keyword_score"),
            "vector_distance": retrieval.get("vector_distance"),
        })
    return {
        "kb_id": kb.id,
        "kb_name": clean_display_text(kb.name),
        "agent_id": kb.agent_id,
        "query": query,
        "retrieval_mode": context.get("retrieval_mode") or "keyword_fallback",
        "confidence": context.get("confidence", 0.0),
        "candidate_count": context.get("candidate_count", len(chunks)),
        "latency_ms": context.get("latency_ms"),
        "chunks": chunks,
        "sources": chunks,
    }


@app.get("/api/agents/{agent_id}/kb-sources")
def list_agent_kb_sources(agent_id: int, db: Session = Depends(get_db)) -> list[dict[str, Any]]:
    if not db.get(Agent, agent_id):
        raise HTTPException(status_code=404, detail="Agente não encontrado.")
    kb = (
        db.query(KnowledgeBase)
        .filter(KnowledgeBase.agent_id == agent_id)
        .order_by(KnowledgeBase.id.asc())
        .first()
    )
    if not kb:
        return []
    return _kb_sources_payload(kb.id, db)


@app.get("/api/agents/{agent_id}/kb-sources/{source_id}")
def get_agent_kb_source(agent_id: int, source_id: int, limit: int = 80, db: Session = Depends(get_db)) -> dict[str, Any]:
    source = db.get(KnowledgeBaseSource, source_id)
    if not source:
        raise HTTPException(status_code=404, detail="Fonte não encontrada.")
    kb = db.get(KnowledgeBase, source.kb_id)
    if not kb or kb.agent_id != agent_id:
        raise HTTPException(status_code=404, detail="Fonte não encontrada para este agente.")
    return _kb_source_detail_payload(source, db, limit)


@app.post("/api/agents/{agent_id}/kb-sources")
async def add_agent_kb_sources(agent_id: int, request: Request, db: Session = Depends(get_db)) -> dict[str, Any]:
    agent = db.get(Agent, agent_id)
    if not agent:
        raise HTTPException(status_code=404, detail="Agente não encontrado.")
    body = await request.json()
    sources = body.get("sources") or []
    if not sources:
        raise HTTPException(status_code=400, detail="Informe ao menos uma fonte.")
    await add_kb_sources(db, agent, sources)
    enable_agent_knowledge_access(db, agent)
    return {"status": "added", "count": len(sources), "knowledge_base_enabled": True}


@app.post("/api/agents/{agent_id}/kb-reindex")
async def reindex_agent_kb(agent_id: int, db: Session = Depends(get_db)) -> dict[str, Any]:
    agent = db.get(Agent, agent_id)
    if not agent:
        raise HTTPException(status_code=404, detail="Agente não encontrado.")

    kb_ids = [item.id for item in db.query(KnowledgeBase.id).filter(KnowledgeBase.agent_id == agent.id).all()]
    if not kb_ids:
        return {"status": "empty", "chunks": 0, "embeddings_generated": 0}

    rows = (
        db.query(KnowledgeBaseChunk, KnowledgeBaseDocument)
        .join(KnowledgeBaseDocument, KnowledgeBaseDocument.id == KnowledgeBaseChunk.document_id)
        .filter(KnowledgeBaseDocument.kb_id.in_(kb_ids))
        .all()
    )
    embeddings_generated = 0
    for index, (chunk, document) in enumerate(rows, start=1):
        metadata = dict(chunk.chunk_metadata or {})
        metadata["keywords"] = metadata.get("keywords") or extract_keywords(chunk.content)
        metadata["source_title"] = metadata.get("source_title") or document.title
        metadata["context_hierarchical"] = metadata.get("context_hierarchical") or ["Knowledge Base", document.title, f"chunk_{index}"]
        metadata["reindexed_at"] = datetime.utcnow().isoformat()
        chunk.token_count = estimate_token_count(chunk.content)
        if chunk.embedding is None:
            embedding = await generate_kb_embedding(agent, chunk.content)
            if embedding:
                chunk.embedding = embedding
                embeddings_generated += 1
                metadata["embedding_status"] = "generated"
                metadata["embedding_model"] = os.getenv("EMBEDDING_MODEL", "text-embedding-3-small")
            else:
                metadata["embedding_status"] = metadata.get("embedding_status") or "not_configured"
        chunk.chunk_metadata = metadata
        db.add(chunk)
        if index % 20 == 0:
            db.commit()
    db.commit()
    return {"status": "reindexed", "chunks": len(rows), "embeddings_generated": embeddings_generated}


def _agent_run_payload(item: AgentRun) -> dict[str, Any]:
    return {
        "id": item.id,
        "conversation_id": item.conversation_id,
        "agent_id": item.agent_id,
        "squad_id": item.squad_id,
        "status": item.status,
        "latency_ms": item.latency_ms,
        "error": item.error,
        "input": item.input or {},
        "output": item.output or {},
        "created_at": item.created_at.isoformat(),
    }


@app.get("/api/agent-runs")
def list_agent_runs(db: Session = Depends(get_db)) -> list[dict[str, Any]]:
    items = db.query(AgentRun).order_by(AgentRun.id.desc()).limit(50).all()
    return [_agent_run_payload(item) for item in items]


@app.get("/api/agents/{agent_id}/runs")
def list_runs_for_agent(agent_id: int, db: Session = Depends(get_db)) -> list[dict[str, Any]]:
    if not db.get(Agent, agent_id):
        raise HTTPException(status_code=404, detail="Agente não encontrado.")
    items = (
        db.query(AgentRun)
        .filter(AgentRun.agent_id == agent_id)
        .order_by(AgentRun.id.desc())
        .limit(100)
        .all()
    )
    return [_agent_run_payload(item) for item in items]


def configured_superadmin_token() -> str:
    return (os.getenv("SUPERADMIN_API_TOKEN") or "").strip()


def superadmin_session_secret(expected_token: str) -> str:
    return (os.getenv("SUPERADMIN_SESSION_SECRET") or os.getenv("SECRET_KEY") or expected_token).strip()


def admin_session_secret() -> str:
    return (
        os.getenv("ADMIN_SESSION_SECRET")
        or os.getenv("SUPERADMIN_SESSION_SECRET")
        or os.getenv("SECRET_KEY")
        or configured_superadmin_token()
        or "system-manager-admin-session"
    ).strip()


def admin_cookie_secure() -> bool:
    raw = os.getenv("ADMIN_COOKIE_SECURE", "").strip().lower()
    if raw in {"1", "true", "yes", "sim"}:
        return True
    if raw in {"0", "false", "no", "nao", "não"}:
        return False
    if os.getenv("VERCEL_ENV", "").strip().lower() == "production":
        return True
    return _url_is_https_public(os.getenv("APP_PUBLIC_BASE_URL") or os.getenv("PUBLIC_CHAT_BASE_URL") or os.getenv("WEBHOOK_BASE_URL"))


def superadmin_session_expiration() -> datetime:
    ttl = int(os.getenv("SUPERADMIN_SESSION_TTL_HOURS", str(SUPERADMIN_SESSION_TTL_HOURS)))
    return datetime.utcnow() + timedelta(hours=max(1, ttl))


def sign_superadmin_session(expires_at: datetime, nonce: str, expected_token: str) -> str:
    token_hash = hashlib.sha256(expected_token.encode("utf-8")).hexdigest()
    payload = f"{int(expires_at.timestamp())}.{nonce}.{token_hash}"
    signature = hmac.new(superadmin_session_secret(expected_token).encode("utf-8"), payload.encode("utf-8"), hashlib.sha256).hexdigest()
    return f"{payload}.{signature}"


def verify_superadmin_session_cookie(raw_cookie: str | None, expected_token: str) -> bool:
    if not raw_cookie:
        return False
    try:
        raw_expires, nonce, token_hash, signature = str(raw_cookie).split(".", 3)
        expires_ts = int(raw_expires)
    except Exception:
        return False
    if expires_ts < int(datetime.utcnow().timestamp()):
        return False
    expected_hash = hashlib.sha256(expected_token.encode("utf-8")).hexdigest()
    if not secrets.compare_digest(token_hash, expected_hash):
        return False
    payload = f"{expires_ts}.{nonce}.{token_hash}"
    expected_signature = hmac.new(superadmin_session_secret(expected_token).encode("utf-8"), payload.encode("utf-8"), hashlib.sha256).hexdigest()
    return secrets.compare_digest(signature, expected_signature)


def set_superadmin_session_cookie(response: Response, expected_token: str) -> None:
    expires_at = superadmin_session_expiration()
    session_value = sign_superadmin_session(expires_at, secrets.token_urlsafe(18), expected_token)
    max_age = max(60, int((expires_at - datetime.utcnow()).total_seconds()))
    response.set_cookie(
        SUPERADMIN_SESSION_COOKIE,
        session_value,
        httponly=True,
        secure=admin_cookie_secure(),
        samesite="lax",
        max_age=max_age,
        path="/",
    )


def clear_superadmin_session_cookie(response: Response) -> None:
    response.delete_cookie(SUPERADMIN_SESSION_COOKIE, path="/")


def sign_admin_session(user: AdminUser, expires_at: datetime, nonce: str) -> str:
    fingerprint = admin_password_fingerprint(user.password_hash)
    payload = f"{user.id}.{int(expires_at.timestamp())}.{nonce}.{fingerprint}"
    signature = hmac.new(admin_session_secret().encode("utf-8"), payload.encode("utf-8"), hashlib.sha256).hexdigest()
    return f"{payload}.{signature}"


def verify_admin_session_cookie(raw_cookie: str | None, db: Session) -> AdminUser | None:
    if not raw_cookie:
        return None
    try:
        raw_user_id, raw_expires, nonce, fingerprint, signature = str(raw_cookie).split(".", 4)
        user_id = int(raw_user_id)
        expires_ts = int(raw_expires)
    except Exception:
        return None
    if expires_ts < int(datetime.utcnow().timestamp()):
        return None
    payload = f"{user_id}.{expires_ts}.{nonce}.{fingerprint}"
    expected_signature = hmac.new(admin_session_secret().encode("utf-8"), payload.encode("utf-8"), hashlib.sha256).hexdigest()
    if not secrets.compare_digest(signature, expected_signature):
        return None
    user = db.get(AdminUser, user_id)
    if not user or user.status != "active" or normalize_admin_role(user.role) not in {"superadmin", "admin"}:
        return None
    if not secrets.compare_digest(fingerprint, admin_password_fingerprint(user.password_hash)):
        return None
    return user


def current_admin_user_from_request(request: Request) -> AdminUser | None:
    session_token = request.cookies.get(ADMIN_SESSION_COOKIE) or ""
    if not session_token:
        return None
    db = SessionLocal()
    try:
        return verify_admin_session_cookie(session_token, db)
    finally:
        db.close()


def set_admin_session_cookie(response: Response, user: AdminUser) -> None:
    expires_at = superadmin_session_expiration()
    session_value = sign_admin_session(user, expires_at, secrets.token_urlsafe(18))
    max_age = max(60, int((expires_at - datetime.utcnow()).total_seconds()))
    response.set_cookie(
        ADMIN_SESSION_COOKIE,
        session_value,
        httponly=True,
        secure=admin_cookie_secure(),
        samesite="lax",
        max_age=max_age,
        path="/",
    )


def clear_admin_session_cookie(response: Response) -> None:
    response.delete_cookie(ADMIN_SESSION_COOKIE, path="/")


def require_superadmin_request(request: Request) -> None:
    expected = configured_superadmin_token()
    bearer = str(request.headers.get("authorization") or "")
    token = bearer.removeprefix("Bearer ").strip() if bearer.lower().startswith("bearer ") else ""
    token = token or str(request.headers.get("x-superadmin-token") or "").strip()
    if expected and token and secrets.compare_digest(token, expected):
        return
    admin_user = current_admin_user_from_request(request)
    if admin_user:
        return
    session_token = request.cookies.get(SUPERADMIN_SESSION_COOKIE) or ""
    if expected and verify_superadmin_session_cookie(session_token, expected):
        return
    raise HTTPException(status_code=401, detail="Usuario administrativo nao autenticado.")


async def request_json_dict(request: Request) -> dict[str, Any]:
    try:
        body = await request.json()
    except Exception:
        body = {}
    if not isinstance(body, dict):
        raise HTTPException(status_code=400, detail="Envie um JSON valido.")
    return body


@app.post("/api/superadmin/auth/login")
async def superadmin_auth_login(request: Request, response: Response) -> dict[str, Any]:
    body = await request_json_dict(request)
    username = normalize_admin_username(body.get("username") or body.get("user") or body.get("name") or "")
    password = str(body.get("password") or "")
    if username:
        db = SessionLocal()
        try:
            user = (
                db.query(AdminUser)
                .filter(func.lower(AdminUser.username) == username.lower(), AdminUser.status == "active")
                .first()
            )
            if not user or normalize_admin_role(user.role) not in {"superadmin", "admin"} or not verify_admin_password(password, user.password_hash):
                raise HTTPException(status_code=401, detail="Usuario ou senha invalidos.")
            user.last_login_at = datetime.utcnow()
            user.updated_at = datetime.utcnow()
            db.commit()
            db.refresh(user)
            set_admin_session_cookie(response, user)
            clear_superadmin_session_cookie(response)
            return {"status": "ok", "user": admin_user_payload(user)}
        finally:
            db.close()

    expected = configured_superadmin_token()
    if not expected:
        raise HTTPException(status_code=503, detail="Nenhum login administrativo configurado.")
    token = str(body.get("token") or body.get("password") or "").strip()
    token = token or str(request.headers.get("x-superadmin-token") or "").strip()
    if not token or not secrets.compare_digest(token, expected):
        raise HTTPException(status_code=401, detail="Usuario ou senha invalidos.")
    set_superadmin_session_cookie(response, expected)
    return {"status": "ok"}


@app.post("/api/superadmin/auth/logout")
def superadmin_auth_logout(response: Response) -> dict[str, str]:
    clear_admin_session_cookie(response)
    clear_superadmin_session_cookie(response)
    return {"status": "logged_out"}


@app.get("/api/superadmin/auth/me")
def superadmin_auth_me(request: Request) -> dict[str, Any]:
    user = current_admin_user_from_request(request)
    if user:
        return {"status": "ok", "user": admin_user_payload(user)}
    expected = configured_superadmin_token()
    session_token = request.cookies.get(SUPERADMIN_SESSION_COOKIE) or ""
    bearer = str(request.headers.get("authorization") or "")
    token = bearer.removeprefix("Bearer ").strip() if bearer.lower().startswith("bearer ") else ""
    token = token or str(request.headers.get("x-superadmin-token") or "").strip()
    if expected and ((token and secrets.compare_digest(token, expected)) or verify_superadmin_session_cookie(session_token, expected)):
        return {"status": "ok", "user": {"id": 0, "username": "SUPERADMIN", "display_name": "SUPERADMIN", "role": "superadmin", "status": "active", "legacy": True}}
    raise HTTPException(status_code=401, detail="Usuario administrativo nao autenticado.")


def _active_superadmin_count(db: Session) -> int:
    return (
        db.query(AdminUser)
        .filter(AdminUser.status == "active", AdminUser.role == "superadmin")
        .count()
    )


def _ensure_not_last_superadmin(db: Session, user: AdminUser, next_role: str | None = None, next_status: str | None = None) -> None:
    role = normalize_admin_role(next_role if next_role is not None else user.role)
    status = str(next_status if next_status is not None else user.status or "active").strip().lower()
    if user.role == "superadmin" and user.status == "active" and (role != "superadmin" or status != "active") and _active_superadmin_count(db) <= 1:
        raise HTTPException(status_code=400, detail="Nao e possivel remover o ultimo superadmin ativo.")


@app.get("/api/superadmin/users")
def list_admin_users(request: Request, db: Session = Depends(get_db)) -> list[dict[str, Any]]:
    require_superadmin_request(request)
    items = db.query(AdminUser).order_by(AdminUser.id.asc()).all()
    return [admin_user_payload(item) for item in items]


@app.post("/api/superadmin/users")
async def create_admin_user(request: Request, db: Session = Depends(get_db)) -> dict[str, Any]:
    require_superadmin_request(request)
    body = await request_json_dict(request)
    username = normalize_admin_username(body.get("username"))
    if not username:
        raise HTTPException(status_code=400, detail="Informe o usuario.")
    if db.query(AdminUser).filter(func.lower(AdminUser.username) == username.lower()).first():
        raise HTTPException(status_code=409, detail="Usuario ja existe.")
    password = str(body.get("password") or "")
    try:
        password_hash = hash_admin_password(password)
    except ValueError as exc:
        raise HTTPException(status_code=400, detail=str(exc))
    user = AdminUser(
        username=username,
        display_name=clean_portal_text(body.get("display_name") or username, 160),
        password_hash=password_hash,
        role=normalize_admin_role(body.get("role") or "operator"),
        status=str(body.get("status") or "active").strip().lower() or "active",
    )
    db.add(user)
    db.commit()
    db.refresh(user)
    return admin_user_payload(user)


@app.put("/api/superadmin/users/{user_id}")
async def update_admin_user(user_id: int, request: Request, db: Session = Depends(get_db)) -> dict[str, Any]:
    require_superadmin_request(request)
    body = await request_json_dict(request)
    user = db.get(AdminUser, user_id)
    if not user:
        raise HTTPException(status_code=404, detail="Usuario nao encontrado.")
    next_role = normalize_admin_role(body.get("role")) if "role" in body else user.role
    next_status = str(body.get("status") or user.status or "active").strip().lower()
    _ensure_not_last_superadmin(db, user, next_role, next_status)
    if "username" in body:
        username = normalize_admin_username(body.get("username"))
        if not username:
            raise HTTPException(status_code=400, detail="Informe o usuario.")
        existing = db.query(AdminUser).filter(func.lower(AdminUser.username) == username.lower(), AdminUser.id != user.id).first()
        if existing:
            raise HTTPException(status_code=409, detail="Usuario ja existe.")
        user.username = username
    if "display_name" in body:
        user.display_name = clean_portal_text(body.get("display_name") or user.username, 160)
    if "role" in body:
        user.role = next_role
    if "status" in body:
        user.status = next_status
    if "password" in body and str(body.get("password") or ""):
        try:
            user.password_hash = hash_admin_password(str(body.get("password") or ""))
        except ValueError as exc:
            raise HTTPException(status_code=400, detail=str(exc))
    user.updated_at = datetime.utcnow()
    db.commit()
    db.refresh(user)
    return admin_user_payload(user)


def portal_internal_email(org_slug: str, access_username: str) -> str:
    local = normalize_portal_username(f"{org_slug}.{access_username}").replace("@", ".")
    local = re.sub(r"[^a-z0-9_.-]+", "-", local).strip(".-") or secrets.token_hex(8)
    return f"{local[:180]}@client-portal.local"


def ensure_client_access_username(
    db: Session,
    organization_id: int,
    value: Any,
    *,
    exclude_membership_id: int | None = None,
) -> str:
    username = normalize_portal_username(value)
    if not username:
        raise HTTPException(status_code=400, detail="Informe o nome de acesso.")
    query = db.query(ClientPortalMembership).filter(
        ClientPortalMembership.organization_id == organization_id,
        ClientPortalMembership.access_username == username,
    )
    if exclude_membership_id:
        query = query.filter(ClientPortalMembership.id != exclude_membership_id)
    if query.first():
        raise HTTPException(status_code=409, detail="Nome de acesso ja existe para este cliente.")
    return username


def available_client_access_username(db: Session, organization_id: int, value: Any, fallback: str = "usuario") -> str:
    base = normalize_portal_username(value) or fallback
    candidate = base
    suffix = 2
    while db.query(ClientPortalMembership).filter(
        ClientPortalMembership.organization_id == organization_id,
        ClientPortalMembership.access_username == candidate,
    ).first():
        suffix_text = f"-{suffix}"
        candidate = f"{base[:120 - len(suffix_text)].rstrip('.-')}{suffix_text}"
        suffix += 1
    return candidate


def client_organization_admin_payload(org: ClientOrganization, request: Request, db: Session) -> dict[str, Any]:
    payload = organization_payload(org)
    payload["portal_url"] = f"{_request_base_url(request)}/clientes/{org.slug}"
    accesses = (
        db.query(ClientPortalAgentAccess, Agent)
        .join(Agent, Agent.id == ClientPortalAgentAccess.agent_id)
        .filter(ClientPortalAgentAccess.organization_id == org.id)
        .order_by(Agent.name.asc())
        .all()
    )
    memberships = (
        db.query(ClientPortalMembership, ClientPortalUser)
        .join(ClientPortalUser, ClientPortalUser.id == ClientPortalMembership.user_id)
        .filter(ClientPortalMembership.organization_id == org.id)
        .order_by(ClientPortalMembership.access_username.asc(), ClientPortalUser.name.asc())
        .all()
    )
    invites = (
        db.query(ClientPortalInvite)
        .filter(ClientPortalInvite.organization_id == org.id)
        .order_by(ClientPortalInvite.id.desc())
        .limit(20)
        .all()
    )
    payload.update(
        {
            "agents": [portal_agent_payload(agent, access) for access, agent in accesses],
            "users": [user_payload(user, membership) for membership, user in memberships],
            "invites": [
                {
                    "id": invite.id,
                    "email": invite.email,
                    "name": clean_portal_text(invite.name, 160),
                    "role": invite.role,
                    "status": invite.status,
                    "expires_at": invite.expires_at.isoformat(),
                    "accepted_at": invite.accepted_at.isoformat() if invite.accepted_at else None,
                    "revoked_at": invite.revoked_at.isoformat() if invite.revoked_at else None,
                    "created_at": invite.created_at.isoformat(),
                }
                for invite in invites
            ],
        }
    )
    return payload


def create_portal_session(db: Session, org: ClientOrganization, user: ClientPortalUser, request: Request) -> tuple[ClientPortalSession, str]:
    token = issue_token()
    session = ClientPortalSession(
        organization_id=org.id,
        user_id=user.id,
        token_hash=hash_token(token),
        ip_hash=request_fingerprint(request.client.host if request.client else ""),
        user_agent_hash=request_fingerprint(request.headers.get("user-agent")),
        expires_at=session_expiration(),
        last_seen_at=datetime.utcnow(),
    )
    db.add(session)
    user.last_login_at = datetime.utcnow()
    db.add(user)
    log_client_portal_audit(db, org.id, "auth.login", user_id=user.id, payload={"method": "password_or_invite"})
    db.commit()
    db.refresh(session)
    return session, token


def client_portal_cookie_should_be_secure(request: Request) -> bool:
    forwarded_proto = (request.headers.get("x-forwarded-proto") or "").split(",", 1)[0].strip().lower()
    if forwarded_proto == "https" or request.url.scheme == "https":
        return client_portal_cookie_secure()
    return False


def set_client_portal_cookie(response: Response, token: str, session: ClientPortalSession, request: Request) -> None:
    max_age = max(60, int((session.expires_at - datetime.utcnow()).total_seconds()))
    response.set_cookie(
        PORTAL_SESSION_COOKIE,
        token,
        httponly=True,
        secure=client_portal_cookie_should_be_secure(request),
        samesite="lax",
        max_age=max_age,
        path="/",
    )


def clear_client_portal_cookie(response: Response) -> None:
    response.delete_cookie(PORTAL_SESSION_COOKIE, path="/")


def get_client_portal_context(request: Request, db: Session = Depends(get_db)) -> ClientPortalContext:
    token = request.cookies.get(PORTAL_SESSION_COOKIE) or ""
    bearer = str(request.headers.get("authorization") or "")
    if not token and bearer.lower().startswith("bearer "):
        token = bearer.removeprefix("Bearer ").strip()
    if not token:
        raise HTTPException(status_code=401, detail="Sessao do portal nao encontrada.")
    session = (
        db.query(ClientPortalSession)
        .filter(
            ClientPortalSession.token_hash == hash_token(token),
            ClientPortalSession.status == "active",
            ClientPortalSession.expires_at > datetime.utcnow(),
            ClientPortalSession.revoked_at.is_(None),
        )
        .first()
    )
    if not session:
        raise HTTPException(status_code=401, detail="Sessao do portal expirada ou revogada.")
    org = db.get(ClientOrganization, session.organization_id)
    user = db.get(ClientPortalUser, session.user_id)
    membership = (
        db.query(ClientPortalMembership)
        .filter(
            ClientPortalMembership.organization_id == session.organization_id,
            ClientPortalMembership.user_id == session.user_id,
            ClientPortalMembership.status == "active",
        )
        .first()
    )
    if not org or org.status != "active" or not user or user.status != "active" or not membership:
        raise HTTPException(status_code=403, detail="Acesso do portal desativado.")
    session.last_seen_at = datetime.utcnow()
    db.add(session)
    db.commit()
    return ClientPortalContext(organization=org, user=user, membership=membership, session=session)


def require_client_agent_access(db: Session, ctx: ClientPortalContext, agent_id: int, permission: str) -> ClientPortalAgentAccess:
    access = active_agent_access(db, ctx.organization.id, agent_id)
    if access and ctx.membership.role == "superadmin":
        access.permissions = superadmin_permissions()
        return access
    if not access or not permission_enabled(access.permissions, permission):
        raise HTTPException(status_code=403, detail="Permissao negada para este agente no portal.")
    return access


def portal_agent_payload_for_context(agent: Agent, access: ClientPortalAgentAccess, ctx: ClientPortalContext) -> dict[str, Any]:
    payload = portal_agent_payload(agent, access)
    if ctx.membership.role == "superadmin":
        payload["permissions"] = superadmin_permissions()
    return payload


def portal_ticket_payload(ticket: Ticket, db: Session) -> dict[str, Any]:
    raw = ticket_payload(ticket, db)
    return {
        "id": raw.get("id"),
        "conversation_id": raw.get("conversation_id"),
        "agent_id": raw.get("agent_id"),
        "agent_name": clean_portal_text(raw.get("agent_name"), 160),
        "customer_phone": clean_portal_text(raw.get("customer_phone"), 80),
        "team_id": raw.get("team_id"),
        "team_name": clean_portal_text(raw.get("team_name"), 160),
        "assigned_attendant_id": raw.get("assigned_attendant_id"),
        "assigned_attendant_name": clean_portal_text(raw.get("assigned_attendant_name"), 160),
        "status": raw.get("status"),
        "summary": clean_portal_text(raw.get("summary"), 2000),
        "tags": redact_portal_value(raw.get("tags") or []),
        "metadata": redact_portal_value(raw.get("metadata") or {}),
        "created_at": raw.get("created_at"),
        "updated_at": raw.get("updated_at"),
        "finished_at": raw.get("finished_at"),
    }


def portal_conversation_summary_payload(conversation: Conversation, db: Session) -> dict[str, Any]:
    state = conversation.state or {}
    open_ticket = (
        db.query(Ticket)
        .filter(Ticket.conversation_id == conversation.id, Ticket.status.in_(["unanswered", "in_progress"]))
        .order_by(Ticket.updated_at.desc(), Ticket.id.desc())
        .first()
    )
    return {
        "id": conversation.id,
        "agent_id": conversation.agent_id,
        "customer_name": clean_portal_text(state.get("customer_name"), 160) if isinstance(state, dict) else "",
        "customer_phone": clean_portal_text(conversation.customer_phone, 80),
        "channel": conversation_channel(conversation),
        "source": clean_portal_text(state.get("source"), 80) if isinstance(state, dict) else "",
        "messages": len(conversation.history or []),
        "preview": clean_portal_text("\n".join(f"{m.get('role')}: {m.get('content')}" for m in (conversation.history or [])[-4:]), 1200),
        "ticket": portal_ticket_payload(open_ticket, db) if open_ticket else None,
        "created_at": conversation.created_at.isoformat(),
        "updated_at": conversation.updated_at.isoformat(),
    }


def portal_conversation_detail_payload(conversation: Conversation, db: Session) -> dict[str, Any]:
    payload = portal_conversation_summary_payload(conversation, db)
    state = conversation.state or {}
    handoff = state.get("human_handoff") if isinstance(state.get("human_handoff"), dict) else {}
    payload.update(
        {
            "handoff": {
                "requested": bool(handoff.get("requested")),
                "paused": bool(handoff.get("paused")),
                "ticket_id": handoff.get("ticket_id"),
                "ticket_status": clean_portal_text(handoff.get("ticket_status"), 80),
                "updated_at": handoff.get("updated_at"),
            },
            "messages": [
                {
                    "id": message.id,
                    "role": message.role,
                    "content": clean_portal_text(message.content, 4000),
                    "created_at": message.created_at.isoformat(),
                }
                for message in db.query(Message).filter(Message.conversation_id == conversation.id).order_by(Message.id.asc()).all()
            ],
        }
    )
    return payload


def portal_tool_payload(tool: ToolDefinition) -> dict[str, Any]:
    blocked = tool.tool_type in CLIENT_PORTAL_BLOCKED_TOOL_TYPES
    executable = tool.enabled and tool.tool_type in CLIENT_PORTAL_EXECUTABLE_TOOL_TYPES and not blocked
    return {
        "id": tool.id,
        "agent_id": tool.agent_id,
        "name": clean_portal_text(tool.name, 160),
        "tool_type": tool.tool_type,
        "description": clean_portal_text(tool.description, 1000),
        "enabled": bool(tool.enabled),
        "requires_approval": bool(tool.requires_approval),
        "executable_in_portal": bool(executable),
        "blocked_in_portal": bool(blocked),
        "config_summary": redact_portal_value(tool.config or {}),
        "created_at": tool.created_at.isoformat(),
    }


def client_portal_invite_url(request: Request, org: ClientOrganization, token: str) -> str:
    return f"{_request_base_url(request)}/clientes/{org.slug}/convite/{token}"


@app.get("/clientes/{client_slug}")
@app.get("/clientes/{client_slug}/login")
@app.get("/clientes/{client_slug}/convite/{invite_token}")
@app.get("/cliente/{client_slug}")
@app.get("/cliente/{client_slug}/login")
@app.get("/cliente/{client_slug}/convite/{invite_token}")
def client_portal_page(client_slug: str, invite_token: str | None = None) -> FileResponse:
    return FileResponse(STATIC_DIR / "client-portal.html", headers=HTML_NO_STORE_HEADERS)


@app.get("/llms/agent-builder.md")
def llm_agent_builder_docs(request: Request) -> Response:
    require_superadmin_request(request)
    return Response(
        (RESOURCES_DIR / "llm-agent-docs.md").read_text(encoding="utf-8"),
        media_type="text/markdown; charset=utf-8",
        headers=HTML_NO_STORE_HEADERS,
    )


@app.get("/api/superadmin/client-organizations")
def list_client_organizations(request: Request, db: Session = Depends(get_db)) -> list[dict[str, Any]]:
    require_superadmin_request(request)
    items = db.query(ClientOrganization).order_by(ClientOrganization.id.desc()).all()
    return [client_organization_admin_payload(item, request, db) for item in items]


@app.post("/api/superadmin/client-organizations")
async def create_client_organization(request: Request, db: Session = Depends(get_db)) -> dict[str, Any]:
    require_superadmin_request(request)
    body = await request_json_dict(request)
    name = clean_portal_text(body.get("name"), 160)
    if not name:
        raise HTTPException(status_code=400, detail="Informe o nome do cliente.")
    slug = normalize_client_slug(body.get("slug") or name)
    if db.query(ClientOrganization).filter(ClientOrganization.slug == slug).first():
        raise HTTPException(status_code=409, detail="Slug do cliente ja esta em uso.")
    access_username = normalize_portal_username(body.get("access_username") or body.get("username"))
    if not access_username:
        raise HTTPException(status_code=400, detail="Informe o nome de acesso.")
    try:
        password_hash_value = hash_password(str(body.get("password") or ""))
    except ValueError as exc:
        raise HTTPException(status_code=400, detail=str(exc)) from exc
    org = ClientOrganization(
        name=name,
        slug=slug,
        status=clean_portal_text(body.get("status"), 40) or "active",
        logo_url=clean_portal_text(body.get("logo_url"), 600) or None,
        custom_domain=clean_portal_text(body.get("custom_domain"), 255) or None,
        brand_config=body.get("brand_config") if isinstance(body.get("brand_config"), dict) else {},
        guide_config=body.get("guide_config") if isinstance(body.get("guide_config"), dict) else {},
    )
    db.add(org)
    db.flush()
    access_username = ensure_client_access_username(db, org.id, access_username)
    user = ClientPortalUser(
        email=portal_internal_email(slug, access_username),
        name=clean_portal_text(body.get("user_name") or body.get("display_name") or name, 160) or access_username,
        password_hash=password_hash_value,
        status="active",
    )
    db.add(user)
    db.flush()
    membership = ClientPortalMembership(
        organization_id=org.id,
        user_id=user.id,
        access_username=access_username,
        role=clean_portal_text(body.get("role"), 60) or "owner",
        status="active",
    )
    db.add(membership)
    log_client_portal_audit(
        db,
        org.id,
        "superadmin.organization_created",
        payload={"name": name, "slug": slug, "access_username": access_username},
    )
    db.commit()
    db.refresh(org)
    return client_organization_admin_payload(org, request, db)


@app.put("/api/superadmin/client-organizations/{organization_id}")
async def update_client_organization(organization_id: int, request: Request, db: Session = Depends(get_db)) -> dict[str, Any]:
    require_superadmin_request(request)
    org = db.get(ClientOrganization, organization_id)
    if not org:
        raise HTTPException(status_code=404, detail="Cliente nao encontrado.")
    body = await request_json_dict(request)
    if "name" in body and clean_portal_text(body.get("name"), 160):
        org.name = clean_portal_text(body.get("name"), 160)
    if "slug" in body:
        slug = normalize_client_slug(body.get("slug") or org.name)
        existing = db.query(ClientOrganization).filter(ClientOrganization.slug == slug, ClientOrganization.id != org.id).first()
        if existing:
            raise HTTPException(status_code=409, detail="Slug do cliente ja esta em uso.")
        org.slug = slug
    if "status" in body:
        org.status = clean_portal_text(body.get("status"), 40) or "active"
    if "logo_url" in body:
        org.logo_url = clean_portal_text(body.get("logo_url"), 600) or None
    if "custom_domain" in body:
        org.custom_domain = clean_portal_text(body.get("custom_domain"), 255) or None
    if isinstance(body.get("brand_config"), dict):
        org.brand_config = body["brand_config"]
    if isinstance(body.get("guide_config"), dict):
        org.guide_config = body["guide_config"]
    db.add(org)
    log_client_portal_audit(db, org.id, "superadmin.organization_updated", payload={"fields": list(body.keys())})
    db.commit()
    return client_organization_admin_payload(org, request, db)


@app.put("/api/superadmin/client-organizations/{organization_id}/users/{user_id}")
async def update_client_portal_user(
    organization_id: int,
    user_id: int,
    request: Request,
    db: Session = Depends(get_db),
) -> dict[str, Any]:
    require_superadmin_request(request)
    org = db.get(ClientOrganization, organization_id)
    user = db.get(ClientPortalUser, user_id)
    membership = (
        db.query(ClientPortalMembership)
        .filter(ClientPortalMembership.organization_id == organization_id, ClientPortalMembership.user_id == user_id)
        .first()
    )
    if not org or not user or not membership:
        raise HTTPException(status_code=404, detail="Usuario do portal nao encontrado.")
    body = await request_json_dict(request)
    if "access_username" in body:
        membership.access_username = ensure_client_access_username(
            db,
            org.id,
            body.get("access_username"),
            exclude_membership_id=membership.id,
        )
    if "name" in body:
        name = clean_portal_text(body.get("name") or user.name, 160)
        if name:
            user.name = name
    if "role" in body:
        membership.role = clean_portal_text(body.get("role"), 60) or membership.role or "operator"
    if "status" in body:
        status = clean_portal_text(body.get("status"), 40).lower() or "active"
        membership.status = status
        user.status = status
    if str(body.get("password") or ""):
        try:
            user.password_hash = hash_password(str(body.get("password") or ""))
        except ValueError as exc:
            raise HTTPException(status_code=400, detail=str(exc)) from exc
    db.add(user)
    db.add(membership)
    log_client_portal_audit(db, org.id, "superadmin.user_updated", user_id=user.id, payload={"fields": list(body.keys())})
    db.commit()
    db.refresh(user)
    db.refresh(membership)
    return user_payload(user, membership)


@app.post("/api/superadmin/client-organizations/{organization_id}/invites")
async def create_client_portal_invite(organization_id: int, request: Request, db: Session = Depends(get_db)) -> dict[str, Any]:
    require_superadmin_request(request)
    org = db.get(ClientOrganization, organization_id)
    if not org:
        raise HTTPException(status_code=404, detail="Cliente nao encontrado.")
    body = await request_json_dict(request)
    email = normalize_email(body.get("email"))
    if "@" not in email:
        raise HTTPException(status_code=400, detail="Informe um e-mail valido.")
    token = issue_token()
    invite = ClientPortalInvite(
        organization_id=org.id,
        email=email,
        name=clean_portal_text(body.get("name"), 160) or None,
        role=clean_portal_text(body.get("role"), 60) or "operator",
        token_hash=hash_token(token),
        expires_at=invite_expiration(),
        created_by="superadmin",
    )
    db.add(invite)
    log_client_portal_audit(db, org.id, "superadmin.invite_created", payload={"email": email, "role": invite.role})
    db.commit()
    db.refresh(invite)
    return {
        "id": invite.id,
        "email": invite.email,
        "role": invite.role,
        "status": invite.status,
        "expires_at": invite.expires_at.isoformat(),
        "invite_url": client_portal_invite_url(request, org, token),
    }


@app.post("/api/superadmin/client-organizations/{organization_id}/agents")
async def link_client_portal_agent(organization_id: int, request: Request, db: Session = Depends(get_db)) -> dict[str, Any]:
    require_superadmin_request(request)
    org = db.get(ClientOrganization, organization_id)
    if not org:
        raise HTTPException(status_code=404, detail="Cliente nao encontrado.")
    body = await request_json_dict(request)
    agent_id = int(body.get("agent_id") or 0)
    agent = db.get(Agent, agent_id)
    if not agent:
        raise HTTPException(status_code=404, detail="Agente nao encontrado.")
    access = (
        db.query(ClientPortalAgentAccess)
        .filter(ClientPortalAgentAccess.organization_id == org.id, ClientPortalAgentAccess.agent_id == agent.id)
        .first()
    )
    if not access:
        access = ClientPortalAgentAccess(organization_id=org.id, agent_id=agent.id)
    access.status = clean_portal_text(body.get("status"), 40) or "active"
    access.permissions = normalize_permissions(body.get("permissions"))
    db.add(access)
    log_client_portal_audit(db, org.id, "superadmin.agent_linked", agent_id=agent.id, payload={"permissions": access.permissions})
    db.commit()
    db.refresh(access)
    return portal_agent_payload(agent, access)


@app.put("/api/superadmin/client-organizations/{organization_id}/agents/{agent_id}/permissions")
async def update_client_portal_agent_permissions(organization_id: int, agent_id: int, request: Request, db: Session = Depends(get_db)) -> dict[str, Any]:
    require_superadmin_request(request)
    org = db.get(ClientOrganization, organization_id)
    agent = db.get(Agent, agent_id)
    if not org or not agent:
        raise HTTPException(status_code=404, detail="Cliente ou agente nao encontrado.")
    access = (
        db.query(ClientPortalAgentAccess)
        .filter(ClientPortalAgentAccess.organization_id == org.id, ClientPortalAgentAccess.agent_id == agent.id)
        .first()
    )
    if not access:
        access = ClientPortalAgentAccess(organization_id=org.id, agent_id=agent.id)
    body = await request_json_dict(request)
    if "permissions" in body:
        access.permissions = normalize_permissions(body.get("permissions"))
    if "status" in body:
        access.status = clean_portal_text(body.get("status"), 40) or "active"
    db.add(access)
    log_client_portal_audit(db, org.id, "superadmin.agent_permissions_updated", agent_id=agent.id, payload={"permissions": access.permissions, "status": access.status})
    db.commit()
    db.refresh(access)
    return portal_agent_payload(agent, access)


@app.post("/api/superadmin/client-organizations/{organization_id}/agents/{agent_id}/targets")
async def upsert_client_portal_allowed_target(organization_id: int, agent_id: int, request: Request, db: Session = Depends(get_db)) -> dict[str, Any]:
    require_superadmin_request(request)
    org = db.get(ClientOrganization, organization_id)
    agent = db.get(Agent, agent_id)
    if not org or not agent:
        raise HTTPException(status_code=404, detail="Cliente ou agente nao encontrado.")
    body = await request_json_dict(request)
    target_id = normalize_whatsapp_target_id(body.get("target_id"), body.get("target_kind") or "contact")
    if not target_id:
        raise HTTPException(status_code=400, detail="Informe um alvo valido.")
    target = (
        db.query(ClientPortalAllowedTarget)
        .filter(
            ClientPortalAllowedTarget.organization_id == org.id,
            ClientPortalAllowedTarget.agent_id == agent.id,
            ClientPortalAllowedTarget.target_id == target_id,
        )
        .first()
    )
    if not target:
        target = ClientPortalAllowedTarget(organization_id=org.id, agent_id=agent.id, target_id=target_id)
    target.target_kind = "group" if is_group_jid(target_id) or str(body.get("target_kind") or "").lower() == "group" else "contact"
    target.label = clean_portal_text(body.get("label"), 160) or target_id
    target.status = clean_portal_text(body.get("status"), 40) or "active"
    db.add(target)
    log_client_portal_audit(db, org.id, "superadmin.allowed_target_upserted", agent_id=agent.id, payload={"target_id": target_id, "target_kind": target.target_kind})
    db.commit()
    db.refresh(target)
    return {"id": target.id, "target_id": target.target_id, "target_kind": target.target_kind, "label": target.label, "status": target.status}


@app.post("/api/client-portal/invites/{token}/accept")
async def accept_client_portal_invite(token: str, request: Request, response: Response, db: Session = Depends(get_db)) -> dict[str, Any]:
    body = await request_json_dict(request)
    invite = (
        db.query(ClientPortalInvite)
        .filter(
            ClientPortalInvite.token_hash == hash_token(token),
            ClientPortalInvite.status == "pending",
            ClientPortalInvite.expires_at > datetime.utcnow(),
            ClientPortalInvite.accepted_at.is_(None),
            ClientPortalInvite.revoked_at.is_(None),
        )
        .first()
    )
    if not invite:
        raise HTTPException(status_code=404, detail="Convite nao encontrado, expirado ou ja usado.")
    org = db.get(ClientOrganization, invite.organization_id)
    if not org or org.status != "active":
        raise HTTPException(status_code=403, detail="Cliente desativado.")
    try:
        password_hash_value = hash_password(str(body.get("password") or ""))
    except ValueError as exc:
        raise HTTPException(status_code=400, detail=str(exc)) from exc
    email = normalize_email(invite.email)
    user = db.query(ClientPortalUser).filter(ClientPortalUser.email == email).first()
    if not user:
        user = ClientPortalUser(email=email, name=clean_portal_text(body.get("name") or invite.name or email.split("@")[0], 160))
    else:
        user.name = clean_portal_text(body.get("name") or invite.name or user.name, 160)
    user.password_hash = password_hash_value
    user.status = "active"
    db.add(user)
    db.flush()
    membership = (
        db.query(ClientPortalMembership)
        .filter(ClientPortalMembership.organization_id == org.id, ClientPortalMembership.user_id == user.id)
        .first()
    )
    if not membership:
        membership = ClientPortalMembership(organization_id=org.id, user_id=user.id)
    if not membership.access_username:
        membership.access_username = available_client_access_username(
            db,
            org.id,
            body.get("access_username") or body.get("username") or email.split("@")[0] or user.name,
        )
    membership.role = invite.role or "operator"
    membership.status = "active"
    db.add(membership)
    invite.status = "accepted"
    invite.accepted_at = datetime.utcnow()
    db.add(invite)
    db.flush()
    session, session_token = create_portal_session(db, org, user, request)
    set_client_portal_cookie(response, session_token, session, request)
    return {"status": "accepted", "organization": organization_payload(org), "user": user_payload(user, membership)}


@app.post("/api/client-portal/auth/login")
async def client_portal_login(request: Request, response: Response, db: Session = Depends(get_db)) -> dict[str, Any]:
    body = await request_json_dict(request)
    org = db.query(ClientOrganization).filter(ClientOrganization.slug == normalize_client_slug(body.get("client_slug") or body.get("organization_slug") or "")).first()
    if not org or org.status != "active":
        raise HTTPException(status_code=401, detail="Cliente ou credenciais invalidos.")
    access_username = normalize_portal_username(body.get("username") or body.get("access_username"))
    if not access_username:
        raise HTTPException(status_code=401, detail="Cliente ou credenciais invalidos.")
    membership, user = (
        db.query(ClientPortalMembership, ClientPortalUser)
        .join(ClientPortalUser, ClientPortalUser.id == ClientPortalMembership.user_id)
        .filter(
            ClientPortalMembership.organization_id == org.id,
            ClientPortalMembership.access_username == access_username,
            ClientPortalMembership.status == "active",
            ClientPortalUser.status == "active",
        )
        .first()
        or (None, None)
    )
    if not user or not verify_password(str(body.get("password") or ""), user.password_hash):
        raise HTTPException(status_code=401, detail="Cliente ou credenciais invalidos.")
    session, token = create_portal_session(db, org, user, request)
    set_client_portal_cookie(response, token, session, request)
    return {"status": "ok", "organization": organization_payload(org), "user": user_payload(user, membership)}


@app.post("/api/client-portal/auth/superadmin-login")
async def client_portal_superadmin_login(request: Request, response: Response, db: Session = Depends(get_db)) -> dict[str, Any]:
    require_superadmin_request(request)
    body = await request_json_dict(request)
    org = db.query(ClientOrganization).filter(ClientOrganization.slug == normalize_client_slug(body.get("client_slug") or body.get("organization_slug") or "")).first()
    if not org or org.status != "active":
        raise HTTPException(status_code=404, detail="Cliente nao encontrado.")
    admin_user = current_admin_user_from_request(request)
    base_username = normalize_portal_username(f"superadmin-{admin_user.id if admin_user else 'legacy'}")
    membership = (
        db.query(ClientPortalMembership)
        .filter(ClientPortalMembership.organization_id == org.id, ClientPortalMembership.access_username == base_username)
        .first()
    )
    access_username = membership.access_username if membership else available_client_access_username(db, org.id, base_username, "superadmin")
    email = portal_internal_email(org.slug, f"{access_username}.system-superadmin")
    user = db.get(ClientPortalUser, membership.user_id) if membership else db.query(ClientPortalUser).filter(ClientPortalUser.email == email).first()
    if not user:
        user = ClientPortalUser(
            email=email,
            name=admin_user.display_name if admin_user else "SUPERADMIN",
            password_hash=None,
            status="active",
        )
        db.add(user)
        db.flush()
    membership = membership or db.query(ClientPortalMembership).filter(ClientPortalMembership.organization_id == org.id, ClientPortalMembership.user_id == user.id).first()
    if not membership:
        membership = ClientPortalMembership(
            organization_id=org.id,
            user_id=user.id,
            access_username=access_username,
            role="superadmin",
            status="active",
        )
    membership.role = "superadmin"
    membership.status = "active"
    db.add(membership)
    db.flush()
    session, token = create_portal_session(db, org, user, request)
    log_client_portal_audit(db, org.id, "auth.superadmin_portal_login", user_id=user.id)
    db.commit()
    set_client_portal_cookie(response, token, session, request)
    return {"status": "ok", "organization": organization_payload(org), "user": user_payload(user, membership)}


@app.post("/api/client-portal/auth/logout")
async def client_portal_logout(response: Response, ctx: ClientPortalContext = Depends(get_client_portal_context), db: Session = Depends(get_db)) -> dict[str, Any]:
    session = db.get(ClientPortalSession, ctx.session.id)
    if session:
        session.status = "revoked"
        session.revoked_at = datetime.utcnow()
        db.add(session)
    log_client_portal_audit(db, ctx.organization.id, "auth.logout", user_id=ctx.user.id)
    db.commit()
    clear_client_portal_cookie(response)
    return {"status": "logged_out"}


@app.get("/api/client-portal/me")
def client_portal_me(ctx: ClientPortalContext = Depends(get_client_portal_context), db: Session = Depends(get_db)) -> dict[str, Any]:
    accesses = active_client_access_query(db, ctx.organization.id).all()
    agents_by_id = {agent.id: agent for agent in db.query(Agent).filter(Agent.id.in_([access.agent_id for access in accesses] or [0])).all()}
    menus = PORTAL_MENU_PERMISSIONS if ctx.membership.role == "superadmin" else visible_menus(accesses)
    return {
        "organization": organization_payload(ctx.organization),
        "user": user_payload(ctx.user, ctx.membership),
        "menus": menus,
        "agents": [portal_agent_payload_for_context(agents_by_id[access.agent_id], access, ctx) for access in accesses if access.agent_id in agents_by_id],
    }


@app.get("/api/client-portal/agents")
def client_portal_agents(ctx: ClientPortalContext = Depends(get_client_portal_context), db: Session = Depends(get_db)) -> list[dict[str, Any]]:
    accesses = active_client_access_query(db, ctx.organization.id).all()
    agents_by_id = {agent.id: agent for agent in db.query(Agent).filter(Agent.id.in_([access.agent_id for access in accesses] or [0])).all()}
    return [portal_agent_payload_for_context(agents_by_id[access.agent_id], access, ctx) for access in accesses if access.agent_id in agents_by_id]


@app.get("/api/client-portal/agents/{agent_id}/dashboard")
def client_portal_agent_dashboard(agent_id: int, ctx: ClientPortalContext = Depends(get_client_portal_context), db: Session = Depends(get_db)) -> dict[str, Any]:
    access = require_client_agent_access(db, ctx, agent_id, "dashboard.view")
    agent = db.get(Agent, agent_id)
    if not agent:
        raise HTTPException(status_code=404, detail="Agente nao encontrado.")
    conversations = _without_agent_test_conversations(db.query(Conversation)).filter(Conversation.agent_id == agent_id).order_by(Conversation.updated_at.desc()).all()
    open_tickets = db.query(func.count(Ticket.id)).filter(Ticket.agent_id == agent_id, Ticket.status.in_(["unanswered", "in_progress"])).scalar() or 0
    handoff_count = sum(1 for item in conversations if isinstance(item.state, dict) and isinstance(item.state.get("human_handoff"), dict) and item.state["human_handoff"].get("requested"))
    channels = db.query(Channel).filter(Channel.agent_id == agent_id).order_by(Channel.id.asc()).all()
    return {
        "agent": portal_agent_payload(agent, access),
        "metrics": {"conversations": len(conversations), "open_tickets": int(open_tickets), "handoffs": int(handoff_count), "channels": len(channels)},
        "channels": [{"id": c.id, "name": clean_portal_text(c.name, 160), "status": "configurado"} for c in channels],
        "recent_conversations": [portal_conversation_summary_payload(item, db) for item in conversations[:5]],
    }


@app.get("/api/client-portal/agents/{agent_id}/conversations")
def client_portal_agent_conversations(agent_id: int, ctx: ClientPortalContext = Depends(get_client_portal_context), db: Session = Depends(get_db)) -> list[dict[str, Any]]:
    require_client_agent_access(db, ctx, agent_id, "conversations.view")
    items = _without_agent_test_conversations(db.query(Conversation)).filter(Conversation.agent_id == agent_id).order_by(Conversation.updated_at.desc()).limit(200).all()
    return [portal_conversation_summary_payload(item, db) for item in items]


@app.get("/api/client-portal/conversations/{conversation_id}")
def client_portal_conversation_detail(conversation_id: int, ctx: ClientPortalContext = Depends(get_client_portal_context), db: Session = Depends(get_db)) -> dict[str, Any]:
    conversation = db.get(Conversation, conversation_id)
    if not conversation:
        raise HTTPException(status_code=404, detail="Conversa nao encontrada.")
    require_client_agent_access(db, ctx, conversation.agent_id, "conversations.view")
    return portal_conversation_detail_payload(conversation, db)


@app.post("/api/client-portal/conversations/{conversation_id}/human-message")
async def client_portal_human_message(conversation_id: int, request: Request, ctx: ClientPortalContext = Depends(get_client_portal_context), db: Session = Depends(get_db)) -> dict[str, Any]:
    conversation = db.get(Conversation, conversation_id)
    if not conversation:
        raise HTTPException(status_code=404, detail="Conversa nao encontrada.")
    require_client_agent_access(db, ctx, conversation.agent_id, "conversations.respond")
    agent = db.get(Agent, conversation.agent_id)
    if not agent:
        raise HTTPException(status_code=404, detail="Agente nao encontrado.")
    if conversation_channel(conversation) != "whatsapp":
        raise HTTPException(status_code=400, detail="Resposta humana pelo portal so esta liberada para conversas WhatsApp.")
    body = await request_json_dict(request)
    message = sanitize_user_input(body.get("message") or body.get("text"), max_chars=3000)
    if not message:
        raise HTTPException(status_code=400, detail="Informe a mensagem.")
    _set_human_pause_state(conversation, None, True)
    await send_whatsapp_text_to_instance(_conversation_instance(conversation, agent) or agent.instance_token, conversation.customer_phone, message)
    row = append_conversation_message(db, conversation, "human", message)
    row.message_metadata = {"source": "client_portal", "client_user_id": ctx.user.id}
    db.add(row)
    db.add(conversation)
    log_client_portal_audit(db, ctx.organization.id, "conversation.human_message_sent", user_id=ctx.user.id, agent_id=agent.id, payload={"conversation_id": conversation.id})
    db.commit()
    return {"status": "sent", "conversation": portal_conversation_detail_payload(conversation, db)}


@app.get("/api/client-portal/agents/{agent_id}/tickets")
def client_portal_agent_tickets(agent_id: int, ctx: ClientPortalContext = Depends(get_client_portal_context), db: Session = Depends(get_db)) -> list[dict[str, Any]]:
    require_client_agent_access(db, ctx, agent_id, "tickets.view")
    items = db.query(Ticket).filter(Ticket.agent_id == agent_id).order_by(Ticket.updated_at.desc(), Ticket.id.desc()).limit(200).all()
    return [portal_ticket_payload(item, db) for item in items]


@app.put("/api/client-portal/tickets/{ticket_id}")
async def client_portal_update_ticket(ticket_id: int, request: Request, ctx: ClientPortalContext = Depends(get_client_portal_context), db: Session = Depends(get_db)) -> dict[str, Any]:
    ticket = db.get(Ticket, ticket_id)
    if not ticket or not ticket.agent_id:
        raise HTTPException(status_code=404, detail="Ticket nao encontrado.")
    require_client_agent_access(db, ctx, ticket.agent_id, "tickets.update")
    body = await request_json_dict(request)
    status = clean_portal_text(body.get("status") or ticket.status, 40).lower()
    if status == "closed":
        status = "finished"
    if status not in {"unanswered", "in_progress", "finished"}:
        raise HTTPException(status_code=400, detail="Status invalido.")
    ticket.status = status
    if status == "finished":
        ticket.finished_at = ticket.finished_at or datetime.utcnow()
    if "assigned_attendant_id" in body:
        require_client_agent_access(db, ctx, ticket.agent_id, "tickets.assign")
        assigned = body.get("assigned_attendant_id")
        if assigned and not db.get(CRMAttendant, int(assigned)):
            raise HTTPException(status_code=400, detail="Atendente invalido.")
        ticket.assigned_attendant_id = int(assigned) if assigned else None
    db.add(ticket)
    log_client_portal_audit(db, ctx.organization.id, "ticket.updated", user_id=ctx.user.id, agent_id=ticket.agent_id, payload={"ticket_id": ticket.id, "status": status})
    db.commit()
    db.refresh(ticket)
    return {"status": "saved", "ticket": portal_ticket_payload(ticket, db)}


@app.get("/api/client-portal/agents/{agent_id}/tools")
def client_portal_agent_tools(agent_id: int, ctx: ClientPortalContext = Depends(get_client_portal_context), db: Session = Depends(get_db)) -> list[dict[str, Any]]:
    require_client_agent_access(db, ctx, agent_id, "tools.view")
    items = db.query(ToolDefinition).filter(ToolDefinition.agent_id == agent_id).order_by(ToolDefinition.id.asc()).all()
    return [portal_tool_payload(item) for item in items]


@app.post("/api/client-portal/agents/{agent_id}/tools/{tool_id}/execute")
async def client_portal_execute_tool(agent_id: int, tool_id: int, request: Request, ctx: ClientPortalContext = Depends(get_client_portal_context), db: Session = Depends(get_db)) -> dict[str, Any]:
    require_client_agent_access(db, ctx, agent_id, "tools.execute")
    tool = db.get(ToolDefinition, tool_id)
    if not tool or tool.agent_id != agent_id:
        raise HTTPException(status_code=404, detail="Ferramenta nao encontrada.")
    if tool.tool_type in CLIENT_PORTAL_BLOCKED_TOOL_TYPES or tool.tool_type not in CLIENT_PORTAL_EXECUTABLE_TOOL_TYPES:
        raise HTTPException(status_code=403, detail="Esta ferramenta nao pode ser executada pelo portal do cliente.")
    body = await request_json_dict(request)
    query = sanitize_user_input(body.get("query") or body.get("message"), max_chars=1000)
    if not query:
        raise HTTPException(status_code=400, detail="Informe a consulta.")
    kb = db.query(KnowledgeBase).filter(KnowledgeBase.agent_id == agent_id).order_by(KnowledgeBase.id.asc()).first()
    chunks = _lexical_kb_test_chunks(kb.id, query, db, limit=5) if kb else []
    log_client_portal_audit(db, ctx.organization.id, "tool.executed", user_id=ctx.user.id, agent_id=agent_id, payload={"tool_id": tool.id, "tool_type": tool.tool_type, "query": query})
    db.commit()
    return {"status": "executed", "tool": portal_tool_payload(tool), "result": {"query": query, "sources": chunks}}


@app.get("/api/client-portal/agents/{agent_id}/channels")
def client_portal_agent_channels(agent_id: int, ctx: ClientPortalContext = Depends(get_client_portal_context), db: Session = Depends(get_db)) -> list[dict[str, Any]]:
    require_client_agent_access(db, ctx, agent_id, "channels.view")
    items = db.query(Channel).filter(Channel.agent_id == agent_id).order_by(Channel.id.asc()).all()
    return [
        {
            "id": item.id,
            "name": clean_portal_text(item.name, 160),
            "description": clean_portal_text(item.description, 800),
            "agent_id": item.agent_id,
            "reply_delay": item.reply_delay,
            "status": "configurado",
            "created_at": item.created_at.isoformat(),
        }
        for item in items
    ]


@app.get("/api/client-portal/agents/{agent_id}/logs")
def client_portal_agent_logs(agent_id: int, ctx: ClientPortalContext = Depends(get_client_portal_context), db: Session = Depends(get_db)) -> dict[str, Any]:
    require_client_agent_access(db, ctx, agent_id, "logs.view_sanitized")
    runs = db.query(AgentRun).filter(AgentRun.agent_id == agent_id).order_by(AgentRun.id.desc()).limit(50).all()
    events = db.query(WebhookEvent).filter(WebhookEvent.agent_id == agent_id).order_by(WebhookEvent.id.desc()).limit(50).all()
    return {
        "runs": [
            {"id": run.id, "conversation_id": run.conversation_id, "status": run.status, "latency_ms": run.latency_ms, "error": clean_portal_text(run.error, 1000), "is_test": bool(run.is_test), "created_at": run.created_at.isoformat()}
            for run in runs
        ],
        "events": [
            {"id": event.id, "event": clean_portal_text(event.event, 160), "status": event.status, "error": clean_portal_text(event.error, 1000), "customer_phone": clean_portal_text(event.customer_phone, 80), "message_text": clean_portal_text(event.message_text, 1000), "created_at": event.created_at.isoformat()}
            for event in events
        ],
    }


@app.get("/api/client-portal/agents/{agent_id}/metrics")
def client_portal_agent_metrics(agent_id: int, ctx: ClientPortalContext = Depends(get_client_portal_context), db: Session = Depends(get_db)) -> dict[str, Any]:
    require_client_agent_access(db, ctx, agent_id, "metrics.view")
    conversations = _without_agent_test_conversations(db.query(Conversation)).filter(Conversation.agent_id == agent_id)
    tickets_total = db.query(func.count(Ticket.id)).filter(Ticket.agent_id == agent_id).scalar() or 0
    tickets_open = db.query(func.count(Ticket.id)).filter(Ticket.agent_id == agent_id, Ticket.status.in_(["unanswered", "in_progress"])).scalar() or 0
    runs = db.query(AgentRun).filter(AgentRun.agent_id == agent_id)
    avg_latency = db.query(func.avg(AgentRun.latency_ms)).filter(AgentRun.agent_id == agent_id, AgentRun.latency_ms.isnot(None)).scalar()
    return {"agent_id": agent_id, "conversations": conversations.count(), "tickets_total": int(tickets_total), "tickets_open": int(tickets_open), "runs": runs.count(), "failed_runs": runs.filter(AgentRun.status == "failed").count(), "avg_latency_ms": int(avg_latency or 0)}


@app.get("/api/client-portal/agents/{agent_id}/allowed-targets")
def client_portal_allowed_targets(agent_id: int, ctx: ClientPortalContext = Depends(get_client_portal_context), db: Session = Depends(get_db)) -> list[dict[str, Any]]:
    require_client_agent_access(db, ctx, agent_id, "messages.send_to_allowed_targets")
    targets = (
        db.query(ClientPortalAllowedTarget)
        .filter(ClientPortalAllowedTarget.organization_id == ctx.organization.id, ClientPortalAllowedTarget.agent_id == agent_id, ClientPortalAllowedTarget.status == "active")
        .order_by(ClientPortalAllowedTarget.label.asc())
        .all()
    )
    return [{"id": item.id, "target_id": item.target_id, "target_kind": item.target_kind, "label": clean_portal_text(item.label, 160)} for item in targets]


@app.post("/api/client-portal/agents/{agent_id}/messages")
async def client_portal_send_allowed_target_message(agent_id: int, request: Request, ctx: ClientPortalContext = Depends(get_client_portal_context), db: Session = Depends(get_db)) -> dict[str, Any]:
    require_client_agent_access(db, ctx, agent_id, "messages.send_to_allowed_targets")
    agent = db.get(Agent, agent_id)
    if not agent:
        raise HTTPException(status_code=404, detail="Agente nao encontrado.")
    body = await request_json_dict(request)
    target_id = normalize_whatsapp_target_id(body.get("target_id"), body.get("target_kind") or "contact")
    message = sanitize_user_input(body.get("message") or body.get("text"), max_chars=2000)
    if not target_id or not message:
        raise HTTPException(status_code=400, detail="Informe alvo e mensagem.")
    allowed = (
        db.query(ClientPortalAllowedTarget)
        .filter(ClientPortalAllowedTarget.organization_id == ctx.organization.id, ClientPortalAllowedTarget.agent_id == agent_id, ClientPortalAllowedTarget.target_id == target_id, ClientPortalAllowedTarget.status == "active")
        .first()
    )
    if not allowed:
        raise HTTPException(status_code=403, detail="Alvo nao liberado para este cliente.")
    await send_whatsapp_text_to_instance(agent.instance_token, target_id, message)
    log_client_portal_audit(db, ctx.organization.id, "message.allowed_target_sent", user_id=ctx.user.id, agent_id=agent_id, payload={"target_id": target_id})
    db.commit()
    return {"status": "sent", "target_id": target_id}


@app.post("/webhook/channel/{channel_id}")
@app.post("/webhook/channel/{channel_id}/{event_name}")
async def channel_webhook(
    channel_id: int,
    request: Request,
    background_tasks: BackgroundTasks,
    event_name: str | None = None,
    db: Session = Depends(get_db),
) -> dict[str, Any]:
    payload = await read_webhook_payload(request)
    data = webhook_data_dict(payload)
    channel = db.get(Channel, channel_id)
    event_log = WebhookEvent(
        agent_id=channel.agent_id if channel else None,
        instance=payload.get("instance") or data.get("instance"),
        event=payload.get("event") or event_name,
        payload=payload,
        status="queued",
    )
    db.add(event_log)
    db.commit()
    db.refresh(event_log)

    agent = db.get(Agent, channel.agent_id) if channel else None
    if agent and not agent.is_published:
        event_log.status = "skipped_agent_not_published"
        db.add(event_log)
        db.commit()
        return JSONResponse({"status": "skipped", "reason": "agent_not_published", "agent_id": agent.id, "channel_id": channel_id}, status_code=202)

    queue = enqueue_channel_webhook_event(event_log.id, channel_id, background_tasks)
    if queue == "inline":
        return await process_channel_webhook_event(event_log.id, channel_id)
    return {"status": "queued", "queue": queue, "event_id": event_log.id, "channel_id": channel_id}


@app.post("/webhook/{agent_id}")
@app.post("/webhook/{agent_id}/{event_name}")
async def whatsapp_webhook(
    agent_id: int,
    request: Request,
    background_tasks: BackgroundTasks,
    event_name: str | None = None,
    db: Session = Depends(get_db),
) -> dict[str, Any]:
    payload = await read_webhook_payload(request)
    data = webhook_data_dict(payload)
    event_log = WebhookEvent(
        agent_id=agent_id,
        instance=payload.get("instance") or data.get("instance"),
        event=payload.get("event") or event_name,
        payload=payload,
        status="queued",
    )
    db.add(event_log)
    db.commit()
    db.refresh(event_log)

    agent = db.get(Agent, agent_id)
    if agent and not agent.is_published:
        event_log.status = "skipped_agent_not_published"
        db.add(event_log)
        db.commit()
        return JSONResponse({"status": "skipped", "reason": "agent_not_published", "agent_id": agent_id}, status_code=202)

    queue = enqueue_agent_webhook_event(event_log.id, background_tasks)
    if queue == "inline":
        return await process_agent_webhook_event(event_log.id)
    return {"status": "queued", "queue": queue, "event_id": event_log.id, "agent_id": agent_id}
