"""Tool 4: check_replies — Check for new replies across all campaigns.

Fetches new LinkedIn messages, classifies sentiment
(positive/negative/question/neutral), surfaces hot leads,
and auto-handles opt-outs.
"""

from __future__ import annotations

import asyncio
import json
import logging
import time
from typing import Any

from ..ai.sentiment import classify_sentiment
from ..db.async_bridge import run_db
from ..db.queries import (
    get_inbound_signal_by_sender,
    get_messages_for_outreach,
    get_setting,
    increment_accepted,
    list_campaigns,
    list_inbound_signals,
    log_action,
    mark_message_read,
    save_message,
    update_contact,
    update_outreach,
)
from ..db.schema import get_db
from ..linkedin import (
    UnipileAuthError,
    UnipileError,
    get_account_id,
    get_linkedin_client,
)
from ..linkedin.circuit_breaker import CircuitBreakerOpen, CollectorCircuitBreaker

logger = logging.getLogger(__name__)

_cb = CollectorCircuitBreaker("check_replies")

# Sentiment display config
SENTIMENT_ICONS = {
    "positive": "🔥",
    "negative": "👎",
    "question": "❓",
    "neutral": "💬",
    "engaged": "💬",
    "out_of_office": "✈️",
    "opt_out": "🚫",
}

SENTIMENT_ACTIONS = {
    "positive": "ACTION NEEDED: Reply with your booking link!",
    "negative": "No action needed. Outreach closed.",
    "question": "ACTION NEEDED: Answer their question.",
    "neutral": "No action needed. Monitor.",
    "engaged": "ACTION NEEDED: Continue the conversation — deepen rapport.",
    "out_of_office": "Will follow up when they're back.",
    "opt_out": "Auto-closed. They won't be contacted again.",
}


def _append_inbound_invitations(
    output: list[str],
    invitations: list[dict[str, Any]],
    limit: int = 10,
) -> None:
    """Append inbound invitation lines with qualification badges if available."""
    output.append(f"📨 Inbound Invitations ({len(invitations)}):")
    for inv in invitations[:limit]:
        inv_name = inv.get("sender_name", "Someone")
        inv_headline = inv.get("headline", "")
        inv_msg = inv.get("message", "")

        # Check if we have qualification data for this sender
        sender_id = inv.get("sender_id", "")
        badge = ""
        if sender_id:
            from ..db.queries import get_inbound_signal_by_sender as _get_sig
            sig = _get_sig(sender_id, "invitation")
            if sig and sig.get("intent"):
                conf = sig.get("confidence", 0) or 0
                intent = sig.get("intent", "unknown")
                if conf >= 0.7:
                    badge = f"  🟢 {conf:.0%} Lead ({intent})"
                elif conf >= 0.4:
                    badge = f"  🟡 {conf:.0%} ({intent})"
                else:
                    badge = f"  🔴 {conf:.0%} ({intent})"

        inv_line = f"   • {inv_name}"
        if inv_headline:
            inv_line += f" — {inv_headline}"
        inv_line += badge
        output.append(inv_line)
        if inv_msg:
            output.append(f"     \"{inv_msg[:100]}\"")
    if len(invitations) > limit:
        output.append(f"   ... and {len(invitations) - limit} more")


async def _sync_silent_connections(client: Any, account_id: str) -> list[dict]:
    """Detect connections that accepted without messaging.

    Calls get_relations() API and cross-references with outreaches
    that are still in 'invited' status. Updates them to 'connected'.

    Returns list of newly detected connections.
    """
    try:
        relations = await _cb.call(
            client.get_relations(account_id, limit=500),
            label="get_relations",
        )
    except (CircuitBreakerOpen, asyncio.TimeoutError) as e:
        logger.warning(f"get_relations timed out or circuit breaker open: {e}")
        return []
    except Exception as e:
        logger.warning(f"Failed to fetch relations for sync: {e}")
        return []

    if not relations:
        return []

    # Build set of connected provider_ids and public_ids
    connected_ids: set[str] = set()
    for rel in relations:
        pid = rel.get("provider_id") or ""
        if pid:
            connected_ids.add(str(pid))
        pub_id = rel.get("public_id") or ""
        if pub_id:
            connected_ids.add(pub_id)

    # Find outreaches stuck in 'invited' whose contact is now connected
    # Limit to 200 most recent to prevent unbounded memory usage
    def _fetch_invited() -> list:
        db = get_db()
        rows = db.execute(
            """SELECT o.id as outreach_id, o.contact_id, o.campaign_id,
                      c.name, c.title, c.company, c.linkedin_id, c.linkedin_url,
                      c.profile_json
               FROM outreaches o
               JOIN contacts c ON o.contact_id = c.id
               WHERE o.status = 'invited'
               ORDER BY o.created_at DESC
               LIMIT 200"""
        ).fetchall()
        db.close()
        return rows

    invited_outreaches = await run_db(_fetch_invited)

    newly_connected: list[dict] = []
    matched_ids: set[str] = set()
    for row in invited_outreaches:
        r = dict(row)
        linkedin_id = r.get("linkedin_id") or ""
        # Extract public_id from linkedin_url
        public_id = ""
        url = r.get("linkedin_url") or ""
        if "/in/" in url:
            public_id = url.split("/in/")[-1].strip("/")

        # Extract provider_id from profile_json (stored during campaign creation)
        contact_provider_id = ""
        try:
            profile_data = json.loads(r.get("profile_json") or "{}")
            contact_provider_id = str(profile_data.get("provider_id", ""))
        except (json.JSONDecodeError, TypeError):
            pass

        # Check if this contact is in our connections
        if (linkedin_id and str(linkedin_id) in connected_ids) or \
           (public_id and public_id in connected_ids) or \
           (contact_provider_id and contact_provider_id in connected_ids):
            await run_db(update_outreach, r["outreach_id"], status="connected")
            await run_db(increment_accepted)
            await run_db(
                log_action,
                "silent_connection_detected",
                outreach_id=r["outreach_id"],
                result="success",
                details={"name": r.get("name", ""), "method": "relations_sync"},
            )
            newly_connected.append(r)
            matched_ids.add(r["outreach_id"])

    # Fallback: individually check remaining invited contacts via API (in parallel)
    unmatched = [dict(row) for row in invited_outreaches
                 if dict(row)["outreach_id"] not in matched_ids]
    if unmatched and len(unmatched) <= 20:
        async def _check_relation(r: dict) -> dict | None:
            contact_provider_id = ""
            try:
                profile_data = json.loads(r.get("profile_json") or "{}")
                contact_provider_id = str(profile_data.get("provider_id", ""))
            except (json.JSONDecodeError, TypeError):
                pass

            if not contact_provider_id:
                return None

            try:
                relation = await client.check_existing_relation(
                    account_id, contact_provider_id
                )
                if relation.get("connected") or relation.get("has_chat"):
                    await run_db(update_outreach, r["outreach_id"], status="connected")
                    await run_db(increment_accepted)
                    await run_db(
                        log_action,
                        "silent_connection_detected",
                        outreach_id=r["outreach_id"],
                        result="success",
                        details={"name": r.get("name", ""), "method": "individual_check"},
                    )
                    return r
            except Exception as e:
                logger.debug(f"Individual relation check failed for {r.get('name', '')}: {e}")
            return None

        results = await asyncio.gather(
            *[_check_relation(r) for r in unmatched],
            return_exceptions=True,
        )
        for result in results:
            if isinstance(result, dict):
                newly_connected.append(result)

    return newly_connected


async def run_check_replies() -> str:
    """Check for new replies across all active campaigns.

    Flow:
    1. Fetch recent LinkedIn inbox messages
    2. Match messages to active outreach contacts
    3. Classify sentiment for new messages
    4. Handle opt-outs automatically
    5. Surface hot leads first
    """

    # ── Pre-checks ──
    setup_done = await run_db(get_setting, "setup_complete", False)
    if not setup_done:
        return (
            "❌ Setup required before checking replies.\n\n"
            "Please run setup_profile first — it connects your LinkedIn account.\n\n"
            "Say 'set up my profile' and I'll walk you through it step by step."
        )

    account_id = get_account_id()
    if not account_id:
        return "❌ No LinkedIn account connected. Run setup_profile first."

    try:
        client = get_linkedin_client()
    except UnipileError as e:
        return f"❌ {e}"

    # ── Fetch messages from LinkedIn via Unipile ──
    try:
        messages = await _cb.call(
            client.get_chats(account_id=account_id, limit=50),
            label="get_chats",
        )
    except UnipileAuthError:
        await client.close()
        return (
            "🔑 LinkedIn account disconnected.\n\n"
            "Run setup_profile() again to reconnect."
        )
    except (CircuitBreakerOpen, asyncio.TimeoutError) as e:
        logger.error(f"get_chats timed out or circuit breaker open: {e}")
        await client.close()
        return f"⏱️ LinkedIn API too slow — skipped this check. Will retry next cycle."
    except Exception as e:
        logger.error(f"Failed to check messages: {e}")
        await client.close()
        return f"❌ Failed to check LinkedIn messages: {e}"

    # ── Sync silent connections (accepted but no message) ──
    silent_connections = await _sync_silent_connections(client, account_id)

    # ── Fetch profile viewers (inbound signals) ──
    profile_viewers: list[dict[str, Any]] = []
    try:
        profile_viewers = await _cb.call(
            client.get_profile_viewers(account_id),
            label="get_profile_viewers",
        )
    except (CircuitBreakerOpen, asyncio.TimeoutError) as e:
        logger.debug(f"Profile viewers skipped (timeout/circuit breaker): {e}")
    except Exception as e:
        logger.debug(f"Profile viewers fetch failed (non-critical): {e}")

    # ── Fetch inbound invitations ──
    inbound_invitations: list[dict[str, Any]] = []
    try:
        inbound_invitations = await _cb.call(
            client.get_received_invitations(account_id),
            label="get_received_invitations",
        )
    except (CircuitBreakerOpen, asyncio.TimeoutError) as e:
        logger.debug(f"Inbound invitations skipped (timeout/circuit breaker): {e}")
    except Exception as e:
        logger.debug(f"Inbound invitations fetch failed (non-critical): {e}")

    # ── Fetch real-time webhook events (if backend mode) ──
    webhook_events: list[dict[str, Any]] = []
    try:
        if hasattr(client, "get_unprocessed_events"):
            webhook_events = await client.get_unprocessed_events(account_id, limit=20)
            # Auto-acknowledge processed events
            for evt in webhook_events:
                evt_id = evt.get("id")
                if evt_id and hasattr(client, "acknowledge_event"):
                    await client.acknowledge_event(evt_id)
            if webhook_events:
                logger.info("Fetched %d real-time webhook events", len(webhook_events))
    except Exception as e:
        logger.debug(f"Webhook event fetch failed (non-critical): {e}")

    await client.close()

    # NOTE: Inbound invitations are now detected and saved by the unified
    # inbound pipeline (services/inbound_pipeline.py). No need to save here.

    if not messages and not silent_connections:
        # Even with no messages, show inbound signals
        extra_output: list[str] = []
        if inbound_invitations:
            _append_inbound_invitations(extra_output, inbound_invitations, limit=10)
            extra_output.append("   💡 Accept matching leads or let the scheduler auto-accept ICP matches!")
        if profile_viewers:
            if extra_output:
                extra_output.append("")
            extra_output.append(f"👀 Profile Viewers ({len(profile_viewers)}):")
            for v in profile_viewers[:10]:
                name = v.get("name", "Anonymous")
                title = v.get("title", "")
                company = v.get("company", "")
                role = title
                if company:
                    role += f" at {company}" if role else company
                extra_output.append(f"   • {name}" + (f" — {role}" if role else ""))
            if len(profile_viewers) > 10:
                extra_output.append(f"   ... and {len(profile_viewers) - 10} more")
            extra_output.append("   💡 These people checked out your profile — consider reaching out!")
        if extra_output:
            return "📭 No new messages found.\n\n" + "\n".join(extra_output)
        return (
            "📭 No new messages found.\n\n"
            "Tip: Replies usually take 1-3 days after invitations are accepted.\n"
            "Use show_status to see your campaign progress."
        )

    # ── Match messages to outreach contacts ──
    def _fetch_outreach_contacts() -> list:
        db = get_db()
        rows = db.execute(
            """SELECT o.id as outreach_id, o.contact_id, o.status,
                      c.linkedin_id, c.name, c.title, c.company, c.campaign_id, c.fit_score,
                      c.profile_json
               FROM outreaches o
               JOIN contacts c ON o.contact_id = c.id
               WHERE o.status NOT IN ('opted_out')"""
        ).fetchall()
        db.close()
        return rows

    outreach_contacts = await run_db(_fetch_outreach_contacts)

    # Build lookup by both linkedin_id (public_id) and provider_id
    # sender_id from get_chats() is provider_id format (ACoAAA...),
    # but contacts.linkedin_id stores public_id format (slug).
    contact_lookup: dict[str, dict] = {}
    for row in outreach_contacts:
        r = dict(row)
        if r.get("linkedin_id"):
            contact_lookup[r["linkedin_id"]] = r
        # Also index by provider_id from profile_json
        pj = r.pop("profile_json", None)
        if pj:
            try:
                profile = json.loads(pj) if isinstance(pj, str) else pj
                provider_id = profile.get("provider_id") or ""
                if provider_id:
                    contact_lookup[provider_id] = r
            except Exception:
                pass

    # Get our own provider_id so we can detect chats where WE sent the last message
    our_provider_id = ""
    try:
        profile_json_str = await run_db(get_setting, "profile", "")
        if profile_json_str:
            our_profile = json.loads(profile_json_str) if isinstance(profile_json_str, str) else profile_json_str
            our_provider_id = our_profile.get("provider_id", "")
    except Exception:
        pass

    # ── Process matches ──
    matched_replies: list[dict[str, Any]] = []

    unsolicited_inbound: list[dict[str, Any]] = []

    pending_classifications: list[tuple[dict, dict, str]] = []  # (contact, msg, reply_text)

    # Phase 1: Process messages that have embedded content (direct Unipile mode)
    has_needs_fetch = False
    for msg in messages:
        if msg.get("_needs_fetch"):
            has_needs_fetch = True
            continue  # Will be handled in Phase 1b via server-side batch

        sender_id = msg.get("sender_id") or ""
        if not sender_id:
            continue
        if sender_id not in contact_lookup:
            if our_provider_id and sender_id == our_provider_id:
                continue  # Our own message
            # Unsolicited inbound DM
            existing_sig = await run_db(get_inbound_signal_by_sender, sender_id, "message")
            unsolicited_inbound.append({
                "name": msg.get("sender_name", "Unknown"),
                "text": msg.get("text", ""),
                "signal_id": existing_sig.get("id", "") if existing_sig else "",
            })
            continue

        contact = contact_lookup[sender_id]
        reply_text = msg.get("text") or ""

        # Dedup
        existing_msgs = await run_db(get_messages_for_outreach, contact["outreach_id"])
        last_prospect_msg_existing = None
        for m in reversed(existing_msgs):
            if m.get("role") == "prospect":
                last_prospect_msg_existing = m
                break
        if last_prospect_msg_existing and last_prospect_msg_existing.get("text", "").strip() == reply_text.strip():
            continue

        # Detect connection acceptance
        if contact["status"] == "invited":
            await run_db(update_outreach, contact["outreach_id"], status="connected")
            await run_db(log_action, "connection_accepted", outreach_id=contact["outreach_id"],
                         details={"name": contact.get("name", "")})
            contact["status"] = "connected"

        # Re-activate dormant outreaches
        DORMANT_STATUSES = ("pending", "error", "closed_happy", "closed_unhappy", "exhausted")
        if contact["status"] in DORMANT_STATUSES:
            old_status = contact["status"]
            await run_db(update_outreach, contact["outreach_id"], status="replied")
            await run_db(log_action, "outreach_reactivated", outreach_id=contact["outreach_id"],
                         details={"name": contact.get("name", ""),
                                  "previous_status": old_status,
                                  "trigger": "inbound_message"})
            contact["status"] = "replied"

        pending_classifications.append((contact, msg, reply_text))

    # Phase 1b: Server-side batch fetch for lightweight chat entries.
    # Instead of N+1 client-side calls, use POST /chats/with-replies
    # which fetches messages server-side and returns only prospect replies.
    if has_needs_fetch and hasattr(client, "get_chats_with_replies"):
        all_provider_ids = [pid for pid in contact_lookup if pid != our_provider_id]
        try:
            reply_client = get_linkedin_client()
            logger.debug("Phase 1b: calling get_chats_with_replies with %d provider_ids", len(all_provider_ids))
            server_replies = await asyncio.wait_for(
                reply_client.get_chats_with_replies(
                    account_id=account_id,
                    provider_ids=all_provider_ids,
                    our_provider_id=our_provider_id,
                    limit=100,
                ),
                timeout=120,
            )
            await reply_client.close()
            logger.info("Reply detection: found %d prospect replies via server-side fetch", len(server_replies))
        except Exception as e:
            logger.warning("Server-side reply fetch failed: %s", e)
            server_replies = []

        for reply in server_replies:
            sender_id = reply.get("sender_id") or reply.get("prospect_provider_id") or ""
            if not sender_id or sender_id not in contact_lookup:
                continue
            contact = contact_lookup[sender_id]
            reply_text = (reply.get("text") or "").strip()
            if not reply_text:
                continue

            # Dedup
            existing_msgs = await run_db(get_messages_for_outreach, contact["outreach_id"])
            last_prospect_msg = None
            for m in reversed(existing_msgs):
                if m.get("role") == "prospect":
                    last_prospect_msg = m
                    break
            if last_prospect_msg and last_prospect_msg.get("text", "").strip() == reply_text:
                continue

            # Detect connection acceptance
            if contact["status"] == "invited":
                await run_db(update_outreach, contact["outreach_id"], status="connected")
                await run_db(log_action, "connection_accepted", outreach_id=contact["outreach_id"],
                             details={"name": contact.get("name", "")})
                contact["status"] = "connected"

            # Re-activate dormant outreaches
            DORMANT_STATUSES = ("pending", "error", "closed_happy", "closed_unhappy", "exhausted")
            if contact["status"] in DORMANT_STATUSES:
                old_status = contact["status"]
                await run_db(update_outreach, contact["outreach_id"], status="replied")
                await run_db(log_action, "outreach_reactivated", outreach_id=contact["outreach_id"],
                             details={"name": contact.get("name", ""),
                                      "previous_status": old_status,
                                      "trigger": "inbound_message"})
                contact["status"] = "replied"

            synthetic_msg = {
                "sender_id": sender_id,
                "sender_name": reply.get("sender_name") or contact.get("name", ""),
                "text": reply_text,
                "timestamp": reply.get("timestamp") or 0,
                "conversation_urn": reply.get("chat_id", ""),
            }
            pending_classifications.append((contact, synthetic_msg, reply_text))

    # Phase 2: Classify sentiment in parallel for all pending messages
    if pending_classifications:
        sentiments = await asyncio.gather(
            *[classify_sentiment(text) for _, _, text in pending_classifications]
        )
    else:
        sentiments = []

    # Phase 3: Process results (save, update status, detect hot leads)
    for (contact, msg, reply_text), sentiment in zip(pending_classifications, sentiments):
        # Mark prior SDR messages as read (prospect replied → they read our messages)
        try:
            prior_msgs = await run_db(get_messages_for_outreach, contact["outreach_id"])
            reply_ts = msg.get("timestamp") or int(time.time())
            for pm in prior_msgs:
                if pm.get("role") == "sdr" and not pm.get("read_at"):
                    await run_db(mark_message_read, pm["id"], read_at=reply_ts)
        except Exception:
            pass  # Non-critical

        # Save the reply
        await run_db(
            save_message,
            outreach_id=contact["outreach_id"],
            role="prospect",
            text=reply_text,
            sentiment=sentiment,
        )

        # Handle opt-outs
        if sentiment == "opt_out":
            await run_db(update_outreach, contact["outreach_id"], status="opted_out")
            await run_db(log_action, "opt_out_detected", outreach_id=contact["outreach_id"],
                         details={"text": reply_text[:200]})

        # Hot lead detection
        elif sentiment == "positive":
            await run_db(update_outreach, contact["outreach_id"], status="hot_lead")
            await run_db(log_action, "hot_lead_detected", outreach_id=contact["outreach_id"],
                         details={"text": reply_text[:200]})
            # Bump fit score on positive reply
            current_fit = contact.get("fit_score", 0.5)
            new_fit = min(1.0, current_fit + 0.1)
            if contact.get("contact_id"):
                await run_db(update_contact, contact["contact_id"], fit_score=new_fit)

        # Question — needs attention
        elif sentiment == "question":
            await run_db(update_outreach, contact["outreach_id"], status="replied")

        # Other
        else:
            await run_db(update_outreach, contact["outreach_id"], status="replied")

        matched_replies.append({
            "name": contact.get("name", "Unknown"),
            "title": contact.get("title", ""),
            "company": contact.get("company", ""),
            "text": reply_text,
            "sentiment": sentiment,
            "conversation_urn": msg.get("conversation_urn", ""),
        })

    # ── Mark processed chats as read (non-blocking) ──
    try:
        chat_ids_to_mark = set()
        for reply in matched_replies:
            curn = reply.get("conversation_urn", "")
            if curn:
                chat_ids_to_mark.add(curn)
        if chat_ids_to_mark:
            mark_account_id = get_account_id()
            if mark_account_id:
                try:
                    mark_client = get_linkedin_client()
                except Exception:
                    mark_client = None
                if mark_client:
                    try:
                        for cid in chat_ids_to_mark:
                            await mark_client.mark_chat_read(cid)
                    finally:
                        await mark_client.close()
    except Exception:
        pass  # Non-critical — inbox hygiene

    # ── Format output ──
    if not matched_replies and not silent_connections:
        return (
            f"📬 Checked {len(messages)} messages — none from campaign contacts.\n\n"
            "Replies from non-campaign contacts are not tracked.\n"
            "Use show_status to see campaign progress."
        )

    if not matched_replies and silent_connections:
        # Only silent connections, no message replies
        output = [f"📬 No new message replies, but:\n"]
        sc_count = len(silent_connections)
        output.append(f"🤝 {sc_count} silent connection{'s' if sc_count != 1 else ''} detected:")
        for sc in silent_connections[:5]:
            name = sc.get("name", "Unknown")
            title = sc.get("title", "")
            company = sc.get("company", "")
            role = title
            if company:
                role += f" at {company}" if role else company
            output.append(f"   └── {name} ({role}) — accepted your invite!")
        if sc_count > 5:
            output.append(f"   ... and {sc_count - 5} more")
        output.append("   → Use send_followup() to reach out!")
        return "\n".join(output)

    # Sort: hot leads first, then questions, then rest
    priority_order = {"positive": 0, "engaged": 1, "question": 2, "neutral": 3, "negative": 4, "out_of_office": 5, "opt_out": 6}
    matched_replies.sort(key=lambda r: priority_order.get(r["sentiment"], 99))

    output = [f"📬 {len(matched_replies)} new repl{'y' if len(matched_replies) == 1 else 'ies'}:\n"]

    for i, reply in enumerate(matched_replies):
        icon = SENTIMENT_ICONS.get(reply["sentiment"], "💬")
        action = SENTIMENT_ACTIONS.get(reply["sentiment"], "")

        name = reply["name"]
        role = reply.get("title", "")
        if reply.get("company"):
            role += f" at {reply['company']}" if role else reply["company"]

        text_preview = reply["text"][:150]
        if len(reply["text"]) > 150:
            text_preview += "..."

        output.append(f"{icon} **{name}** ({role}):")
        output.append(f"   \"{text_preview}\"")
        if action:
            output.append(f"   → {action}")
        output.append("")

    # Summary
    hot_count = sum(1 for r in matched_replies if r["sentiment"] == "positive")
    question_count = sum(1 for r in matched_replies if r["sentiment"] == "question")
    opt_out_count = sum(1 for r in matched_replies if r["sentiment"] == "opt_out")

    if hot_count > 0:
        output.append(f"🔥 {hot_count} hot lead{'s' if hot_count > 1 else ''}! Reply to them ASAP.")
    if question_count > 0:
        output.append(f"❓ {question_count} question{'s' if question_count > 1 else ''} to answer.")
    if opt_out_count > 0:
        output.append(f"🚫 {opt_out_count} opt-out{'s' if opt_out_count > 1 else ''} (auto-closed).")

    # ── Silent connections ──
    if silent_connections:
        output.append("")
        sc_count = len(silent_connections)
        output.append(f"🤝 {sc_count} silent connection{'s' if sc_count != 1 else ''} detected:")
        for sc in silent_connections[:5]:
            name = sc.get("name", "Unknown")
            title = sc.get("title", "")
            company = sc.get("company", "")
            role = title
            if company:
                role += f" at {company}" if role else company
            output.append(f"   └── {name} ({role}) — accepted your invite!")
        if sc_count > 5:
            output.append(f"   ... and {sc_count - 5} more")
        output.append("   → Use send_followup() to reach out!")

    # ── Profile viewers (inbound signals) ──
    if profile_viewers:
        output.append("")
        output.append(f"👀 Profile Viewers ({len(profile_viewers)}):")
        for v in profile_viewers[:5]:
            name = v.get("name", "Anonymous")
            title = v.get("title", "")
            company = v.get("company", "")
            role = title
            if company:
                role += f" at {company}" if role else company
            output.append(f"   • {name}" + (f" — {role}" if role else ""))
        if len(profile_viewers) > 5:
            output.append(f"   ... and {len(profile_viewers) - 5} more")
        output.append("   💡 These people checked your profile — warm leads!")

    # ── Inbound invitations with qualification badges ──
    if inbound_invitations:
        output.append("")
        _append_inbound_invitations(output, inbound_invitations, limit=5)
        output.append("   💡 Accept matching leads or let the scheduler handle it!")

    # ── Unsolicited inbound DMs ──
    if unsolicited_inbound:
        output.append("")
        output.append(f"📩 Unsolicited Inbound DMs ({len(unsolicited_inbound)}):")
        for ub in unsolicited_inbound[:5]:
            name = ub.get("name", "Unknown")
            text = (ub.get("text") or "")[:100]
            output.append(f"   • {name}: \"{text}\"")
        if len(unsolicited_inbound) > 5:
            output.append(f"   ... and {len(unsolicited_inbound) - 5} more")
        output.append("   💡 New inbound messages detected — pipeline will classify and respond!")

    # ── Inbound Pipeline Status ──
    recent_signals = await run_db(list_inbound_signals, limit=30)
    if recent_signals:
        by_status: dict[str, int] = {}
        for s in recent_signals:
            st = s.get("status", "unknown")
            by_status[st] = by_status.get(st, 0) + 1
        pipeline_parts = []
        for status_name in ("new", "classified", "accepted", "ignored", "engaged", "dismissed"):
            count = by_status.get(status_name, 0)
            if count:
                pipeline_parts.append(f"{count} {status_name}")
        if pipeline_parts:
            output.append("")
            output.append(f"📊 Inbound Pipeline: {', '.join(pipeline_parts)}")

    # ── Real-time webhook events ──
    if webhook_events:
        output.append("")
        new_msg_events = [e for e in webhook_events if e.get("event_type") == "new_message"]
        new_rel_events = [e for e in webhook_events if e.get("event_type") in ("new_relation", "invitation_accepted")]
        disconnect_events = [e for e in webhook_events if e.get("event_type") == "account_disconnected"]
        if new_msg_events:
            output.append(f"⚡ {len(new_msg_events)} real-time message event(s) received via webhook")
        if new_rel_events:
            output.append(f"⚡ {len(new_rel_events)} new connection event(s) via webhook")
        if disconnect_events:
            output.append("⚠️ Account disconnection event detected — check account health!")

    return "\n".join(output)
