"""Unified Inbound Pipeline v2 — Classify-First Architecture.

Single-pass pipeline that handles 100% of inbound traffic:
1. DETECT  — Fetch invitations + messages, save as inbound_signals
2. CLASSIFY — AI qualification on every new signal BEFORE any action
3. ACT     — Accept/ignore/decline invitations, respond/react to messages
4. TRACK   — Create outreaches, log actions

Replaces the old accept-first approach where all invitations were
accepted immediately and qualified later.
"""

from __future__ import annotations

import logging
import time
from typing import Any

from ..ai.inbound_qualifier import (
    InboundQualification,
    generate_discovery_question,
    qualify_inbound,
)
from ..ai.message_fixer import fix_message
from ..ai.message_improver import improve_message
from ..ai.message_validator import validate_reply
from ..ai.reply_generator import generate_reply
from ..ai.sentiment import classify_sentiment
from ..constants import (
    DAILY_INBOUND_DM_LIMIT,
    INBOUND_MAX_DM_ATTEMPTS,
)
from ..db.async_bridge import run_db
from ..db.queries import (
    count_inbound_dms_today,
    create_outreach,
    get_inbound_signal_by_sender,
    get_messages_for_outreach,
    get_setting,
    list_campaigns,
    list_icps,
    list_inbound_signals,
    log_action,
    save_contact,
    save_inbound_signal,
    save_message,
    save_setting,
    update_inbound_signal,
    update_outreach,
)
from ..db.signal_queries import (
    get_contact_by_linkedin_id,
    get_contact_by_name_company,
    get_contact_by_provider_id,
)

logger = logging.getLogger(__name__)

# ── Configurable thresholds ──────────────────────────────────────────────────

INBOUND_ACCEPT_CONFIDENCE = 0.4   # Min confidence to accept an invitation
INBOUND_DECLINE_SPAM = False       # If True, actively decline spam (default: ignore)
DAILY_INBOUND_ACCEPT_LIMIT = 50   # Max invitations to accept per day


# ── Main Entry Point ─────────────────────────────────────────────────────────

async def process_inbound_pipeline() -> str:
    """Single-pass unified inbound processor.

    1. DETECT: Fetch all inbound signals (invitations, messages)
    2. CLASSIFY: Run AI qualification on every new signal BEFORE any action
    3. ACT: Accept/ignore/decline invitations, respond/react to messages
    4. TRACK: Update DB state, create outreaches, log everything

    Returns a summary string.
    """
    from ..linkedin import UnipileError, get_account_id, get_linkedin_client

    account_id = get_account_id()
    if not account_id:
        return "No LinkedIn account connected."

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

    try:
        # ── DETECT ──
        inv_count = await _detect_invitations(client, account_id)
        msg_count = await _detect_messages(client, account_id)

        # ── CLASSIFY ──
        classified = await _classify_all_new()

        # ── ACT ──
        inv_summary = await _act_on_invitations(client, account_id)
        msg_summary = await _act_on_messages(client, account_id)

        # ── Record scan timestamp for next run's time filter ──
        await run_db(save_setting, "last_inbound_scan_ts", int(time.time()))

        # ── Summary ──
        parts = []
        if inv_count or msg_count:
            parts.append(f"Detected: {inv_count} invitations, {msg_count} messages")
        if classified:
            parts.append(f"Classified: {classified}")
        if inv_summary:
            parts.append(f"Invitations: {inv_summary}")
        if msg_summary:
            parts.append(f"Messages: {msg_summary}")

        return " | ".join(parts) if parts else "No inbound activity."

    except Exception as e:
        logger.error("Inbound pipeline error: %s", e, exc_info=True)
        return f"Inbound pipeline error: {e}"
    finally:
        await client.close()


# ── DETECT ───────────────────────────────────────────────────────────────────

async def _detect_invitations(client: Any, account_id: str) -> int:
    """Fetch received invitations and save new ones as inbound_signals."""
    try:
        invitations = await client.get_received_invitations(account_id)
    except Exception as e:
        logger.warning("Failed to fetch invitations: %s", e)
        return 0

    saved = 0
    for inv in invitations:
        inv_id = inv.get("id", "")
        sender_id = inv.get("sender_id", "")
        if not inv_id or not sender_id:
            continue

        # Dedup by sender_id
        existing = await run_db(get_inbound_signal_by_sender, sender_id, "invitation")
        if existing:
            continue

        await run_db(
            save_inbound_signal,
            signal_type="invitation",
            sender_name=inv.get("sender_name", ""),
            sender_id=sender_id,
            sender_headline=inv.get("headline", ""),
            content=inv.get("message", ""),
            invitation_id=inv_id,
        )
        saved += 1

    if saved:
        logger.info("Detected %d new inbound invitations", saved)
    return saved


async def _detect_messages(client: Any, account_id: str) -> int:
    """Fetch recent messages and save unsolicited ones as inbound_signals.

    Filters out:
    - Our own sent messages (by LinkedIn provider_id)
    - Messages older than last scan (time-based dedup)
    - Senders with existing active inbound signals
    - Known campaign contacts (by linkedin_id or provider_id)
    - Empty messages
    """
    try:
        messages = await client.list_all_messages(account_id, limit=50)
    except Exception as e:
        logger.warning("Failed to list all messages: %s", e)
        return 0

    if not messages:
        return 0

    logger.info("Inbound detect: %d messages fetched from API", len(messages))

    # Resolve our LinkedIn provider_id for own-message filtering.
    # account_id is a Unipile UUID — sender_id is a LinkedIn provider_id (ACoAAA format).
    profile = await run_db(get_setting, "profile", {})
    our_provider_id = ""
    if isinstance(profile, dict):
        our_provider_id = profile.get("provider_id", "")
    if not our_provider_id:
        logger.warning(
            "Inbound detect: no provider_id in profile setting — "
            "own-message filter disabled, false positives possible"
        )

    # Time-based filtering: skip messages older than last scan.
    last_scan_ts = await run_db(get_setting, "last_inbound_scan_ts", 0)
    if not last_scan_ts:
        last_scan_ts = int(time.time()) - 3600  # Default: last hour

    # Skip counters for logging
    skip_own = 0
    skip_old = 0
    skip_signal = 0
    skip_contact = 0
    skip_empty = 0

    saved = 0
    for msg in messages:
        sender_id = msg.get("sender_id", "")
        if not sender_id:
            continue

        # Skip our own sent messages
        if our_provider_id and sender_id == our_provider_id:
            skip_own += 1
            continue

        # Skip messages older than last scan
        msg_ts = msg.get("timestamp", 0)
        if msg_ts and msg_ts <= last_scan_ts:
            skip_old += 1
            continue

        # Skip if this sender already has an inbound signal in active state
        existing = await run_db(get_inbound_signal_by_sender, sender_id, "message")
        if existing:
            status = existing.get("status", "")
            if status in ("new", "classified", "accepted", "engaged"):
                skip_signal += 1
                continue
            # For dismissed/ignored/declined: allow re-detection if message is NEW
            existing_content = (existing.get("content") or "").strip()
            new_content = (msg.get("text") or "").strip()
            if existing_content == new_content:
                skip_signal += 1
                continue

        # Known campaign contact — re-activate instead of skipping
        contact = await run_db(get_contact_by_linkedin_id, sender_id)
        if not contact:
            contact = await run_db(get_contact_by_provider_id, sender_id)
        if contact:
            text = msg.get("text", "")
            if text:
                await _reactivate_existing_contact(contact, sender_id, text)
            skip_contact += 1
            continue

        text = msg.get("text", "")
        if not text:
            skip_empty += 1
            continue

        message_id = msg.get("message_id", "")

        await run_db(
            save_inbound_signal,
            signal_type="message",
            sender_name=msg.get("sender_name", ""),
            sender_id=sender_id,
            content=text,
            message_id=message_id,
        )
        saved += 1

    total_skipped = skip_own + skip_old + skip_signal + skip_contact + skip_empty
    logger.info(
        "Inbound detect: %d from API, %d skipped "
        "(own=%d old=%d signal=%d contact=%d empty=%d), %d new signals saved",
        len(messages), total_skipped,
        skip_own, skip_old, skip_signal, skip_contact, skip_empty, saved,
    )
    return saved


async def _reactivate_existing_contact(
    contact: dict[str, Any],
    sender_id: str,
    text: str,
) -> None:
    """Re-activate an existing campaign contact who sent a new message.

    Finds their outreach, updates status to 'replied' or 'hot_lead',
    saves the prospect message, and lets the auto-reply scheduler handle
    the response.
    """
    from ..db.schema import get_db

    contact_id = contact.get("id", "")
    if not contact_id:
        return

    # Find the most recent outreach for this contact
    def _find_outreach() -> dict | None:
        db = get_db()
        row = db.execute(
            """SELECT id, status, campaign_id FROM outreaches
               WHERE contact_id = ?
               ORDER BY updated_at DESC LIMIT 1""",
            (contact_id,),
        ).fetchone()
        db.close()
        return dict(row) if row else None

    outreach = await run_db(_find_outreach)
    if not outreach:
        return

    outreach_id = outreach["id"]
    old_status = outreach["status"]

    # Don't re-activate opted_out contacts
    if old_status == "opted_out":
        return

    # Dedup: check if we already saved this exact message
    existing_msgs = await run_db(get_messages_for_outreach, outreach_id)
    if existing_msgs:
        for m in reversed(existing_msgs):
            if m.get("role") == "prospect":
                if m.get("text", "").strip() == text.strip():
                    return  # Already processed
                break

    # Classify sentiment
    sentiment = "neutral"
    try:
        sentiment = await classify_sentiment(text)
    except Exception:
        pass

    # Map sentiment to status
    if sentiment == "opt_out":
        await run_db(update_outreach, outreach_id, status="opted_out")
        return

    new_status = "hot_lead" if sentiment == "positive" else "replied"

    # Re-activate if in a dormant/terminal status
    REACTIVATABLE = (
        "pending", "error", "closed_happy", "closed_unhappy", "exhausted",
        "messaged", "connected", "invited", "sending", "sending_followup",
        "replied", "hot_lead",
    )
    if old_status in REACTIVATABLE:
        await run_db(update_outreach, outreach_id, status=new_status)

    # Save the prospect message
    await run_db(save_message, outreach_id, role="prospect", text=text, sentiment=sentiment)

    await run_db(
        log_action,
        "outreach_reactivated_inbound",
        outreach_id=outreach_id,
        result="success",
        details={
            "contact_name": contact.get("name", ""),
            "previous_status": old_status,
            "new_status": new_status,
            "sentiment": sentiment,
            "text_preview": text[:100],
        },
    )
    logger.info(
        "Re-activated outreach %s for contact %s (%s -> %s, sentiment=%s)",
        outreach_id[:8], contact.get("name", ""), old_status, new_status, sentiment,
    )


# ── CLASSIFY ─────────────────────────────────────────────────────────────────

async def _classify_all_new() -> str:
    """Run AI qualification on all new (unclassified) inbound signals.

    Returns summary string.
    """
    new_signals = await run_db(list_inbound_signals, status="new", limit=20)
    if not new_signals:
        return ""

    active_icps = await run_db(list_icps, status="active")

    classified_count = 0
    high_conf = 0

    for signal in new_signals:
        profile = {
            "name": signal.get("sender_name", ""),
            "headline": signal.get("sender_headline", ""),
            "company": signal.get("sender_company", ""),
            "title": signal.get("sender_headline", ""),
        }
        content = signal.get("content", "")
        signal_type = signal.get("signal_type", "invitation")

        try:
            qual = await qualify_inbound(
                profile=profile,
                content=content,
                signal_type=signal_type,
                active_icps=active_icps,
            )

            # Resolve best-matching campaign
            campaign_id = _resolve_campaign_for_signal(signal)

            await run_db(
                update_inbound_signal,
                signal["id"],
                intent=qual.intent,
                matched_icp_id=qual.matched_icp_id,
                confidence=qual.confidence,
                recommended_action=qual.recommended_action,
                reasoning=qual.reasoning,
                status="classified",
                qualified_at=int(time.time()),
                **({"campaign_id": campaign_id} if campaign_id else {}),
            )
            classified_count += 1
            if qual.confidence >= 0.7:
                high_conf += 1

        except Exception as e:
            logger.warning(
                "Failed to classify signal %s (%s): %s",
                signal["id"], signal.get("sender_name"), e,
            )

    summary = f"{classified_count}/{len(new_signals)} classified"
    if high_conf:
        summary += f", {high_conf} high-confidence"
    return summary


# ── ACT: Invitations ────────────────────────────────────────────────────────

async def _act_on_invitations(client: Any, account_id: str) -> str:
    """Accept, ignore, or decline invitations based on classification.

    Decision matrix:
    - buying_signal (conf >= 0.4) → Accept + queue for DM
    - networking (conf >= 0.4) → Accept (no DM)
    - partnership (any) → Accept (no DM)
    - unknown (conf >= 0.3) → Accept + monitor
    - unknown (conf < 0.3) → Ignore (leave pending)
    - job_seeking → Dismiss (ignore)
    - spam → Ignore (or decline if INBOUND_DECLINE_SPAM=True)
    - vendor_pitch (conf >= 0.8) → Ignore (or decline)
    - vendor_pitch (conf < 0.8) → Ignore
    """
    classified = await run_db(
        list_inbound_signals, status="classified", signal_type="invitation", limit=DAILY_INBOUND_ACCEPT_LIMIT,
    )
    if not classified:
        return ""

    accepted = 0
    ignored = 0
    declined = 0
    dismissed = 0
    errors = 0

    for signal in classified:
        intent = signal.get("intent", "unknown")
        confidence = signal.get("confidence", 0) or 0
        invitation_id = signal.get("invitation_id", "")
        now = int(time.time())

        # Determine action
        action = _decide_invitation_action(intent, confidence)

        if action == "accept":
            if not invitation_id:
                # No invitation_id — can't accept, mark as accepted anyway
                # (may have been auto-accepted by LinkedIn)
                await run_db(
                    update_inbound_signal, signal["id"],
                    status="accepted", actioned_at=now,
                )
                accepted += 1
                continue

            try:
                result = await client.handle_invitation(
                    account_id, invitation_id, action="accept",
                )
                if result.get("success"):
                    await run_db(
                        update_inbound_signal, signal["id"],
                        status="accepted", actioned_at=now,
                    )
                    await run_db(
                        log_action,
                        "inbound_invitation_accepted",
                        result="success",
                        details={
                            "signal_id": signal["id"],
                            "sender_name": signal.get("sender_name", ""),
                            "intent": intent,
                            "confidence": confidence,
                        },
                    )
                    accepted += 1
                    logger.info(
                        "Accepted invitation from %s (intent=%s, conf=%.2f)",
                        signal.get("sender_name", "Unknown"), intent, confidence,
                    )
                else:
                    error = result.get("error", "unknown")
                    if "not found" in error.lower() or "already" in error.lower():
                        # Already handled — mark as accepted
                        await run_db(
                            update_inbound_signal, signal["id"],
                            status="accepted", actioned_at=now,
                        )
                        accepted += 1
                    else:
                        errors += 1
                        logger.warning("Failed to accept invitation %s: %s", invitation_id, error)
            except Exception as e:
                errors += 1
                logger.warning("Error accepting invitation %s: %s", invitation_id, e)

        elif action == "decline":
            if invitation_id:
                try:
                    result = await client.handle_invitation(
                        account_id, invitation_id, action="decline",
                    )
                    if result.get("success"):
                        await run_db(
                            update_inbound_signal, signal["id"],
                            status="declined",
                            actioned_at=now,
                            decline_reason=f"Auto-declined: {intent} (conf={confidence:.2f})",
                        )
                        await run_db(
                            log_action,
                            "inbound_invitation_declined",
                            result="success",
                            details={
                                "signal_id": signal["id"],
                                "sender_name": signal.get("sender_name", ""),
                                "intent": intent,
                            },
                        )
                        declined += 1
                    else:
                        # Decline failed — fall back to ignore
                        await run_db(
                            update_inbound_signal, signal["id"],
                            status="ignored", actioned_at=now,
                        )
                        ignored += 1
                except Exception:
                    await run_db(
                        update_inbound_signal, signal["id"],
                        status="ignored", actioned_at=now,
                    )
                    ignored += 1
            else:
                await run_db(
                    update_inbound_signal, signal["id"],
                    status="dismissed", actioned_at=now,
                )
                dismissed += 1

        elif action == "dismiss":
            await run_db(
                update_inbound_signal, signal["id"],
                status="dismissed", actioned_at=now,
            )
            dismissed += 1

        else:  # ignore
            await run_db(
                update_inbound_signal, signal["id"],
                status="ignored", actioned_at=now,
            )
            ignored += 1

    parts = []
    if accepted:
        parts.append(f"{accepted} accepted")
    if ignored:
        parts.append(f"{ignored} ignored")
    if declined:
        parts.append(f"{declined} declined")
    if dismissed:
        parts.append(f"{dismissed} dismissed")
    if errors:
        parts.append(f"{errors} errors")
    return ", ".join(parts)


def _decide_invitation_action(intent: str, confidence: float) -> str:
    """Return 'accept', 'ignore', 'decline', or 'dismiss' for an invitation."""
    if intent in ("spam",):
        return "decline" if INBOUND_DECLINE_SPAM else "dismiss"

    if intent in ("vendor_pitch",):
        if confidence >= 0.8:
            return "decline" if INBOUND_DECLINE_SPAM else "dismiss"
        return "ignore"

    if intent in ("job_seeking",):
        return "dismiss"

    if intent in ("buying_signal",):
        return "accept" if confidence >= INBOUND_ACCEPT_CONFIDENCE else "ignore"

    if intent in ("partnership",):
        return "accept"

    if intent in ("networking",):
        return "accept" if confidence >= INBOUND_ACCEPT_CONFIDENCE else "ignore"

    # unknown
    return "accept" if confidence >= 0.3 else "ignore"


# ── ACT: Messages ───────────────────────────────────────────────────────────

async def _act_on_messages(client: Any, account_id: str) -> str:
    """Send discovery DMs and/or reactions for classified inbound signals.

    Handles both invitation-based signals (after acceptance) and
    unsolicited DM signals.

    - High confidence (>= 0.7): send discovery DM immediately
    - Medium confidence (0.4-0.7): send reaction first, then discovery DM
    - Spam/vendor/job_seeking: already dismissed in invitation phase
    """
    # Process accepted invitations that need DMs
    accepted_inv = await run_db(
        list_inbound_signals, status="accepted", limit=20,
    )
    # Process classified messages (unsolicited DMs)
    classified_msg = await run_db(
        list_inbound_signals, status="classified", signal_type="message", limit=20,
    )

    all_signals = (accepted_inv or []) + (classified_msg or [])
    if not all_signals:
        return ""

    # Daily DM limit
    today_sent = await run_db(count_inbound_dms_today)
    if today_sent >= DAILY_INBOUND_DM_LIMIT:
        return f"Daily DM limit reached ({today_sent}/{DAILY_INBOUND_DM_LIMIT})"

    remaining = DAILY_INBOUND_DM_LIMIT - today_sent
    voice = await run_db(get_setting, "voice_signature", {})

    sent = 0
    skipped = 0
    errors = 0

    for signal in all_signals[:remaining]:
        intent = signal.get("intent", "unknown")
        confidence = signal.get("confidence", 0) or 0
        sender_id = signal.get("sender_id", "")

        # Skip intents that shouldn't get DMs
        if intent in ("spam", "job_seeking", "vendor_pitch"):
            await run_db(update_inbound_signal, signal["id"], status="dismissed")
            skipped += 1
            continue

        if not sender_id:
            skipped += 1
            continue

        # Skip if already a known campaign contact (re-activation handled in _detect_messages)
        existing_contact = await run_db(get_contact_by_linkedin_id, sender_id)
        if not existing_contact:
            sender_name = signal.get("sender_name", "")
            sender_company = signal.get("sender_company", "")
            if sender_name and sender_company:
                existing_contact = await run_db(
                    get_contact_by_name_company, sender_name, sender_company,
                )
        if existing_contact:
            skipped += 1
            continue

        # Friend / prior conversation guard
        signal_created_at = signal.get("created_at") or int(time.time())
        is_friend = await _has_prior_conversation(
            client, account_id, sender_id, signal_created_at,
        )
        if is_friend:
            await run_db(update_inbound_signal, signal["id"], status="dismissed")
            skipped += 1
            continue

        # For medium confidence DMs, send a reaction first
        message_id = signal.get("message_id", "")
        if message_id and 0.4 <= confidence < 0.7:
            try:
                react_result = await client.add_message_reaction(
                    account_id, message_id,
                )
                if react_result.get("success"):
                    await run_db(
                        update_inbound_signal, signal["id"], reaction_sent=1,
                    )
            except Exception as e:
                logger.debug("Reaction failed for message %s: %s (continuing)", message_id, e)

        # Generate and send discovery DM
        result = await _send_discovery_dm(
            client, account_id, signal, voice,
        )
        if result == "sent":
            sent += 1
            if today_sent + sent >= DAILY_INBOUND_DM_LIMIT:
                break
        elif result == "error":
            errors += 1
        else:
            skipped += 1

    parts = []
    if sent:
        parts.append(f"{sent} DMs sent")
    if skipped:
        parts.append(f"{skipped} skipped")
    if errors:
        parts.append(f"{errors} failed")
    return ", ".join(parts)


async def _send_discovery_dm(
    client: Any,
    account_id: str,
    signal: dict[str, Any],
    voice: dict[str, Any],
) -> str:
    """Generate and send a contextual reply to an inbound signal.

    Instead of a generic discovery question, generates a reply that
    addresses what the person actually said using the user's voice.

    Returns 'sent', 'skipped', or 'error'.
    """
    sender_id = signal.get("sender_id", "")
    intent = signal.get("intent", "unknown")
    confidence = signal.get("confidence", 0) or 0
    content = signal.get("content", "")

    prospect_profile = {
        "name": signal.get("sender_name", ""),
        "headline": signal.get("sender_headline", ""),
        "company": signal.get("sender_company", ""),
        "title": signal.get("sender_headline", ""),
    }

    # ── Step 1: Create contact + outreach BEFORE generating reply ──
    campaign_id = signal.get("campaign_id") or ""
    now = int(time.time())
    outreach_id = ""
    try:
        _sig_type = signal.get("signal_type", "invitation")
        _inbound_src = {
            "invitation": "inbound_invitation",
            "dm": "inbound_dm",
            "message": "inbound_dm",
            "comment": "inbound_comment",
        }.get(_sig_type, "inbound_invitation")

        contact_id = await run_db(
            save_contact,
            campaign_id=campaign_id,
            name=signal.get("sender_name", ""),
            title=signal.get("sender_headline", ""),
            company=signal.get("sender_company", ""),
            linkedin_url=signal.get("sender_url", ""),
            linkedin_id=sender_id,
            source=_inbound_src,
        )

        # Classify sentiment of the inbound message
        sentiment = "neutral"
        if content:
            try:
                sentiment = await classify_sentiment(content)
            except Exception as e:
                logger.debug("Sentiment classification failed: %s", e)

        # Skip opt-outs entirely
        if sentiment == "opt_out":
            await run_db(update_inbound_signal, signal["id"], status="dismissed")
            return "skipped"

        # Map sentiment to outreach status
        status_map = {
            "positive": "hot_lead",
            "question": "replied",
            "negative": "replied",
            "neutral": "replied",
            "out_of_office": "replied",
        }
        initial_status = status_map.get(sentiment, "replied")

        outreach_id = await run_db(
            create_outreach,
            campaign_id=campaign_id,
            contact_id=contact_id,
            status=initial_status,
            signal_id=signal["id"],
        )
        await run_db(update_outreach, outreach_id, accepted_at=now)

        # Save the inbound prospect message with sentiment
        if content:
            await run_db(
                save_message, outreach_id,
                role="prospect", text=content, sentiment=sentiment,
            )
    except Exception as e:
        logger.warning(
            "Failed to create outreach for inbound %s: %s",
            signal.get("sender_name"), e,
        )
        # Continue anyway — try to send the reply even without tracking

    # ── Step 2: Generate contextual reply ──
    try:
        sender_profile = await run_db(get_setting, "profile", {})
        dm_text = await _generate_contextual_reply(
            prospect=prospect_profile,
            sender_profile=sender_profile,
            voice_signature=voice,
            content=content,
            sentiment=sentiment if content else "neutral",
            signal=signal,
        )
        if not dm_text:
            return "skipped"

        # ── Step 3: Send the reply ──
        chat_id = await client.find_chat_for_user(account_id, sender_id)
        if chat_id:
            result = await client.send_message(
                account_id=account_id,
                chat_id=chat_id,
                text=dm_text,
            )
        else:
            result = await client.send_new_message(
                account_id=account_id,
                provider_id=sender_id,
                text=dm_text,
            )

        if result.get("success"):
            # Save SDR reply and update tracking
            if outreach_id:
                await run_db(save_message, outreach_id, role="sdr", text=dm_text)
                await run_db(
                    update_inbound_signal, signal["id"],
                    status="engaged",
                    outreach_id=outreach_id,
                    actioned_at=now,
                )
            else:
                await run_db(
                    update_inbound_signal, signal["id"],
                    status="engaged", actioned_at=now,
                )

            await run_db(
                log_action,
                "inbound_contextual_reply_sent",
                result="success",
                details={
                    "signal_id": signal["id"],
                    "sender_name": signal.get("sender_name", ""),
                    "intent": intent,
                    "confidence": confidence,
                    "sentiment": sentiment if content else "unknown",
                    "dm_text": dm_text[:200],
                },
            )
            return "sent"

        # Send failed — handle DM attempt tracking
        dm_attempts = (signal.get("dm_attempts") or 0) + 1
        if dm_attempts >= INBOUND_MAX_DM_ATTEMPTS:
            await run_db(
                update_inbound_signal, signal["id"],
                status="dismissed", dm_attempts=dm_attempts,
            )
            return "skipped"
        else:
            await run_db(
                update_inbound_signal, signal["id"],
                dm_attempts=dm_attempts,
            )
            return "skipped"

    except Exception as e:
        logger.warning(
            "Failed to send contextual reply to %s: %s",
            signal.get("sender_name"), e,
        )
        return "error"


async def _generate_contextual_reply(
    prospect: dict[str, Any],
    sender_profile: dict[str, Any],
    voice_signature: dict[str, Any],
    content: str,
    sentiment: str,
    signal: dict[str, Any],
) -> str:
    """Generate a contextual reply using the reply generator with improve/validate/fix.

    Falls back to generate_discovery_question() if reply generation fails.
    """
    if not content:
        # No message content (e.g. invitation with no note) — fall back to discovery
        return await _fallback_discovery(prospect, voice_signature, signal)

    conversation_history = [{"role": "prospect", "text": content}]
    campaign_context = {"target_description": "", "relevance_hook": "", "booking_link": ""}

    try:
        result = await generate_reply(
            prospect=prospect,
            sender_profile=sender_profile,
            voice_signature=voice_signature,
            campaign_context=campaign_context,
            conversation_history=conversation_history,
            reply_text=content,
            sentiment=sentiment,
        )
        dm_text = result.get("message", "")
        if not dm_text:
            return await _fallback_discovery(prospect, voice_signature, signal)

        # ── Improve → Validate → Fix pipeline ──
        try:
            dm_text = await improve_message(dm_text, voice_signature, message_type="reply", max_chars=500)
        except Exception as e:
            logger.debug("Improve failed for inbound reply: %s", e)

        validation = validate_reply(dm_text, voice_signature, sentiment=sentiment)
        if not validation.is_valid:
            try:
                dm_text = await fix_message(
                    dm_text, validation.issues, voice_signature,
                    message_type="reply", max_chars=500,
                )
            except Exception as e:
                logger.debug("Fix failed for inbound reply: %s", e)

        return dm_text

    except Exception as e:
        logger.warning("Contextual reply generation failed: %s — falling back to discovery", e)
        return await _fallback_discovery(prospect, voice_signature, signal)


async def _fallback_discovery(
    prospect: dict[str, Any],
    voice: dict[str, Any],
    signal: dict[str, Any],
) -> str:
    """Fall back to generic discovery question when contextual reply fails."""
    qual = InboundQualification(
        intent=signal.get("intent", "unknown"),
        matched_icp_id=signal.get("matched_icp_id"),
        confidence=signal.get("confidence", 0) or 0,
        recommended_action=signal.get("recommended_action", "ask_purpose"),
        reasoning=signal.get("reasoning", ""),
    )
    try:
        result = await generate_discovery_question(
            sender_profile=prospect,
            signal_type=signal.get("signal_type", "invitation"),
            content=signal.get("content", ""),
            voice=voice,
            qualification=qual,
        )
        return result.get("message", "")
    except Exception as e:
        logger.warning("Discovery question fallback also failed: %s", e)
        return ""


# ── Helpers ──────────────────────────────────────────────────────────────────

async def _has_prior_conversation(
    client: Any,
    account_id: str,
    sender_id: str,
    signal_created_at: int,
    buffer_seconds: int = 300,
) -> bool:
    """Return True if a pre-existing conversation exists with this person."""
    try:
        chat_id = await client.find_chat_for_user(account_id, sender_id)
        if not chat_id:
            return False

        messages = await client.get_chat_messages(account_id, chat_id, limit=10)
        if not messages:
            return False

        cutoff = signal_created_at - buffer_seconds
        for msg in messages:
            ts = msg.get("timestamp", 0)
            if ts and ts < cutoff:
                return True

        return False
    except Exception as e:
        logger.debug(
            "Prior conversation check failed for sender %s: %s — allowing DM",
            sender_id, e,
        )
        return False


def _resolve_campaign_for_signal(signal: dict[str, Any]) -> str:
    """Resolve the best campaign for a qualified inbound signal."""
    from .signal_linker import _match_best_campaign

    active_campaigns = list_campaigns(status="active")
    if not active_campaigns:
        return ""

    signal_for_matching = {
        "content": (signal.get("content") or ""),
        "prospect_title": (signal.get("sender_headline") or ""),
        "metadata_json": "",
    }

    campaign_id = _match_best_campaign(signal_for_matching, active_campaigns)
    return campaign_id or ""
