"""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 ..constants import (
    JOB_ACCEPT_INBOUND,
    JOB_ACTIVATE_SIGNALS,
    JOB_BRAND_ANALYZE,
    JOB_BRAND_ENGAGE,
    JOB_BRAND_POST,
    JOB_CHECK_POST_COMMENTS,
    JOB_CHECK_REPLIES,
    JOB_CLASSIFY_SIGNALS,
    JOB_COLLECT_COMPETITOR_SIGNALS,
    JOB_COLLECT_HIRING_SIGNALS,
    JOB_COLLECT_KEYWORD_SIGNALS,
    JOB_COLLECT_NEWS_SIGNALS,
    JOB_COLLECT_PROFILE_VIEWS,
    JOB_DETECT_COMPOUND_INTENT,
    JOB_DETECT_JOB_CHANGES,
    JOB_SIGNAL_DECAY_CYCLE,
    JOB_ENDORSE,
    JOB_ENGAGE,
    JOB_FOLLOW,
    JOB_FOLLOWUP,
    JOB_INVITE,
    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")


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.
    if job_type in (JOB_INVITE, JOB_FOLLOWUP) and job.get("outreach_id"):
        delay = _check_prospect_business_hours(job["outreach_id"])
        if delay > 0:
            # Reschedule for prospect's business hours
            from ..db.queries import reschedule_job
            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,
            )
            return f"Rescheduled for prospect business hours (in {delay // 60} min)"

    executors = {
        JOB_INVITE: _execute_invite,
        JOB_FOLLOWUP: _execute_followup,
        JOB_ENGAGE: _execute_engagement,
        JOB_FOLLOW: _execute_follow,
        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_ACTIVATE_SIGNALS: _execute_activate_signals,
        JOB_DETECT_COMPOUND_INTENT: _execute_detect_compound_intent,
        JOB_SIGNAL_DECAY_CYCLE: _execute_decay_cycle,
    }

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

    return await executor(job)


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 = 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 _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)

    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])
    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])
    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
            # Mark skip_engagement so this outreach is excluded from future candidates
            if outreach_id:
                update_outreach(outreach_id, next_action="skip_engagement")
                log_action(
                    "engagement_skip_permanent",
                    outreach_id=outreach_id,
                    result="skip_engagement",
                    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])
    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_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:
                update_outreach(outreach_id, next_action="skip_engagement")
                log_action(
                    "follow_skip_permanent",
                    outreach_id=outreach_id,
                    result="skip_engagement",
                    details={"status_code": status_code, "error": result[:200]},
                )
            logger.warning(
                "Follow permanently failed (%d) for outreach %s — skip_engagement",
                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])
    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_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 get_inbound_signal_by_sender(sender_id, "invitation"):
            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
                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:
                update_outreach(outreach_id, next_action="skip_engagement")
                log_action(
                    "endorse_skip_permanent",
                    outreach_id=outreach_id,
                    result="skip_engagement",
                    details={"status_code": status_code, "error": result[:200]},
                )
            logger.warning(
                "Endorsement permanently failed for outreach %s — skip_engagement: %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])
    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
                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 = 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

        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 = 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

        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 get_setting("brand_reanalyze_needed", False):
            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 = 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


# ──────────────────────────────────────────────
# 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 = 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 = run_decay_cycle()
    logger.info("Scheduler: decay cycle result: %s", result)
    return result
