"""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
from typing import Any

from ..db.async_bridge import run_db
from ..constants import (
    JOB_ACCEPT_INBOUND,
    JOB_ACTIVATE_SIGNALS,
    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_SYNC_CONNECTIONS,
    JOB_VERIFY_ACTIONS,
    JOB_QUALIFY_INBOUND,
    JOB_SCAN_PROSPECT_POSTS,
    JOB_WITHDRAW_INVITE,
    STALE_INVITE_DAYS,
)

logger = logging.getLogger(__name__)

# HTTP status codes indicating permanent engagement failure
_PERMANENT_FAIL_CODES = {422, 403, 404}
# Pattern to extract HTTP status codes from error strings
_STATUS_CODE_RE = re.compile(r"\b(4\d{2})\b")

# 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 categorize_error(error_msg: str) -> str:
    """Classify an error message into a category for filtering and analysis.

    Categories:
        rate_limit       — 429 / rate limit responses from LinkedIn/Unipile
        permanent_failure — 4xx errors that won't resolve on retry (422, 403, 404)
        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()
    if "429" in lower or "rate limit" in lower or "too many requests" in lower:
        return "rate_limit"
    if any(code in lower for code in ("422", "403", "404")):
        return "permanent_failure"
    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"
    if any(w in lower for w in ("unauthorized", "401", "disconnected", "no linkedin account")):
        return "auth_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_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,
        JOB_WITHDRAW_INVITE: _execute_withdraw_stale_invites,
        JOB_QUALIFY_INBOUND: _execute_qualify_inbound,
        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,
    }

    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"]
    logger.info("Scheduler: sending DM for connections-only campaign %s", campaign_id)

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

    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)

    # 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)

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

    # Check if the result indicates an error (tools return error strings)
    if result and ("❌" in result or "failed" in result.lower()):
        # Check for LinkedIn rate limit (422 cannot_resend_yet)
        lower_result = result.lower()
        if "cannot_resend_yet" in lower_result or (
            "422" in result and "resend" in lower_result
        ):
            logger.warning(
                "Invite rate limited (422 cannot_resend_yet) for campaign %s — "
                "completing job without retry",
                campaign_id,
            )
            return result  # Don't raise — already invited

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

    # 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 _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
    """
    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.
    """
    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
    """
    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()):
        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.

    Calls run_reply_to_prospect() with the campaign's mode (autopilot/copilot).
    Autopilot: sends immediately after validation.
    Copilot: queues for user review.
    """
    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")

    # Determine mode from campaign (autopilot sends, copilot queues for review)
    mode = "autopilot"
    if campaign_id:
        campaign = await run_db(get_campaign, campaign_id)
        if campaign:
            mode = campaign.get("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)

    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,
    )

    # Log the auto-reply action for observability
    log_result = "success" if result and "failed" not in result.lower() else "error"
    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 result and ("failed" in result.lower() or "error" in result.lower()):
        raise RuntimeError(f"Auto-reply failed: {result[:200]}")

    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_accept_inbound(job: dict[str, Any]) -> str:
    """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")
            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 ..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
    try:
        invitations = await client.get_pending_invitations(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("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("name", ""),
                    },
                )
                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()

    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


# ──────────────────────────────────────────────
# 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
