"""Job executors — bridge between scheduler jobs and existing tool internals.

Each executor wraps an existing tool's `run_*()` function, passing the appropriate
parameters from the job record. This ensures all existing logic (validation, rate
limits, message generation, etc.) is reused — the scheduler just decides WHEN to run.

All executors return the string result from the tool function. Errors are raised
as exceptions for the engine to handle (retry/fail).
"""

from __future__ import annotations

import logging
import re
import time
from typing import Any

from ..db.async_bridge import run_db
from ..constants import (
    JOB_ACCEPT_INBOUND,
    JOB_ACTIVATE_SIGNALS,
    JOB_PROCESS_INBOUND,
    JOB_AUTO_REPLY,
    JOB_BACKFILL_POST_ANALYSIS,
    JOB_BRAND_ANALYZE,
    JOB_BRAND_ENGAGE,
    JOB_BRAND_POST,
    JOB_CHECK_POST_COMMENTS,
    JOB_CHECK_REPLIES,
    JOB_CLASSIFY_POST_INTENT,
    JOB_CLASSIFY_SIGNALS,
    JOB_COLLECT_COMPETITOR_SIGNALS,
    JOB_COLLECT_COMPANY_FOLLOWERS,
    JOB_COLLECT_COMPANY_PAGE,
    JOB_COLLECT_HIRING_SIGNALS,
    JOB_COLLECT_KEYWORD_SIGNALS,
    JOB_COLLECT_NEWS_SIGNALS,
    JOB_COLLECT_POSTS_DISTRIBUTED,
    JOB_COLLECT_PROFILE_VIEWS,
    JOB_DETECT_COMPOUND_INTENT,
    JOB_DETECT_JOB_CHANGES,
    JOB_DETECT_VIRAL_POSTS,
    JOB_MINE_COMMENTS,
    JOB_RESEARCH_CONTACTS,
    JOB_SIGNAL_DECAY_CYCLE,
    JOB_REMATCH_SIGNALS,
    JOB_BACKFILL_ORPHAN_SIGNALS,
    JOB_TUNE_WATCHLISTS,
    JOB_EMAIL_INVITE,
    JOB_ENDORSE,
    JOB_ENGAGE,
    JOB_FOLLOW,
    JOB_FOLLOWUP,
    JOB_INVITE,
    JOB_SEND_DM,
    JOB_PROFILE_VIEW,
    JOB_DAILY_DIGEST,
    JOB_DAILY_STRATEGY,
    JOB_EXECUTE_STRATEGY_PLANS,
    JOB_PARTNER_REMINDER,
    JOB_CAMPAIGN_REFILL,
    JOB_SYNC_CONNECTIONS,
    JOB_VERIFY_ACTIONS,
    JOB_QUALIFY_INBOUND,
    JOB_SCAN_PROSPECT_POSTS,
    JOB_WITHDRAW_INVITE,
    STALE_INVITE_DAYS,
)

logger = logging.getLogger(__name__)


def _check_outreach_still_valid(job: dict[str, Any], allowed_statuses: tuple[str, ...]) -> str | None:
    """Re-validate outreach status before executing a scheduled job.

    Returns an error message if the outreach should be skipped, or None if OK.
    This prevents sending messages to prospects whose status changed between
    job creation and job execution (e.g., prospect replied, opted out, etc.).
    """
    outreach_id = job.get("outreach_id", "")
    if not outreach_id:
        return None  # Pool-based jobs pick their own candidate

    from ..db.queries import get_outreach

    outreach = get_outreach(outreach_id)
    if not outreach:
        return f"Outreach {outreach_id[:8]} no longer exists — skipping"

    status = outreach.get("status", "")
    if status not in allowed_statuses:
        logger.info(
            "Pre-exec check: outreach %s status is '%s' (expected %s) — skipping job",
            outreach_id[:8], status, allowed_statuses,
        )
        return (
            f"Outreach status changed to '{status}' since job was scheduled — "
            f"skipping (expected one of {allowed_statuses})"
        )

    return None


def _check_recent_messages(outreach_id: str, max_messages_per_day: int = 2) -> str | None:
    """Prevent rapid-fire messaging to the same prospect.

    Only counts SDR messages sent AFTER the last prospect reply — each new
    prospect message resets the counter so we always send at least one reply.
    Also enforces a hard 24h window to prevent runaway loops.

    Returns an error message if too many messages were sent, or None if OK.
    """
    if not outreach_id:
        return None

    from ..db.queries import get_messages_for_outreach

    messages = get_messages_for_outreach(outreach_id)
    if not messages:
        return None

    now = int(time.time())
    today_start = now - 86400  # Last 24 hours

    # Find the timestamp of the latest prospect message
    last_prospect_ts = 0
    for m in messages:
        ts = m.get("created_at") or m.get("timestamp") or 0
        if m.get("role") == "prospect" and ts > last_prospect_ts:
            last_prospect_ts = ts

    # Count SDR messages sent AFTER the last prospect reply (within 24h)
    # This ensures each new prospect message resets the counter
    cutoff = max(today_start, last_prospect_ts)
    sdr_messages_since_reply = sum(
        1 for m in messages
        if m.get("role") == "sdr"
        and (m.get("created_at") or m.get("timestamp") or 0) > cutoff
    )

    if sdr_messages_since_reply >= max_messages_per_day:
        logger.info(
            "Rate limit: %d messages already sent to outreach %s since last reply (limit=%d)",
            sdr_messages_since_reply, outreach_id[:8], max_messages_per_day,
        )
        return (
            f"Already sent {sdr_messages_since_reply} messages to this prospect "
            f"since their last reply (limit: {max_messages_per_day})"
        )

    return None


# HTTP status codes indicating permanent engagement failure
_PERMANENT_FAIL_CODES = {400, 422, 403, 404}
# Pattern to extract HTTP status codes from error strings
_STATUS_CODE_RE = re.compile(r"\b(4\d{2})\b")
# Stricter pattern: status code preceded by HTTP-context words
_HTTP_STATUS_RE = re.compile(
    r"(?:http|status|returned|unipile|linkedin|code)[^\d]{0,10}(4\d{2})\b", re.IGNORECASE,
)

# Cooldown durations for engagement failures (seconds)
_COOLDOWN_PERMANENT_FAIL = 24 * 3600  # 24h — retry next day
_COOLDOWN_ENDORSE_FAIL = 7 * 24 * 3600  # 7d — skills unlikely to change fast


def _set_engagement_cooldown(outreach_id: str, seconds: int) -> None:
    """Set a time-limited engagement cooldown instead of permanent skip.

    Uses JSON format: {"skip_engagement_until": <unix_timestamp>}
    Already supported by the engagement candidates query.
    """
    import json as _json
    import time as _time

    cooldown_until = int(_time.time()) + seconds
    from ..db.queries import update_outreach

    update_outreach(
        outreach_id,
        next_action=_json.dumps({"skip_engagement_until": cooldown_until}),
    )


def _classify_422(error_msg: str) -> str:
    """Classify a 422 error into a sub-type.

    Sub-types:
        cannot_resend_yet        — already invited, treat as success
        already_connected        — already a connection
        temporary_provider_limit — LinkedIn throttle, retry later
        invitation_pending       — invitation already pending
        unknown_422              — unrecognised 422
    """
    lower = error_msg.lower()
    if "resend" in lower or "reinvite" in lower:
        return "cannot_resend_yet"
    if "already_connected" in lower or "already connected" in lower:
        return "already_connected"
    if "provider_limit" in lower or "temporary" in lower:
        return "temporary_provider_limit"
    if "pending" in lower:
        return "invitation_pending"
    return "unknown_422"


def categorize_error(error_msg: str) -> str:
    """Classify an error message into a category for filtering and analysis.

    Uses ``_extract_status_code()`` for reliable HTTP status detection instead
    of raw substring matching (avoids false positives like "processed 403 items").

    Categories:
        rate_limit           — 429 / rate limit responses from LinkedIn/Unipile
        voyager_unavailable  — 400 "malformed request" from Voyager raw routes
        permanent_failure    — 4xx errors that won't resolve on retry
        network_error        — timeouts, connection failures, DNS errors
        llm_error            — LLM service failures (Gemini, Claude, OpenAI)
        auth_error           — authentication / account disconnected
        unknown              — anything else
    """
    lower = error_msg.lower()
    # Use stricter pattern to avoid false matches on non-HTTP numbers
    _match = _HTTP_STATUS_RE.search(error_msg)
    status_code = int(_match.group(1)) if _match else 0

    # --- status-code based classification (reliable) ---
    if status_code == 429 or "rate limit" in lower or "too many requests" in lower:
        return "rate_limit"
    if status_code == 400:
        if "malformed request" in lower or "rejected by provider" in lower:
            return "voyager_unavailable"
        return "permanent_failure"
    if status_code == 401 or "unauthorized" in lower or "disconnected" in lower or "no linkedin account" in lower:
        return "auth_error"
    if status_code in (403, 404, 422):
        return "permanent_failure"

    # --- keyword-only fallbacks (no status code extracted) ---
    if any(w in lower for w in ("premium required", "subscription_required", "permission denied", "not a connection")):
        return "permanent_failure"
    if any(w in lower for w in ("timeout", "timed out", "connection", "dns", "reset by peer")):
        return "network_error"
    if any(w in lower for w in ("gemini", "claude", "openai", "llm", "model")):
        return "llm_error"
    return "unknown"


async def _get_campaign_config(campaign_id: str) -> dict:
    """Load and parse config_json for a campaign. Returns empty dict on failure."""
    import json
    from ..db.queries import get_campaign
    campaign = await run_db(get_campaign, campaign_id)
    if not campaign:
        return {}
    try:
        return json.loads(campaign.get("config_json") or "{}")
    except (json.JSONDecodeError, TypeError):
        return {}


async def execute_job(job: dict[str, Any]) -> str:
    """Route a scheduler job to the appropriate executor.

    Args:
        job: Full job record from scheduler_jobs table.

    Returns:
        String result from the tool execution.

    Raises:
        ValueError: Unknown job type.
        Exception: Any error from the tool execution.
    """
    job_type = job["job_type"]

    # Timezone-aware scheduling: for outreach actions (invite, followup),
    # check if it's business hours for the prospect before executing.
    # Respects per-campaign send_in_business_hours and active_days config.
    if job_type in (JOB_INVITE, JOB_SEND_DM, JOB_FOLLOWUP, JOB_AUTO_REPLY) and job.get("outreach_id"):
        campaign_id = job.get("campaign_id")
        should_check_hours = True
        if campaign_id:
            campaign_config = await _get_campaign_config(campaign_id)
            should_check_hours = campaign_config.get("send_in_business_hours", True)
            # Check active_days (0=Mon ... 6=Sun)
            active_days = campaign_config.get("active_days")
            if active_days and isinstance(active_days, list):
                import datetime
                today_weekday = datetime.datetime.now(datetime.timezone.utc).weekday()
                if today_weekday not in active_days:
                    from ..db.queries import reschedule_job
                    # Reschedule for next active day
                    days_ahead = 1
                    for i in range(1, 8):
                        if (today_weekday + i) % 7 in active_days:
                            days_ahead = i
                            break
                    delay = days_ahead * 86400
                    await run_db(reschedule_job, job["id"], int(__import__("time").time()) + delay)
                    logger.info(
                        "Rescheduled %s job — today not in active days (next in %d days)",
                        job_type, days_ahead,
                    )
                    try:
                        from ..db.queries import log_action
                        await run_db(
                            log_action, f"skip_{job_type}", result="rescheduled",
                            campaign_id=campaign_id or "",
                            details={"reason": "inactive_day", "days_ahead": days_ahead},
                        )
                    except Exception:
                        pass
                    return f"Rescheduled for active day (in {days_ahead} day{'s' if days_ahead != 1 else ''})"

        if should_check_hours:
            delay = await _check_prospect_business_hours(job["outreach_id"])
            if delay > 0:
                from ..db.queries import reschedule_job
                await run_db(reschedule_job, job["id"], int(__import__("time").time()) + delay)
                logger.info(
                    "Rescheduled %s job for outreach %s — prospect outside business hours (%d min)",
                    job_type, job["outreach_id"][:8], delay // 60,
                )
                try:
                    from ..db.queries import log_action
                    await run_db(
                        log_action, f"skip_{job_type}", result="rescheduled",
                        campaign_id=campaign_id or "",
                        details={"reason": "business_hours", "delay_min": delay // 60,
                                 "outreach_id": job["outreach_id"]},
                    )
                except Exception:
                    pass
                return f"Rescheduled for prospect business hours (in {delay // 60} min)"

    executors = {
        JOB_INVITE: _execute_invite,
        JOB_SEND_DM: _execute_send_dm,
        JOB_EMAIL_INVITE: _execute_email_invite,
        JOB_FOLLOWUP: _execute_followup,
        JOB_AUTO_REPLY: _execute_auto_reply,
        JOB_ENGAGE: _execute_engagement,
        JOB_FOLLOW: _execute_follow,
        JOB_PROFILE_VIEW: _execute_profile_view,
        JOB_ENDORSE: _execute_endorse,
        JOB_CHECK_REPLIES: _execute_check_replies,
        JOB_ACCEPT_INBOUND: _execute_accept_inbound,   # DEPRECATED: kept for in-flight jobs
        JOB_PROCESS_INBOUND: _execute_process_inbound, # Unified classify-first pipeline
        JOB_WITHDRAW_INVITE: _execute_withdraw_stale_invites,
        JOB_QUALIFY_INBOUND: _execute_qualify_inbound, # DEPRECATED: kept for in-flight jobs
        JOB_CHECK_POST_COMMENTS: _execute_check_post_comments,
        JOB_BRAND_POST: _execute_brand_post,
        JOB_BRAND_ENGAGE: _execute_brand_engage,
        JOB_BRAND_ANALYZE: _execute_brand_analyze,
        JOB_COLLECT_KEYWORD_SIGNALS: _execute_collect_keyword_signals,
        JOB_SCAN_PROSPECT_POSTS: _execute_scan_prospect_posts,
        JOB_CLASSIFY_SIGNALS: _execute_classify_signals,
        JOB_COLLECT_PROFILE_VIEWS: _execute_collect_profile_views,
        JOB_DETECT_JOB_CHANGES: _execute_detect_job_changes,
        JOB_COLLECT_COMPETITOR_SIGNALS: _execute_collect_competitor_signals,
        JOB_COLLECT_HIRING_SIGNALS: _execute_collect_hiring_signals,
        JOB_COLLECT_NEWS_SIGNALS: _execute_collect_news_signals,
        JOB_COLLECT_COMPANY_PAGE: _execute_collect_company_page,
        JOB_COLLECT_COMPANY_FOLLOWERS: _execute_collect_company_followers,
        JOB_ACTIVATE_SIGNALS: _execute_activate_signals,
        JOB_DETECT_COMPOUND_INTENT: _execute_detect_compound_intent,
        JOB_SIGNAL_DECAY_CYCLE: _execute_decay_cycle,
        JOB_REMATCH_SIGNALS: _execute_rematch_signals,
        JOB_BACKFILL_ORPHAN_SIGNALS: _execute_backfill_orphan_signals,
        JOB_PARTNER_REMINDER: _execute_partner_reminder,
        JOB_DAILY_DIGEST: _execute_daily_digest,
        JOB_DAILY_STRATEGY: _execute_daily_strategy,
        JOB_EXECUTE_STRATEGY_PLANS: _execute_strategy_plans,
        JOB_VERIFY_ACTIONS: _execute_verify_actions,
        JOB_SYNC_CONNECTIONS: _execute_sync_connections,
        JOB_COLLECT_POSTS_DISTRIBUTED: _execute_collect_posts_distributed,
        JOB_RESEARCH_CONTACTS: _execute_research_contacts,
        JOB_BACKFILL_POST_ANALYSIS: _execute_backfill_post_analysis,
        JOB_DETECT_VIRAL_POSTS: _execute_detect_viral_posts,
        JOB_TUNE_WATCHLISTS: _execute_tune_watchlists,
        JOB_CLASSIFY_POST_INTENT: _execute_classify_post_intent,
        JOB_MINE_COMMENTS: _execute_mine_comments,
        JOB_CAMPAIGN_REFILL: _execute_campaign_refill,
    }

    executor = executors.get(job_type)
    if not executor:
        raise ValueError(f"Unknown job type: {job_type}")

    return await executor(job)


async def _check_prospect_business_hours(outreach_id: str) -> int:
    """Check if it's business hours for this prospect. Returns delay in seconds (0 = OK)."""
    try:
        from ..db.queries import get_outreach_with_contact
        from ..services.timezone_resolver import (
            get_prospect_location,
            is_business_hours,
            resolve_utc_offset,
            seconds_until_business_hours,
        )

        record = await run_db(get_outreach_with_contact, outreach_id)
        if not record:
            return 0

        location = get_prospect_location(record)
        if not location:
            return 0  # No location — send anytime

        utc_offset = resolve_utc_offset(location)
        if is_business_hours(utc_offset):
            return 0

        return seconds_until_business_hours(utc_offset)
    except Exception as e:
        logger.debug("Business hours check failed (non-fatal): %s", e)
        return 0


async def _maybe_toggle_otw(campaign_id: str) -> None:
    """Toggle Open-to-Work badge based on campaign config before invite batch."""
    import json

    from ..db.queries import get_campaign, get_setting
    from ..linkedin import get_account_id, get_linkedin_client

    campaign = await run_db(get_campaign, campaign_id)
    if not campaign:
        return
    try:
        config = json.loads(campaign.get("config_json") or "{}")
    except (json.JSONDecodeError, TypeError):
        return

    otw_mode = config.get("open_to_work_mode", "off")
    if otw_mode == "off":
        return

    account_id = get_account_id()
    profile = await run_db(get_setting, "profile", {})
    provider_id = profile.get("provider_id", "")
    if not account_id or not provider_id:
        return

    from ..tools.profile_editor import apply_profile_change

    if otw_mode == "off_for_outbound":
        # Disable OTW when doing outbound invites
        try:
            client = get_linkedin_client()
            await apply_profile_change(
                client, account_id, provider_id, "open_to_work",
                json.dumps({"enabled": "false"}), source="scheduler_otw",
            )
            await client.close()
            logger.info("OTW disabled for outbound campaign %s", campaign_id)
        except Exception as e:
            logger.debug("OTW toggle failed (non-fatal): %s", e)

    elif otw_mode == "on_for_inbound":
        # Enable OTW to attract inbound connections
        try:
            client = get_linkedin_client()
            await apply_profile_change(
                client, account_id, provider_id, "open_to_work",
                json.dumps({"enabled": "true"}), source="scheduler_otw",
            )
            await client.close()
            logger.info("OTW enabled for inbound campaign %s", campaign_id)
        except Exception as e:
            logger.debug("OTW toggle failed (non-fatal): %s", e)


async def _execute_send_dm(job: dict[str, Any]) -> str:
    """Execute a cold DM to an existing connection (connections-only campaigns).

    Calls run_generate_and_send() with force_channel="dm" which skips the
    invitation path and sends a direct message instead.
    """
    from ..tools.generate_send import run_generate_and_send

    campaign_id = job["campaign_id"]
    outreach_id = job.get("outreach_id", "")
    logger.info("Scheduler: sending DM for connections-only campaign %s (outreach=%s)", campaign_id, outreach_id or "pool")

    # Pre-execution safety: re-validate status and message rate
    skip = _check_outreach_still_valid(job, ("pending", "connected"))
    if skip:
        return skip
    skip = _check_recent_messages(outreach_id)
    if skip:
        return skip

    result = await run_generate_and_send(
        campaign_id=campaign_id,
        mode="autopilot",
        force_channel="dm",
        target_outreach_id=outreach_id,
    )

    if result and ("❌" in result or "failed" in result.lower()):
        raise RuntimeError(f"DM send failed: {result[:200]}")

    logger.info("Scheduler: DM result for campaign %s: %s", campaign_id, result[:100])
    await _track_plan_execution(job, "send_dm", result or "")
    return result


async def _execute_invite(job: dict[str, Any]) -> str:
    """Execute an invitation job.

    Calls run_generate_and_send() with mode="autopilot" for the campaign.
    The tool internally picks the next pending prospect by fit_score.

    Error handling:
    - 422 with 'cannot_resend_yet' → already invited, don't retry
    - Other failures → raise for normal retry logic
    """
    from ..tools.generate_send import run_generate_and_send

    campaign_id = job["campaign_id"]
    logger.info("Scheduler: sending invitation for campaign %s", campaign_id)

    # Pre-execution safety: re-validate status
    skip = _check_outreach_still_valid(job, ("pending",))
    if skip:
        return skip

    # Toggle OTW badge based on campaign config
    await _maybe_toggle_otw(campaign_id)

    # Swap headline if a headline A/B test is running
    from ..services.experiment_service import swap_headline_for_batch

    headline_variant = await swap_headline_for_batch(campaign_id)
    if headline_variant:
        logger.info("Headline swapped to variant %s for campaign %s", headline_variant, campaign_id)

    outreach_id = job.get("outreach_id", "")
    logger.info("Scheduler: sending invitation for campaign %s (outreach=%s)", campaign_id, outreach_id or "pool")

    result = await run_generate_and_send(
        campaign_id=campaign_id,
        mode="autopilot",
        target_outreach_id=outreach_id,
    )

    # Check if the result indicates an error (tools return error strings)
    if result and ("❌" in result or "failed" in result.lower()):
        # Check for 422 sub-types that should not be retried
        status_code = _extract_status_code(result)
        if status_code == 422 or "cannot_resend_yet" in result.lower():
            sub_type = _classify_422(result)
            if sub_type in ("cannot_resend_yet", "already_connected", "invitation_pending"):
                logger.warning(
                    "Invite 422 (%s) for campaign %s — completing without retry",
                    sub_type, campaign_id,
                )
                return result  # Don't raise — idempotent
            if sub_type == "temporary_provider_limit":
                # Auto-withdraw oldest invite to free a spot, then retry
                logger.warning(
                    "Invite 422 (temporary_provider_limit) for campaign %s — "
                    "attempting auto-withdraw to free spot",
                    campaign_id,
                )
                try:
                    from ..linkedin import get_account_id, get_linkedin_client
                    from ..linkedin.rate_limiter import withdraw_oldest_to_free_spot
                    _acct = get_account_id()
                    if _acct:
                        _client = get_linkedin_client()
                        wd = await withdraw_oldest_to_free_spot(_client, _acct)
                        if wd.get("success"):
                            logger.info(
                                "Auto-withdrew invite for %s (%d days old) — retrying invitation",
                                wd.get("name", "?"), wd.get("days_old", 0),
                            )
                except Exception as wd_err:
                    logger.warning("Auto-withdraw in executor failed: %s", wd_err)
                raise RuntimeError(f"Rate limited (422): {result[:200]}")

        raise RuntimeError(f"Invitation failed: {result[:200]}")

    logger.info("Scheduler: invitation result for campaign %s: %s", campaign_id, result[:100])
    await _track_plan_execution(job, "invite", result or "")
    return result


async def _execute_email_invite(job: dict[str, Any]) -> str:
    """Execute an email invitation job (overflow from LinkedIn limits).

    Calls run_generate_and_send() with force_channel="email" for a specific
    outreach_id. The tool will use the email path directly.
    """
    from ..tools.generate_send import run_generate_and_send

    campaign_id = job["campaign_id"]
    outreach_id = job.get("outreach_id")

    if not outreach_id:
        return "Email invite job missing outreach_id"

    logger.info(
        "Scheduler: sending email invite for outreach %s (campaign %s, LinkedIn overflow)",
        outreach_id[:8], campaign_id,
    )

    result = await run_generate_and_send(
        campaign_id=campaign_id,
        mode="autopilot",
        force_channel="email",
        target_outreach_id=outreach_id,
    )

    # Check for errors
    if result and ("❌" in result or "failed" in result.lower()):
        raise RuntimeError(f"Email invite failed: {result[:200]}")

    logger.info(
        "Scheduler: email invite result for campaign %s: %s",
        campaign_id, (result or "")[:100],
    )
    return result


async def _execute_followup(job: dict[str, Any]) -> str:
    """Execute a follow-up message job.

    Calls run_send_followup() with mode="autopilot" for a specific outreach.
    If outreach_id is set, it targets that specific outreach.
    Reads campaign voice_mode to determine message format (text/voice).
    """
    from ..tools.send_followup import run_send_followup

    campaign_id = job["campaign_id"]
    outreach_id = job.get("outreach_id", "")
    logger.info("Scheduler: sending follow-up for outreach %s", outreach_id or "next")

    # Pre-execution safety: re-validate status and message rate
    skip = _check_outreach_still_valid(job, ("connected", "messaged"))
    if skip:
        return skip
    skip = _check_recent_messages(outreach_id)
    if skip:
        return skip

    # Determine format based on campaign voice_mode
    fmt = "text"
    if outreach_id:
        try:
            from ..services.experiment_service import get_voice_format_for_outreach
            fmt = get_voice_format_for_outreach(campaign_id, outreach_id)
        except Exception as e:
            logger.debug("Voice format lookup failed (using text): %s", e)

    result = await run_send_followup(
        campaign_id=campaign_id,
        outreach_id=outreach_id or "",
        mode="autopilot",
        format=fmt,
    )

    if result and ("❌" in result or "failed" in result.lower()):
        raise RuntimeError(f"Follow-up failed: {result[:200]}")

    logger.info("Scheduler: follow-up result (format=%s): %s", fmt, result[:100])
    await _track_plan_execution(job, "followup", result or "")
    return result


async def _check_engagement_budget_or_defer(job: dict[str, Any], action_label: str) -> str | None:
    """Check global engagement budget. If exhausted, re-queue the job.

    Returns a deferral message if over budget, or None if budget is available.
    """
    from ..constants import ENGAGEMENT_BUDGET_REQUEUE_DELAY
    from ..db.queries import log_action, reschedule_job
    from ..linkedin.rate_limiter import check_engagement_budget

    has_budget, current, limit = check_engagement_budget()
    if has_budget:
        return None

    # Re-queue with delay instead of executing now
    new_time = int(time.time()) + ENGAGEMENT_BUDGET_REQUEUE_DELAY
    reschedule_job(job["id"], new_time)
    log_action(
        "engagement_budget_deferred",
        outreach_id=job.get("outreach_id", ""),
        campaign_id=job.get("campaign_id", ""),
        result="deferred",
        details={
            "job_type": action_label,
            "current": current,
            "limit": limit,
            "requeue_delay": ENGAGEMENT_BUDGET_REQUEUE_DELAY,
        },
    )
    logger.info(
        "Deferred %s job %s: engagement budget exhausted (%d/%d), re-queued in %d min",
        action_label, job["id"], current, limit, ENGAGEMENT_BUDGET_REQUEUE_DELAY // 60,
    )
    return f"Deferred: engagement budget exhausted ({current}/{limit})"


async def _execute_engagement(job: dict[str, Any]) -> str:
    """Execute an engagement warm-up job.

    Calls run_engage_prospect() with mode="autopilot" for a specific outreach.
    Action defaults to "auto" (comment if post has text, react otherwise).

    Error handling:
    - 422/403/404 in result → permanent failure, mark skip_engagement (no retry)
    - 429 in result → rate limited, raise to retry later
    - Other failures → raise for normal retry logic
    """
    deferred = await _check_engagement_budget_or_defer(job, "engage")
    if deferred:
        return deferred

    from ..db.queries import log_action, update_outreach
    from ..tools.engage_prospect import run_engage_prospect

    campaign_id = job["campaign_id"]
    outreach_id = job.get("outreach_id", "")
    logger.info("Scheduler: engaging prospect for outreach %s", outreach_id or "next")

    result = await run_engage_prospect(
        campaign_id=campaign_id,
        outreach_id=outreach_id or "",
        action="auto",
        mode="autopilot",
    )

    if result and ("❌" in result or "failed" in result.lower()):
        # Extract HTTP status code from error string
        status_code = _extract_status_code(result)

        if status_code in _PERMANENT_FAIL_CODES:
            # Permanent failure — post deleted, comments disabled, account issues
            # Set time-limited cooldown so prospect becomes eligible again after 24h
            if outreach_id:
                await run_db(_set_engagement_cooldown, outreach_id, _COOLDOWN_PERMANENT_FAIL)
                await run_db(
                    log_action,
                    "engagement_cooldown",
                    outreach_id=outreach_id,
                    result="cooldown_24h",
                    details={"status_code": status_code, "error": result[:200]},
                )
            logger.warning(
                "Engagement permanently failed (%d) for outreach %s — skip_engagement",
                status_code, outreach_id,
            )
            # Don't raise — job completes, outreach removed from candidate pool
            return result

        if status_code == 429:
            # Rate limited — raise to trigger retry, but will be cleaned up
            # if it keeps failing
            logger.warning("Engagement rate limited (429) for outreach %s", outreach_id)
            raise RuntimeError(f"Engagement rate limited (429): {result[:200]}")

        # Other failures — raise for normal retry logic
        raise RuntimeError(f"Engagement failed: {result[:200]}")

    logger.info("Scheduler: engagement result: %s", result[:100])
    await _track_plan_execution(job, "engage_comment", result or "")
    return result


def _extract_status_code(text: str) -> int:
    """Extract HTTP status code from an error string.

    Looks for 4xx codes in the text. Returns 0 if none found.
    """
    match = _STATUS_CODE_RE.search(text)
    return int(match.group(1)) if match else 0


async def _execute_profile_view(job: dict[str, Any]) -> str:
    """Execute a profile view job.

    Calls run_engage_prospect() with action="view" and mode="autopilot"
    for a specific outreach. Profile views warm up prospects by triggering
    a "X viewed your profile" notification — lightest touch.

    Error handling follows the same pattern as _execute_follow.
    """
    deferred = await _check_engagement_budget_or_defer(job, "profile_view")
    if deferred:
        return deferred

    from ..db.queries import log_action, update_outreach
    from ..tools.engage_prospect import run_engage_prospect

    campaign_id = job["campaign_id"]
    outreach_id = job.get("outreach_id", "")
    logger.info("Scheduler: viewing profile for outreach %s", outreach_id or "next")

    result = await run_engage_prospect(
        campaign_id=campaign_id,
        outreach_id=outreach_id or "",
        action="view",
        mode="autopilot",
    )

    if result and ("failed" in result.lower()):
        status_code = _extract_status_code(result)

        if status_code in _PERMANENT_FAIL_CODES:
            if outreach_id:
                await run_db(_set_engagement_cooldown, outreach_id, _COOLDOWN_PERMANENT_FAIL)
                await run_db(
                    log_action,
                    "profile_view_cooldown",
                    outreach_id=outreach_id,
                    result="cooldown_24h",
                    details={"status_code": status_code, "error": result[:200]},
                )
            logger.warning(
                "Profile view failed (%d) for outreach %s — cooldown 24h",
                status_code, outreach_id,
            )
            return result

        if status_code == 429:
            logger.warning("Profile view rate limited (429) for outreach %s", outreach_id)
            raise RuntimeError(f"Profile view rate limited (429): {result[:200]}")

        raise RuntimeError(f"Profile view failed: {result[:200]}")

    logger.info("Scheduler: profile view result: %s", result[:100] if result else "empty")
    await _track_plan_execution(job, "profile_view", result or "")
    return result or ""


async def _execute_follow(job: dict[str, Any]) -> str:
    """Execute a profile follow job.

    Calls run_engage_prospect() with action="follow" and mode="autopilot"
    for a specific outreach. Follows warm up prospects by triggering a
    "X started following you" notification before connecting.

    Error handling:
    - 422/403/404 → permanent failure, mark skip_engagement
    - 429 → rate limited, raise to retry later
    - Other failures → raise for normal retry logic
    """
    deferred = await _check_engagement_budget_or_defer(job, "follow")
    if deferred:
        return deferred

    from ..db.queries import log_action, update_outreach
    from ..tools.engage_prospect import run_engage_prospect

    campaign_id = job["campaign_id"]
    outreach_id = job.get("outreach_id", "")
    logger.info("Scheduler: following prospect for outreach %s", outreach_id or "next")

    result = await run_engage_prospect(
        campaign_id=campaign_id,
        outreach_id=outreach_id or "",
        action="follow",
        mode="autopilot",
    )

    if result and ("failed" in result.lower()):
        # Voyager endpoints may be temporarily unavailable — defer instead of failing
        if "voyager" in result.lower() and "unavailable" in result.lower():
            logger.info(
                "Follow deferred (Voyager unavailable) for outreach %s",
                outreach_id,
            )
            return f"Deferred: Voyager unavailable — {result[:100]}"

        status_code = _extract_status_code(result)

        if status_code in _PERMANENT_FAIL_CODES:
            if outreach_id:
                await run_db(_set_engagement_cooldown, outreach_id, _COOLDOWN_PERMANENT_FAIL)
                await run_db(
                    log_action,
                    "follow_cooldown",
                    outreach_id=outreach_id,
                    result="cooldown_24h",
                    details={"status_code": status_code, "error": result[:200]},
                )
            logger.warning(
                "Follow failed (%d) for outreach %s — cooldown 24h",
                status_code, outreach_id,
            )
            return result

        if status_code == 429:
            logger.warning("Follow rate limited (429) for outreach %s", outreach_id)
            raise RuntimeError(f"Follow rate limited (429): {result[:200]}")

        raise RuntimeError(f"Follow failed: {result[:200]}")

    logger.info("Scheduler: follow result: %s", result[:100])
    await _track_plan_execution(job, "follow", result or "")
    return result


async def _execute_check_replies(job: dict[str, Any]) -> str:
    """Execute a reply check job.

    Calls run_check_replies() which checks all active campaigns.
    """
    from ..tools.check_replies import run_check_replies

    logger.info("Scheduler: checking replies")

    result = await run_check_replies()

    # Reply checks don't typically fail hard — just log
    logger.info("Scheduler: reply check result: %s", result[:100] if result else "empty")
    return result or "No new replies"


async def _execute_auto_reply(job: dict[str, Any]) -> str:
    """Execute an auto-reply job. Always sends immediately (autopilot)."""
    from ..db.queries import get_campaign, log_action
    from ..tools.reply_to_prospect import run_reply_to_prospect

    campaign_id = job.get("campaign_id", "")
    outreach_id = job.get("outreach_id", "")

    if not outreach_id:
        raise ValueError("Auto-reply job missing outreach_id")

    mode = "autopilot"

    # Determine format based on campaign voice_mode
    fmt = "text"
    if campaign_id:
        try:
            from ..services.experiment_service import get_voice_format_for_outreach
            fmt = get_voice_format_for_outreach(campaign_id, outreach_id)
        except Exception as e:
            logger.debug("Voice format lookup failed (using text): %s", e)

    # Pre-execution safety: re-validate status and message rate
    skip = _check_outreach_still_valid(job, ("replied", "hot_lead", "connected", "messaged"))
    if skip:
        return skip
    skip = _check_recent_messages(outreach_id, max_messages_per_day=3)
    if skip:
        return skip

    logger.info(
        "Scheduler: auto-replying to outreach %s (mode=%s, format=%s)",
        outreach_id[:8], mode, fmt,
    )

    result = await run_reply_to_prospect(
        outreach_id=outreach_id,
        mode=mode,
        format=fmt,
    )

    # Detect failures — check for known failure phrases, not just "failed"/"error"
    _failure_phrases = ("failed", "error", "could not find", "not found", "setup required",
                        "no linkedin account", "no prospect", "cannot send", "limit reached",
                        "cap reached", "already sent", "opted out", "out of office")
    _is_failure = not result or any(p in result.lower() for p in _failure_phrases)

    # Log the auto-reply action for observability
    log_result = "error" if _is_failure else "success"
    await run_db(
        log_action,
        action_type="auto_reply_sent",
        outreach_id=outreach_id,
        result=log_result,
        details={"mode": mode, "format": fmt, "scheduler": True},
    )

    if _is_failure:
        raise RuntimeError(f"Auto-reply failed: {result[:200] if result else 'empty result'}")

    logger.info("Scheduler: auto-reply result (format=%s): %s", fmt, result[:100] if result else "empty")
    await _track_plan_execution(job, "auto_reply", result or "")
    return result or "Auto-reply completed"


async def _execute_process_inbound(job: dict[str, Any]) -> str:
    """Execute the unified classify-first inbound pipeline.

    Single pass: detect → classify → act (accept/ignore/respond).
    Replaces _execute_accept_inbound + _execute_qualify_inbound.
    """
    from ..services.inbound_pipeline import process_inbound_pipeline

    logger.info("Scheduler: running unified inbound pipeline")
    result = await process_inbound_pipeline()
    logger.info("Scheduler: inbound pipeline result: %s", result)
    return result


async def _execute_accept_inbound(job: dict[str, Any]) -> str:
    """DEPRECATED: Execute an inbound invitation auto-accept job.

    Qualify-then-accept-all flow:
    1. Fetch invitations
    2. Save each as inbound signal + qualify via LLM
    3. Accept ALL invitations (grow the network)
    4. Qualification results are used later by _execute_qualify_inbound
       to send discovery DMs.
    """
    from ..constants import DAILY_INBOUND_ACCEPT_LIMIT
    from ..db.queries import (
        get_inbound_signal_by_sender,
        log_action,
        save_inbound_signal,
    )
    from ..linkedin import UnipileError, get_account_id, get_linkedin_client

    logger.info("Scheduler: checking inbound invitations")

    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:
        invitations = await client.get_received_invitations(account_id)
    except Exception as e:
        await client.close()
        return f"Failed to fetch inbound invitations: {e}"

    if not invitations:
        await client.close()
        return "No inbound invitations"

    accepted = 0
    saved = 0
    errors = 0
    for inv in invitations[:DAILY_INBOUND_ACCEPT_LIMIT]:
        inv_id = inv.get("id", "")
        if not inv_id:
            continue

        # Save as inbound signal for qualification (dedup by sender_id)
        sender_id = inv.get("sender_id", "")
        if sender_id and not await run_db(get_inbound_signal_by_sender, sender_id, "invitation"):
            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", ""),
            )
            saved += 1

        # Accept ALL invitations (grow the network)
        try:
            result = await client.handle_invitation(
                account_id, inv_id, action="accept",
                shared_secret=inv.get("shared_secret", ""))
            if result.get("success"):
                accepted += 1
                await run_db(
                    log_action,
                    "inbound_invitation_accepted",
                    result="success",
                    details={
                        "invitation_id": inv_id,
                        "sender_name": inv.get("sender_name", ""),
                        "sender_id": sender_id,
                    },
                )
                logger.info(
                    "Accepted inbound invitation from %s (%s)",
                    inv.get("sender_name", "Unknown"), inv_id,
                )
            else:
                errors += 1
                logger.warning(
                    "Failed to accept invitation %s: %s",
                    inv_id, result.get("error", "unknown"),
                )
        except Exception as e:
            errors += 1
            logger.warning("Error accepting invitation %s: %s", inv_id, e)

    await client.close()

    summary = f"Inbound invitations: {accepted} accepted, {saved} saved for qualification"
    if errors:
        summary += f", {errors} failed"
    logger.info("Scheduler: %s", summary)
    return summary


async def _execute_qualify_inbound(job: dict[str, Any]) -> str:
    """Execute inbound signal qualification and send discovery DMs.

    1. Qualify all 'new' inbound signals against ICPs
    2. Send discovery DMs to qualified connections (full autopilot)
    """
    from ..services.inbound_service import process_inbound_signals, send_discovery_dms

    logger.info("Scheduler: qualifying inbound signals")

    # Step 1: Qualify new signals
    qual_result = await process_inbound_signals()
    logger.info("Scheduler: qualification result: %s", qual_result)

    # Step 2: Send discovery DMs to qualified signals
    dm_result = await send_discovery_dms()
    logger.info("Scheduler: discovery DM result: %s", dm_result)

    return f"{qual_result} | {dm_result}"


async def _execute_check_post_comments(job: dict[str, Any]) -> str:
    """Check comments on recently published posts for inbound leads."""
    from ..services.inbound_service import check_post_comments

    logger.info("Scheduler: checking post comments for inbound leads")

    result = await check_post_comments()
    logger.info("Scheduler: post comments result: %s", result)
    return result


async def _execute_endorse(job: dict[str, Any]) -> str:
    """Execute a skill endorsement job.

    Calls run_engage_prospect() with action="endorse" and mode="autopilot".
    Endorsements trigger high-visibility LinkedIn notifications.

    Error handling follows the same pattern as _execute_follow.
    """
    from ..constants import ENDORSEMENT_ENABLED
    if not ENDORSEMENT_ENABLED:
        logger.info("Endorsement globally disabled (ENDORSEMENT_ENABLED=False) — skipping")
        return "Endorsement globally disabled"

    deferred = await _check_engagement_budget_or_defer(job, "endorse")
    if deferred:
        return deferred

    from ..db.queries import log_action, update_outreach
    from ..tools.engage_prospect import run_engage_prospect

    campaign_id = job["campaign_id"]
    outreach_id = job.get("outreach_id", "")
    logger.info("Scheduler: endorsing prospect for outreach %s", outreach_id or "next")

    result = await run_engage_prospect(
        campaign_id=campaign_id,
        outreach_id=outreach_id or "",
        action="endorse",
        mode="autopilot",
    )

    if result and ("failed" in result.lower()):
        status_code = _extract_status_code(result)

        # Detect permanent failures: HTTP 4xx or known unrecoverable errors
        _permanent_error_phrases = ("profile or skills not found", "no linkedin identifier")
        is_permanent = (
            status_code in _PERMANENT_FAIL_CODES
            or any(phrase in result.lower() for phrase in _permanent_error_phrases)
        )

        if is_permanent:
            if outreach_id:
                await run_db(_set_engagement_cooldown, outreach_id, _COOLDOWN_ENDORSE_FAIL)
                await run_db(
                    log_action,
                    "endorse_cooldown",
                    outreach_id=outreach_id,
                    result="cooldown_7d",
                    details={"status_code": status_code, "error": result[:200]},
                )
            logger.warning(
                "Endorsement failed for outreach %s — cooldown 7d: %s",
                outreach_id, result[:100],
            )
            return result

        if status_code == 429:
            logger.warning("Endorsement rate limited (429) for outreach %s", outreach_id)
            raise RuntimeError(f"Endorsement rate limited (429): {result[:200]}")

        raise RuntimeError(f"Endorsement failed: {result[:200]}")

    logger.info("Scheduler: endorsement result: %s", result[:100])
    await _track_plan_execution(job, "endorse", result or "")
    return result


async def _execute_withdraw_stale_invites(job: dict[str, Any]) -> str:
    """Withdraw sent invitations that are older than STALE_INVITE_DAYS.

    Fetches sent invitations from LinkedIn, finds those older than the
    threshold, and withdraws them to free up invitation quota.
    """
    import time

    from ..db.queries import log_action
    from ..linkedin import UnipileError, get_account_id, get_linkedin_client

    logger.info("Scheduler: checking for stale invitations to withdraw")

    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}"

    # Get sent invitations (use cache to avoid duplicate API calls)
    from ..linkedin.rate_limiter import get_cached_pending_invitations, invalidate_pending_cache
    try:
        _, invitations = await get_cached_pending_invitations(client, account_id)
    except Exception as e:
        await client.close()
        return f"Failed to fetch sent invitations: {e}"

    if not invitations:
        await client.close()
        return "No pending sent invitations"

    now = int(time.time())
    stale_threshold = now - (STALE_INVITE_DAYS * 86400)
    withdrawn = 0
    errors = 0

    for inv in invitations:
        # Check if invitation is stale
        inv_time = (
            inv.get("parsed_datetime")
            or inv.get("timestamp")
            or inv.get("created_at")
            or 0
        )
        if isinstance(inv_time, str):
            try:
                from datetime import datetime
                inv_time = int(datetime.fromisoformat(inv_time.replace("Z", "+00:00")).timestamp())
            except (ValueError, TypeError):
                continue

        if not inv_time or inv_time > stale_threshold:
            continue  # Not stale yet

        inv_id = inv.get("id") or inv.get("invitation_id") or ""
        if not inv_id:
            continue

        days_old = (now - inv_time) // 86400

        try:
            result = await client.withdraw_invitation(account_id, inv_id)
            if result.get("success"):
                withdrawn += 1
                await run_db(
                    log_action,
                    "stale_invite_withdrawn",
                    result="success",
                    details={
                        "invitation_id": inv_id,
                        "days_old": days_old,
                        "name": inv.get("invited_user") or inv.get("name") or "",
                    },
                )
                logger.info("Withdrew stale invitation %s (%d days old)", inv_id, days_old)
            else:
                errors += 1
                logger.warning("Failed to withdraw invitation %s: %s", inv_id, result.get("error"))
        except Exception as e:
            errors += 1
            logger.warning("Error withdrawing invitation %s: %s", inv_id, e)

    await client.close()

    if withdrawn > 0:
        invalidate_pending_cache()

    summary = f"Stale invitations: {withdrawn} withdrawn"
    if errors:
        summary += f", {errors} failed"
    logger.info("Scheduler: %s", summary)
    return summary


# ──────────────────────────────────────────────
# Brand strategy executors (account-level)
# ──────────────────────────────────────────────


async def _execute_brand_post(job: dict[str, Any]) -> str:
    """Execute a brand post job — generate and auto-publish a LinkedIn post.

    Loads the next pending post action from the brand plan and delegates
    to the brand_strategy tool's post execution logic.
    """
    from ..db.queries import get_setting
    from ..linkedin import get_account_id
    from ..services.brand_service import (
        get_next_pending_action_by_type,
        load_brand_plan,
    )

    logger.info("Scheduler: executing brand post")

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

    plan = await run_db(load_brand_plan)
    if not plan:
        return "No brand plan found"

    action = get_next_pending_action_by_type(plan, "post")
    if not action:
        return "No pending post actions in brand plan"

    action_id = action.get("id", "")

    from ..tools.brand_strategy import _execute_post_action

    try:
        result = await _execute_post_action(action, action_id, account_id)
        logger.info("Scheduler: brand post result: %s", result[:100])
        return result
    except Exception as e:
        from ..services.brand_service import mark_action_completed

        await run_db(mark_action_completed, action_id, f"Scheduler failed: {e}")
        logger.error("Scheduler: brand post failed: %s", e)
        return f"Brand post failed: {e}"


async def _execute_brand_engage(job: dict[str, Any]) -> str:
    """Execute a brand engagement job — find trending posts and engage.

    Loads the next pending engagement action from the brand plan and delegates
    to the brand_strategy tool's engagement execution logic.
    """
    from ..linkedin import get_account_id
    from ..services.brand_service import (
        get_next_pending_action_by_type,
        load_brand_plan,
    )

    logger.info("Scheduler: executing brand engagement")

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

    plan = await run_db(load_brand_plan)
    if not plan:
        return "No brand plan found"

    action = get_next_pending_action_by_type(plan, "engagement")
    if not action:
        return "No pending engagement actions in brand plan"

    action_id = action.get("id", "")

    from ..tools.brand_strategy import _execute_engagement_action

    try:
        result = await _execute_engagement_action(action, action_id, account_id)
        logger.info("Scheduler: brand engagement result: %s", result[:100])
        return result
    except Exception as e:
        from ..services.brand_service import mark_action_completed

        await run_db(mark_action_completed, action_id, f"Scheduler failed: {e}")
        logger.error("Scheduler: brand engagement failed: %s", e)
        return f"Brand engagement failed: {e}"


async def _execute_brand_analyze(job: dict[str, Any]) -> str:
    """Execute a brand analyze job — audit profile and optionally generate plan.

    Runs a full brand analysis. If no plan exists after analysis,
    also generates a 4-week plan automatically.
    """
    from ..linkedin import get_account_id

    logger.info("Scheduler: executing brand analysis")

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

    from ..tools.brand_strategy import _handle_analyze, _handle_plan

    try:
        result = await _handle_analyze(account_id, "")
        logger.info("Scheduler: brand analysis complete")

        # Clear re-analysis flag if it was set (by campaign creation)
        from ..db.queries import get_setting, save_setting

        if await run_db(get_setting, "brand_reanalyze_needed", False):
            await run_db(save_setting, "brand_reanalyze_needed", False)
            logger.info("Scheduler: cleared brand_reanalyze_needed flag")

        # Auto-generate plan if none exists
        from ..services.brand_service import load_brand_plan

        plan = await run_db(load_brand_plan)
        if not plan:
            plan_result = await _handle_plan(account_id, "")
            logger.info("Scheduler: brand plan auto-generated")
            return f"{result}\n\n--- Auto-generated plan ---\n{plan_result}"

        return result
    except Exception as e:
        logger.error("Scheduler: brand analysis failed: %s", e)
        return f"Brand analysis failed: {e}"


# ──────────────────────────────────────────────
# Signal collection executors (v1.0)
# ──────────────────────────────────────────────


async def _execute_collect_keyword_signals(job: dict[str, Any]) -> str:
    """Execute keyword signal collection from LinkedIn post searches."""
    from ..collectors.keyword_collector import collect_keyword_signals

    logger.info("Scheduler: collecting keyword signals from LinkedIn posts")

    result = await collect_keyword_signals()
    logger.info("Scheduler: keyword signal collection result: %s", result)
    return result


async def _execute_scan_prospect_posts(job: dict[str, Any]) -> str:
    """Execute scanning campaign contacts' recent posts for signals."""
    from ..collectors.prospect_post_collector import collect_prospect_posts

    logger.info("Scheduler: scanning prospect posts for signals")

    result = await collect_prospect_posts()
    logger.info("Scheduler: prospect post scan result: %s", result)
    return result


async def _execute_classify_signals(job: dict[str, Any]) -> str:
    """Execute classification of pending signals via LLM."""
    from ..ai.signal_classifier import classify_pending_signals

    logger.info("Scheduler: classifying pending signals")

    result = await classify_pending_signals()
    logger.info("Scheduler: signal classification result: %s", result)
    return result


async def _execute_collect_profile_views(job: dict[str, Any]) -> str:
    """Execute LinkedIn profile viewer collection."""
    from ..collectors.profile_view_collector import collect_profile_views

    logger.info("Scheduler: collecting profile view signals")

    result = await collect_profile_views()
    logger.info("Scheduler: profile view collection result: %s", result)
    return result


async def _execute_detect_job_changes(job: dict[str, Any]) -> str:
    """Execute job change detection for campaign contacts."""
    from ..collectors.job_change_collector import collect_job_changes

    logger.info("Scheduler: scanning contacts for job changes")

    result = await collect_job_changes()
    logger.info("Scheduler: job change detection result: %s", result)
    return result


async def _execute_collect_competitor_signals(job: dict[str, Any]) -> str:
    """Execute competitor mention collection from LinkedIn posts."""
    from ..collectors.competitor_collector import collect_competitor_signals

    logger.info("Scheduler: collecting competitor mention signals")

    result = await collect_competitor_signals()
    logger.info("Scheduler: competitor signal collection result: %s", result)
    return result


async def _execute_collect_hiring_signals(job: dict[str, Any]) -> str:
    """Execute hiring surge detection from LinkedIn job searches."""
    from ..collectors.hiring_collector import collect_hiring_signals

    logger.info("Scheduler: collecting hiring surge signals")

    result = await collect_hiring_signals()
    logger.info("Scheduler: hiring signal collection result: %s", result)
    return result


async def _execute_collect_news_signals(job: dict[str, Any]) -> str:
    """Execute news/funding signal collection from SERPER API."""
    from ..collectors.news_collector import collect_news_signals

    logger.info("Scheduler: collecting news/funding signals")

    result = await collect_news_signals()
    logger.info("Scheduler: news signal collection result: %s", result)
    return result


# ──────────────────────────────────────────────
# Company page engagement collectors (Phase 2 intent expansion)
# ──────────────────────────────────────────────


async def _execute_collect_company_page(job: dict[str, Any]) -> str:
    """Execute company page engagement signal collection."""
    from ..collectors.company_page_collector import collect_company_page_signals

    logger.info("Scheduler: collecting company page engagement signals")
    result = await collect_company_page_signals()
    logger.info("Scheduler: company page collection result: %s", result)
    return result


async def _execute_collect_company_followers(job: dict[str, Any]) -> str:
    """Execute company follower signal collection."""
    from ..collectors.company_follower_collector import collect_company_followers

    logger.info("Scheduler: collecting company follower signals")
    result = await collect_company_followers()
    logger.info("Scheduler: company follower collection result: %s", result)
    return result


# ──────────────────────────────────────────────
# Signal activation executor (v1.1)
# ──────────────────────────────────────────────


async def _execute_activate_signals(job: dict[str, Any]) -> str:
    """Execute signal activation — convert classified signals into outreach.

    Routes signals by composite score:
    - Score >= 0.7 + buying intent → auto-create outreach with signal context
    - Score >= 0.5 → add to matching campaign as warm lead
    - Score >= 0.3 + existing contact → boost engagement priority
    - Below threshold → mark as below_threshold
    """
    from ..services.signal_activator import activate_pending_signals

    logger.info("Scheduler: activating classified signals")

    result = await activate_pending_signals()
    logger.info("Scheduler: signal activation result: %s", result)
    return result


async def _execute_detect_compound_intent(job: dict[str, Any]) -> str:
    """Execute compound intent detection — stack signals into high-value events.

    Scans all signal accounts for 2+ signal types within 14 days.
    Creates intent_events when compound patterns match (e.g.,
    job_change + hiring_surge → "new_leader_building").
    """
    from ..services.signal_scorer import detect_all_compound_intents

    logger.info("Scheduler: detecting compound intent events")

    result = await run_db(detect_all_compound_intents)
    logger.info("Scheduler: compound intent result: %s", result)
    return result


async def _execute_decay_cycle(job: dict[str, Any]) -> str:
    """Execute signal decay cycle — expire old signals and recompute scores.

    Runs every 6 hours:
    1. Expires signals past their TTL
    2. Expires old intent events
    3. Recomputes all signal account composite scores
    """
    from ..services.signal_service import run_decay_cycle

    logger.info("Scheduler: running signal decay cycle")

    result = await run_db(run_decay_cycle)
    logger.info("Scheduler: decay cycle result: %s", result)
    return result


async def _execute_rematch_signals(job: dict[str, Any]) -> str:
    """Re-match homeless signals to active campaigns.

    Runs every 1 hour. Picks up signals that previously had no matching
    campaign and re-evaluates them against all active campaign ICPs.
    """
    from ..services.signal_linker import match_signals_to_campaigns

    logger.info("Scheduler: running signal rematch cycle")

    result = await run_db(match_signals_to_campaigns)
    logger.info("Scheduler: signal rematch result: %s", result)
    return result


async def _execute_backfill_orphan_signals(job: dict[str, Any]) -> str:
    """Backfill orphan signals to now-known contacts.

    Runs every 2 hours. Links signals (prospect_id IS NULL) whose
    linkedin_id now matches a contact in the contacts table.
    """
    from ..services.signal_linker import backfill_orphan_signals

    logger.info("Scheduler: running orphan signal backfill")

    result = await run_db(backfill_orphan_signals)
    logger.info("Scheduler: orphan backfill result: %s", result)
    return result


async def _execute_partner_reminder(job: dict[str, Any]) -> str:
    """Check for due partner follow-ups and send email reminders to self.

    Runs every hour. For each overdue partner follow-up:
    1. Builds reminder email with partner context
    2. Sends to user's own email via Unipile
    3. Advances the follow-up schedule (escalating cadence)
    """
    from ..db.queries import (
        get_due_partner_reminders,
        advance_partner_followup,
        get_setting,
    )
    from ..tools.partner_followup import build_partner_reminder_email

    logger.info("Scheduler: checking partner follow-up reminders")

    due = await run_db(get_due_partner_reminders)
    if not due:
        return "No partner follow-ups due"

    user_email = await run_db(get_setting, "user_email", "")
    email_account_id = await run_db(get_setting, "email_account_id", "")

    if not user_email or not email_account_id:
        logger.warning(
            "Partner reminder: %d follow-ups due but no email configured "
            "(user_email=%r, email_account_id=%r)",
            len(due), bool(user_email), bool(email_account_id),
        )
        return f"{len(due)} partner follow-ups due but no email account configured"

    from ..linkedin.factory import get_linkedin_client

    sent = 0
    client = get_linkedin_client()
    for partner in due:
        try:
            email_body = build_partner_reminder_email(partner)
            name = partner.get("name", "Unknown")
            company = partner.get("company", "")
            count = partner.get("followup_count", 0) + 1
            subject = f"🔔 Follow up with {name}" + (f" ({company})" if company else "")

            result = await client.send_email(
                account_id=email_account_id,
                to_email=user_email,
                to_name="HeyLead",
                subject=subject,
                body=email_body,
            )
            if result.get("success"):
                await run_db(advance_partner_followup, partner["id"])
                sent += 1
                logger.info("Partner reminder sent: %s (#%d)", name, count)
            else:
                logger.warning(
                    "Partner reminder email failed for %s: %s",
                    name, result.get("error", "unknown"),
                )
        except Exception as e:
            logger.error("Partner reminder error for %s: %s", partner.get("name"), e)

    await client.close()
    msg = f"Sent {sent}/{len(due)} partner follow-up reminders"
    logger.info("Scheduler: %s", msg)
    return msg


async def _execute_daily_digest(job: dict[str, Any]) -> str:
    """Compile and log the daily digest.

    The digest is stored as a scheduler event so it can be retrieved
    via scheduler(action='logs'). If email is configured, also sends
    a copy to the user.
    """
    from ..services.daily_digest import compile_daily_digest
    from ..db.queries import get_setting, log_scheduler_event

    logger.info("Scheduler: compiling daily digest")
    digest = compile_daily_digest()

    # Store as event for retrieval via logs tool
    await run_db(
        log_scheduler_event, "daily_digest",
        context={"digest": digest},
    )

    # If email is configured, send digest to user
    user_email = await run_db(get_setting, "user_email", "")
    email_account_id = await run_db(get_setting, "email_account_id", "")

    if user_email and email_account_id:
        try:
            from ..linkedin.factory import get_linkedin_client

            client = get_linkedin_client()
            from datetime import date
            subject = f"HeyLead Daily Digest — {date.today().isoformat()}"
            await client.send_email(
                account_id=email_account_id,
                to_email=user_email,
                to_name="HeyLead User",
                subject=subject,
                body=digest,
            )
            await client.close()
            logger.info("Scheduler: daily digest emailed to %s", user_email)
        except Exception as e:
            logger.warning("Scheduler: failed to email digest: %s", e)

    return digest


# ──────────────────────────────────────────────
# Communication Strategist Executors
# ──────────────────────────────────────────────

async def _execute_daily_strategy(job: dict[str, Any]) -> str:
    """Execute the daily strategy planning cycle."""
    from ..services.communication_strategist import run_daily_strategy

    logger.info("Scheduler: running daily strategy planning")
    result = await run_daily_strategy()
    plans = result.get("plans_created", 0)
    evaluated = result.get("plans_evaluated", 0)
    return f"Daily strategy: {plans} plans created, {evaluated} evaluated"


async def _execute_strategy_plans(job: dict[str, Any]) -> str:
    """Execute planned actions from daily strategy for a campaign."""
    campaign_id = job.get("campaign_id")
    if not campaign_id:
        return "No campaign_id for strategy plan execution"
    from ..services.communication_strategist import execute_planned_actions

    jobs_created = await execute_planned_actions(campaign_id)
    return f"Strategy plans: {jobs_created} jobs created"


async def _execute_verify_actions(job: dict[str, Any]) -> str:
    """Verify that recent outreach actions actually landed on LinkedIn."""
    from ..services.action_verifier import verify_recent_actions

    stats = await verify_recent_actions()
    return (
        f"Verification: checked={stats['checked']} "
        f"confirmed={stats['confirmed']} "
        f"unconfirmed={stats['unconfirmed']} "
        f"errors={stats['errors']}"
    )


async def _execute_sync_connections(job: dict[str, Any]) -> str:
    """Sync 1st-degree LinkedIn connections to the local DB.

    Fetches relations from Unipile and upserts into the local connections table.
    Runs every 4 hours to keep the local cache fresh for dedup and pre-send checks.
    """
    from ..linkedin.client_factory import get_linkedin_client
    from ..services.connection_sync import sync_connections, get_connection_count

    client = get_linkedin_client()
    try:
        account_id, _ = await client.find_linkedin_account()
        if not account_id:
            return "No LinkedIn account connected — skipping connection sync"

        synced = await sync_connections(client, account_id)
        total = get_connection_count(account_id)
        return f"Connection sync: {synced} synced, {total} total in local DB"
    except Exception as e:
        logger.warning("Connection sync failed: %s", e)
        return f"Connection sync failed: {e}"
    finally:
        await client.close()


# ──────────────────────────────────────────────
# Post Intelligence v2 Executors
# ──────────────────────────────────────────────


async def _execute_collect_posts_distributed(job: dict[str, Any]) -> str:
    """Execute distributed multi-account post collection."""
    from ..collectors.distributed_post_collector import collect_posts_distributed

    logger.info("Scheduler: running distributed post collection")
    result = await collect_posts_distributed()
    logger.info("Scheduler: distributed collection result: %s", result)
    return result


async def _execute_research_contacts(job: dict[str, Any]) -> str:
    """Execute contact research pipeline."""
    from ..services.post_research_service import research_pending_contacts

    logger.info("Scheduler: researching pending contacts")
    result = await research_pending_contacts()
    logger.info("Scheduler: research result: %s", result)
    return result


async def _execute_backfill_post_analysis(job: dict[str, Any]) -> str:
    """Execute backfill of unanalyzed posts."""
    from ..services.post_research_service import backfill_post_analysis

    logger.info("Scheduler: backfilling unanalyzed posts")
    result = await backfill_post_analysis()
    logger.info("Scheduler: backfill result: %s", result)
    return result


async def _execute_detect_viral_posts(job: dict[str, Any]) -> str:
    """Execute viral post detection from metric snapshots."""
    from ..services.post_research_service import detect_viral_posts

    logger.info("Scheduler: detecting viral posts")
    result = await detect_viral_posts()
    logger.info("Scheduler: viral detection result: %s", result)
    return result


async def _execute_tune_watchlists(job: dict[str, Any]) -> str:
    """Execute keyword watchlist auto-tuning."""
    from ..services.post_research_service import auto_tune_watchlists

    logger.info("Scheduler: auto-tuning keyword watchlists")
    result = await auto_tune_watchlists()
    logger.info("Scheduler: watchlist tuning result: %s", result)
    return result


# ──────────────────────────────────────────────
# Post Intelligence Phase 5 executors
# ──────────────────────────────────────────────


async def _execute_classify_post_intent(job: dict[str, Any]) -> str:
    """Execute post content intent classification (Phase 5)."""
    from ..collectors.post_intent_collector import classify_post_intents

    logger.info("Scheduler: classifying post intents")
    result = await classify_post_intents()
    logger.info("Scheduler: post intent result: %s", result)
    return result


async def _execute_mine_comments(job: dict[str, Any]) -> str:
    """Execute comment mining from competitor/industry posts (Phase 5)."""
    from ..collectors.comment_mining_collector import mine_post_comments

    logger.info("Scheduler: mining post comments")
    result = await mine_post_comments()
    logger.info("Scheduler: comment mining result: %s", result)
    return result


# ──────────────────────────────────────────────
# Campaign auto-refill executor
# ──────────────────────────────────────────────


async def _execute_campaign_refill(job: dict[str, Any]) -> str:
    """Execute campaign refill — search LinkedIn for more prospects when running low."""
    from ..services.campaign_refill_service import refill_campaigns

    logger.info("Scheduler: checking campaigns for refill")
    result = await refill_campaigns()
    logger.info("Scheduler: campaign refill result: %s", result)
    return result


# ──────────────────────────────────────────────
# Plan Execution Tracking
# ──────────────────────────────────────────────

async def _track_plan_execution(
    job: dict[str, Any], action_type: str, result: str = ""
) -> None:
    """If this outreach has a daily plan, mark the action as executed.

    Non-fatal: tracking failure should never break actual execution.
    """
    outreach_id = job.get("outreach_id")
    if not outreach_id:
        return
    try:
        from ..db.strategist_queries import mark_action_executed, _today_str

        await run_db(
            mark_action_executed,
            outreach_id,
            _today_str(),
            action_type,
            result[:200] if result else "",
            job.get("id", ""),
        )
    except Exception:
        pass  # Non-fatal
