"""Scheduler engine — asyncio-based background loop for autonomous outreach.

Runs alongside the MCP server via FastMCP's lifespan hook.
Ticks every 60 seconds, checking for ready work and executing it.

Key responsibilities:
1. Execute ready jobs from the DB-backed job queue
2. Schedule new work (invites, follow-ups) for autopilot campaigns
3. Periodically check for replies (every 5 min)
4. Periodically schedule follow warm-ups (every 15 min)
5. Periodically schedule engagement warm-ups (every 30 min)
6. Retry failed jobs (up to 3 attempts)
7. Cleanup old completed jobs (7 days)
"""

from __future__ import annotations

import asyncio
import logging
import time
from typing import Any

from ..config import is_scheduler_always_on, is_scheduler_enabled, load_config, set_scheduler_enabled
from ..constants import (
    BRAND_ENGAGE_CHECK_SECONDS,
    BRAND_LIFECYCLE_CHECK_SECONDS,
    BRAND_POST_CHECK_SECONDS,
    INBOUND_CHECK_SECONDS,
    INBOUND_QUALIFY_SECONDS,
    POST_COMMENT_CHECK_SECONDS,
    SIGNAL_KEYWORD_POLL_SECONDS,
    SIGNAL_PROSPECT_SCAN_SECONDS,
    SIGNAL_CLASSIFY_SECONDS,
    SIGNAL_PROFILE_VIEW_SECONDS,
    SIGNAL_JOB_CHANGE_SCAN_SECONDS,
    SIGNAL_COMPETITOR_POLL_SECONDS,
    SIGNAL_ACTIVATE_SECONDS,
    SIGNAL_COMPOUND_DETECT_SECONDS,
    SIGNAL_DECAY_CYCLE_SECONDS,
    SIGNAL_REMATCH_SECONDS,
    SIGNAL_BACKFILL_SECONDS,
    SIGNAL_HIRING_SCAN_SECONDS,
    SIGNAL_NEWS_SCAN_SECONDS,
    JOB_ACCEPT_INBOUND,
    JOB_ACTIVATE_SIGNALS,
    JOB_CHECK_POST_COMMENTS,
    JOB_CHECK_REPLIES,
    JOB_CLASSIFY_SIGNALS,
    JOB_COMPLETED,
    JOB_ENGAGE,
    JOB_ENDORSE,
    JOB_FAILED,
    JOB_FOLLOW,
    JOB_FOLLOWUP,
    JOB_INVITE,
    JOB_PROFILE_VIEW,
    JOB_DAILY_DIGEST,
    JOB_DAILY_STRATEGY,
    JOB_EXECUTE_STRATEGY_PLANS,
    JOB_PARTNER_REMINDER,
    JOB_QUALIFY_INBOUND,
    JOB_WITHDRAW_INVITE,
    SCHEDULER_CLEANUP_DAYS,
    SCHEDULER_DAILY_STRATEGY_SECONDS,
    SCHEDULER_DAILY_DIGEST_SECONDS,
    SCHEDULER_EXECUTE_PLANS_SECONDS,
    SCHEDULER_ENDORSE_SECONDS,
    SCHEDULER_ENGAGEMENT_SECONDS,
    SCHEDULER_FOLLOW_SECONDS,
    SCHEDULER_PROFILE_VIEW_WARMUP_SECONDS,
    SCHEDULER_MAX_RETRIES,
    SCHEDULER_AUTO_REPLY_SECONDS,
    SCHEDULER_AUTO_RESUME_CHECK_SECONDS,
    SCHEDULER_REPLY_CHECK_SECONDS,
    SCHEDULER_RETRY_DELAY,
    SCHEDULER_AB_EVAL_SECONDS,
    SCHEDULER_EXPERIMENT_SECONDS,
    SCHEDULER_PARTNER_REMINDER_SECONDS,
    SCHEDULER_STRATEGY_SECONDS,
    SCHEDULER_TICK_SECONDS,
    ANOMALY_SCAN_SECONDS,
    STATUS_ACTIVE,
    WITHDRAW_CHECK_SECONDS,
)
from ..correlation import new_correlation_id, clear_correlation_id, get_correlation_id
from ..tracing import tracer
from ..db.async_bridge import run_db
from ..db.queries import (
    claim_job,
    cleanup_old_jobs,
    cleanup_scheduler_events,
    complete_job,
    get_ready_jobs,
    get_setting,
    list_campaigns,
    log_scheduler_event,
    retry_job,
    save_setting,
)

logger = logging.getLogger(__name__)

# Inbound job types run even when the outbound scheduler is toggled off.
# Job types that run even when the outbound scheduler is toggled off.
# Signal intelligence is passive analysis, not outbound action.
_INBOUND_JOB_TYPES = frozenset({
    JOB_ACCEPT_INBOUND,
    JOB_QUALIFY_INBOUND,
    JOB_CHECK_POST_COMMENTS,
    JOB_CLASSIFY_SIGNALS,
    JOB_ACTIVATE_SIGNALS,
    JOB_PARTNER_REMINDER,
    JOB_DAILY_DIGEST,
})


class SchedulerEngine:
    """Asyncio-based scheduler for autonomous outreach execution.

    Runs as a background task in the same event loop as the MCP server.
    All outreach logic is delegated to executors (which reuse existing tool internals).
    """

    # Setting key for persisting timers to SQLite
    _TIMERS_SETTING_KEY = "scheduler_timers"

    def __init__(self) -> None:
        self._running = False
        self._task: asyncio.Task[None] | None = None
        # All periodic timers stored in a dict, persisted to SQLite.
        # Loaded on start() to survive MCP server restarts.
        self._timers: dict[str, float] = {}
        self._tick_count: int = 0
        self._errors_in_row: int = 0

    def _timer(self, name: str) -> float:
        """Get a timer value (last execution timestamp). Returns 0 if never run."""
        return self._timers.get(name, 0)

    def _set_timer(self, name: str, value: float) -> None:
        """Set a timer value. Persisted to SQLite every 5 ticks."""
        self._timers[name] = value

    async def _send_scheduler_disable_alert(self, toggle_info: dict) -> None:
        """Send immediate email alert when scheduler was disabled while always-on is active."""
        try:
            from ..config import is_backend_mode
            if not is_backend_mode():
                logger.debug("No backend configured, skipping scheduler disable alert")
                return

            import httpx
            from ..services.cloud_sync import _base_url, _headers

            base = _base_url()
            payload = {
                "alert_type": "scheduler_disabled",
                "disabled_by": toggle_info.get("caller", "unknown"),
                "reason": toggle_info.get("reason", ""),
                "disabled_at": toggle_info.get("timestamp", 0),
                "auto_reenabled": True,
            }

            async with httpx.AsyncClient(timeout=15.0) as http:
                resp = await http.post(
                    f"{base}/api/v1/alerts/scheduler-disabled",
                    json=payload,
                    headers=_headers(),
                )
                if resp.status_code == 200:
                    logger.info("Scheduler disable alert sent to backend")
                else:
                    logger.warning(
                        "Scheduler disable alert failed: HTTP %d — %s",
                        resp.status_code, resp.text[:100],
                    )
        except Exception as e:
            logger.warning("Failed to send scheduler disable alert: %s", e)

    @property
    def is_running(self) -> bool:
        return self._running

    @property
    def tick_count(self) -> int:
        return self._tick_count

    async def start(self) -> None:
        """Start the scheduler background loop.

        Loads persisted timer state from SQLite so periodic tasks resume
        where they left off instead of all firing immediately on restart.
        """
        if self._running:
            logger.warning("Scheduler already running")
            return
        # Load persisted timers from DB (survives restarts)
        try:
            saved = await run_db(get_setting, self._TIMERS_SETTING_KEY, {})
            if isinstance(saved, dict):
                self._timers = saved
                logger.info(
                    "Loaded %d persisted scheduler timers", len(self._timers),
                )
        except Exception as e:
            logger.debug("Failed to load persisted timers: %s", e)

        self._running = True
        self._task = asyncio.create_task(self._run_loop())
        logger.info("Scheduler engine started")

    async def stop(self) -> None:
        """Gracefully stop the scheduler."""
        self._running = False
        if self._task and not self._task.done():
            self._task.cancel()
            try:
                await self._task
            except asyncio.CancelledError:
                pass
        self._task = None
        logger.info("Scheduler engine stopped")

    async def _run_loop(self) -> None:
        """Main tick loop — runs every SCHEDULER_TICK_SECONDS."""
        # Small initial delay to let the MCP server finish startup
        await asyncio.sleep(5)

        while self._running:
            try:
                await self._tick()
                self._errors_in_row = 0
            except asyncio.CancelledError:
                break
            except Exception as e:
                self._errors_in_row += 1
                logger.error("Scheduler tick error (%d in a row): %s", self._errors_in_row, e)
                # Back off if too many consecutive errors
                if self._errors_in_row >= 5:
                    logger.warning("Too many scheduler errors, backing off for 5 minutes")
                    await asyncio.sleep(300)
                    self._errors_in_row = 0

            await asyncio.sleep(SCHEDULER_TICK_SECONDS)

    async def _tick(self) -> None:
        """Single scheduler tick — the heart of the autonomous system."""
        self._tick_count += 1

        # Guard: setup must be complete for ANY processing
        if not await run_db(get_setting, "setup_complete", False):
            return

        now = time.time()
        scheduler_on = is_scheduler_enabled()
        tick_processed = 0
        tick_failed = 0
        tick_planned = 0

        # ── ALWAYS-ON GUARD: auto-re-enable if always_on mode is active ──
        if not scheduler_on and is_scheduler_always_on():
            cfg = load_config()
            last_toggle = cfg.get("_last_scheduler_toggle", {})

            set_scheduler_enabled(
                True,
                caller="always_on_guard",
                reason=(
                    f"Auto-re-enabled by always-on guard. "
                    f"Was disabled by '{last_toggle.get('caller', 'unknown')}': "
                    f"{last_toggle.get('reason', 'no reason given')}"
                ),
            )
            scheduler_on = True

            logger.warning(
                "Scheduler was disabled but always_on is active — auto-re-enabled. "
                "Disabled by: %s, reason: %s",
                last_toggle.get("caller", "unknown"),
                last_toggle.get("reason", "unknown"),
            )

            await self._send_scheduler_disable_alert(last_toggle)

            await run_db(
                log_scheduler_event, "scheduler_always_on_reenable",
                context={
                    "disabled_by": last_toggle.get("caller", "unknown"),
                    "disable_reason": last_toggle.get("reason", ""),
                    "disabled_at": last_toggle.get("timestamp", 0),
                    "reenabled_at": int(now),
                },
            )

        # ── ALWAYS-ON: execute ready jobs from the DB queue ──
        # Inbound jobs run regardless of scheduler_enabled.
        # Outbound jobs are skipped when the scheduler is off.
        processed, failed = await self._process_ready_jobs(
            scheduler_on=scheduler_on,
        )
        tick_processed += processed
        tick_failed += failed

        # ── ALWAYS-ON: inbound pipeline (independent of scheduler toggle) ──

        # Accept inbound invitations (every 15 min)
        if now - self._timer("inbound_check") >= INBOUND_CHECK_SECONDS:
            await self._schedule_inbound_accepts()
            self._set_timer("inbound_check", now)

        # Qualify inbound signals + send discovery DMs (every 15 min)
        if now - self._timer("qualify_inbound") >= INBOUND_QUALIFY_SECONDS:
            await self._schedule_qualify_inbound()
            self._set_timer("qualify_inbound", now)

        # Check post comments for inbound leads (every 30 min)
        if now - self._timer("post_comment_check") >= POST_COMMENT_CHECK_SECONDS:
            await self._schedule_check_post_comments()
            self._set_timer("post_comment_check", now)

        # ── ALWAYS-ON: signal intelligence pipeline ──
        # Classification and activation are passive analysis, not outbound action.
        # They must run regardless of scheduler toggle so signals don't get stuck.

        # Classify pending signals (every 15 min)
        if now - self._timer("signal_classify") >= SIGNAL_CLASSIFY_SECONDS:
            await self._schedule_signal_classification()
            self._set_timer("signal_classify", now)

        # Activate classified signals (every 15 min)
        if now - self._timer("signal_activate") >= SIGNAL_ACTIVATE_SECONDS:
            await self._schedule_signal_activation()
            self._set_timer("signal_activate", now)

        # Re-match homeless signals to campaigns (every 1 hour)
        if now - self._timer("signal_rematch") >= SIGNAL_REMATCH_SECONDS:
            await self._schedule_signal_rematch()
            self._set_timer("signal_rematch", now)

        # Backfill orphan signals to known contacts (every 2 hours)
        if now - self._timer("orphan_backfill") >= SIGNAL_BACKFILL_SECONDS:
            await self._schedule_orphan_backfill()
            self._set_timer("orphan_backfill", now)

        # ── OUTBOUND: requires scheduler to be enabled ──
        if not scheduler_on:
            logger.info(
                "tick#%d: processed=%d failed=%d planned=%d scheduler=off",
                self._tick_count, tick_processed, tick_failed, tick_planned,
            )
            return

        # Schedule new work for all active autopilot campaigns
        await self._schedule_new_work()

        # Periodic: auto-resume limit-paused campaigns (every 5 min)
        if now - self._timer("auto_resume_check") >= SCHEDULER_AUTO_RESUME_CHECK_SECONDS:
            await self._check_auto_resume()
            self._set_timer("auto_resume_check", now)

        # Periodic: check replies (every 5 min)
        if now - self._timer("reply_check") >= SCHEDULER_REPLY_CHECK_SECONDS:
            await self._schedule_reply_checks()
            self._set_timer("reply_check", now)

        # Periodic: auto-reply to detected messages (every 5 min)
        if now - self._timer("auto_reply_plan") >= SCHEDULER_AUTO_REPLY_SECONDS:
            await self._schedule_auto_replies()
            self._set_timer("auto_reply_plan", now)

        # Periodic: profile view warm-ups (every 10 min — lightest touch)
        if now - self._timer("profile_view_plan") >= SCHEDULER_PROFILE_VIEW_WARMUP_SECONDS:
            await self._schedule_profile_views()
            self._set_timer("profile_view_plan", now)

        # Periodic: follow warm-ups (every 15 min)
        if now - self._timer("follow_plan") >= SCHEDULER_FOLLOW_SECONDS:
            await self._schedule_follows()
            self._set_timer("follow_plan", now)

        # Periodic: engagement warm-ups (every 30 min)
        if now - self._timer("engagement_plan") >= SCHEDULER_ENGAGEMENT_SECONDS:
            await self._schedule_engagements()
            self._set_timer("engagement_plan", now)

        # 6d. Periodic: collect keyword signals (every 30 min)
        if now - self._timer("keyword_signal_collect") >= SIGNAL_KEYWORD_POLL_SECONDS:
            await self._schedule_keyword_signal_collection()
            self._set_timer("keyword_signal_collect", now)

        # 6e. Periodic: scan prospect posts for signals (every 1 hour)
        if now - self._timer("prospect_post_scan") >= SIGNAL_PROSPECT_SCAN_SECONDS:
            await self._schedule_prospect_post_scan()
            self._set_timer("prospect_post_scan", now)

        # 6g. Periodic: collect profile views (every 1 hour)
        if now - self._timer("profile_view_collect") >= SIGNAL_PROFILE_VIEW_SECONDS:
            await self._schedule_profile_view_collection()
            self._set_timer("profile_view_collect", now)

        # 6h. Periodic: detect job changes (every 4 hours)
        if now - self._timer("job_change_scan") >= SIGNAL_JOB_CHANGE_SCAN_SECONDS:
            await self._schedule_job_change_detection()
            self._set_timer("job_change_scan", now)

        # 6i. Periodic: collect competitor mentions (every 30 min)
        if now - self._timer("competitor_signal_collect") >= SIGNAL_COMPETITOR_POLL_SECONDS:
            await self._schedule_competitor_signal_collection()
            self._set_timer("competitor_signal_collect", now)

        # 6k. Periodic: detect hiring surges (every 4 hours)
        if now - self._timer("hiring_signal_collect") >= SIGNAL_HIRING_SCAN_SECONDS:
            await self._schedule_hiring_signal_collection()
            self._set_timer("hiring_signal_collect", now)

        # 6l. Periodic: collect news/funding signals (every 4 hours)
        if now - self._timer("news_signal_collect") >= SIGNAL_NEWS_SCAN_SECONDS:
            await self._schedule_news_signal_collection()
            self._set_timer("news_signal_collect", now)

        # 6m. Periodic: detect compound intent events (every 30 min)
        if now - self._timer("compound_intent_detect") >= SIGNAL_COMPOUND_DETECT_SECONDS:
            await self._schedule_compound_intent_detection()
            self._set_timer("compound_intent_detect", now)

        # 6n. Periodic: signal decay cycle (every 6 hours)
        if now - self._timer("decay_cycle") >= SIGNAL_DECAY_CYCLE_SECONDS:
            await self._schedule_decay_cycle()
            self._set_timer("decay_cycle", now)

        # 7. Periodic: skill endorsement warm-ups (every 15 min)
        if now - self._timer("endorse_plan") >= SCHEDULER_ENDORSE_SECONDS:
            await self._schedule_endorsements()
            self._set_timer("endorse_plan", now)

        # 8. Periodic: stale invite withdrawal (every hour)
        if now - self._timer("withdraw_check") >= WITHDRAW_CHECK_SECONDS:
            await self._schedule_stale_withdrawals()
            self._set_timer("withdraw_check", now)

        # 9a. Brand lifecycle (every hour)
        if now - self._timer("brand_lifecycle_check") >= BRAND_LIFECYCLE_CHECK_SECONDS:
            await self._schedule_brand_lifecycle()
            self._set_timer("brand_lifecycle_check", now)

        # 9b. Brand posts (every hour — planner decides if one is due)
        if now - self._timer("brand_post_plan") >= BRAND_POST_CHECK_SECONDS:
            await self._schedule_brand_posts()
            self._set_timer("brand_post_plan", now)

        # 9c. Brand engagement (every 4 hours)
        if now - self._timer("brand_engage_plan") >= BRAND_ENGAGE_CHECK_SECONDS:
            await self._schedule_brand_engagements()
            self._set_timer("brand_engage_plan", now)

        # 10. Cleanup old completed jobs (once per hour)
        if now - self._timer("cleanup") >= 3600:
            try:
                deleted = await run_db(cleanup_old_jobs, days=SCHEDULER_CLEANUP_DAYS)
                if deleted > 0:
                    logger.debug("Cleaned up %d old scheduler jobs", deleted)
            except Exception as e:
                logger.debug("Cleanup failed: %s", e)

            # Auto-cleanup failed engage & invite jobs
            try:
                from ..db.queries import (
                    cleanup_failed_engage_jobs,
                    cleanup_failed_invite_jobs,
                )
                engage_result = await run_db(cleanup_failed_engage_jobs)
                invite_result = await run_db(cleanup_failed_invite_jobs)
                if engage_result["cleaned"] or invite_result["cleaned"]:
                    logger.info(
                        "Auto-cleanup: %d engage + %d invite failed jobs",
                        engage_result["cleaned"], invite_result["cleaned"],
                    )
            except Exception as e:
                logger.debug("Failed job cleanup failed: %s", e)

            # Recover jobs stuck in 'running' state (process crash recovery)
            try:
                from ..db.queries import recover_stuck_running_jobs
                recovered = await run_db(recover_stuck_running_jobs, 600)
                if recovered > 0:
                    logger.warning("Recovered %d stuck running jobs", recovered)
            except Exception as e:
                logger.debug("Stuck job recovery failed: %s", e)

            self._set_timer("cleanup", now)

        # 11. Periodic: A/B test evaluation (every hour)
        if now - self._timer("ab_eval") >= SCHEDULER_AB_EVAL_SECONDS:
            try:
                from ..services.experiment_service import evaluate_ab_tests
                results = await run_db(evaluate_ab_tests)
                for r in results:
                    logger.info("A/B test result: %s", r)
            except Exception as e:
                logger.debug("A/B test evaluation failed: %s", e)
            self._set_timer("ab_eval", now)

        # 12. Periodic: experiment analysis (every 6 hours)
        if now - self._timer("experiment") >= SCHEDULER_EXPERIMENT_SECONDS:
            try:
                from ..services.experiment_service import run_experiment_analysis
                result = await run_experiment_analysis()
                if result:
                    logger.info("Experiment analysis completed: %s", result.get("health_check", "")[:100])
            except Exception as e:
                logger.debug("Experiment analysis failed: %s", e)
            self._set_timer("experiment", now)

        # 13. Periodic: strategy engine cycle (every 4 hours)
        if now - self._timer("strategy_cycle") >= SCHEDULER_STRATEGY_SECONDS:
            try:
                from ..services.strategy_engine import run_strategy_cycle
                result = await run_strategy_cycle()
                if result:
                    logger.info(
                        "Strategy cycle: %d patterns, %d optimizations, %d spawned",
                        result.get("patterns_detected", 0),
                        result.get("optimizations", 0),
                        result.get("campaigns_spawned", 0),
                    )
            except Exception as e:
                logger.debug("Strategy cycle failed: %s", e)
            self._set_timer("strategy_cycle", now)

        # 13b. Periodic: communication strategist — daily planning (once per day)
        if now - self._timer("daily_strategy") >= SCHEDULER_DAILY_STRATEGY_SECONDS:
            try:
                from ..db.queries import create_scheduler_job, get_pending_job_count
                pending = await run_db(get_pending_job_count, None, JOB_DAILY_STRATEGY)
                if pending == 0:
                    await run_db(
                        create_scheduler_job, None, JOB_DAILY_STRATEGY, now
                    )
                    tick_planned += 1
                    logger.info("Scheduled daily strategy planning")
            except Exception as e:
                logger.debug("Daily strategy scheduling failed: %s", e)
            self._set_timer("daily_strategy", now)

        # 13c. Periodic: execute strategy plans (every 15 min)
        if now - self._timer("execute_strategy_plans") >= SCHEDULER_EXECUTE_PLANS_SECONDS:
            try:
                campaigns = await run_db(list_campaigns, status=STATUS_ACTIVE)
                for campaign in campaigns:
                    if campaign.get("mode") != "autopilot":
                        continue
                    campaign_id = campaign["id"]
                    from ..db.queries import create_scheduler_job, get_pending_job_count
                    pending = await run_db(
                        get_pending_job_count, campaign_id, JOB_EXECUTE_STRATEGY_PLANS
                    )
                    if pending == 0:
                        await run_db(
                            create_scheduler_job,
                            campaign_id,
                            JOB_EXECUTE_STRATEGY_PLANS,
                            now,
                        )
                        tick_planned += 1
            except Exception as e:
                logger.debug("Strategy plans execution scheduling failed: %s", e)
            self._set_timer("execute_strategy_plans", now)

        # 14. Periodic: partner follow-up reminders (every hour)
        if now - self._timer("partner_reminder") >= SCHEDULER_PARTNER_REMINDER_SECONDS:
            try:
                from ..db.queries import create_scheduler_job
                await run_db(create_scheduler_job, None, None, JOB_PARTNER_REMINDER)
            except Exception as e:
                logger.debug("Partner reminder scheduling failed: %s", e)
            self._set_timer("partner_reminder", now)

        # 15. Periodic: daily digest (once per day)
        if now - self._timer("daily_digest") >= SCHEDULER_DAILY_DIGEST_SECONDS:
            try:
                from ..db.queries import create_scheduler_job
                await run_db(create_scheduler_job, None, None, JOB_DAILY_DIGEST)
                tick_planned += 1
            except Exception as e:
                logger.debug("Daily digest scheduling failed: %s", e)
            self._set_timer("daily_digest", now)

        # 16. Periodic: engagement anomaly scan (every hour)
        if now - self._timer("anomaly_scan") >= ANOMALY_SCAN_SECONDS:
            try:
                from ..services.anomaly_detector import run_anomaly_scan

                anomalies = await run_anomaly_scan()
                if anomalies:
                    logger.warning(
                        "Anomaly scan found %d issues: %s",
                        len(anomalies),
                        ", ".join(a.anomaly_type for a in anomalies),
                    )
            except Exception as e:
                logger.debug("Anomaly scan failed: %s", e)
            self._set_timer("anomaly_scan", now)

        # ── Tick summary + persist timers + event log ──
        logger.info(
            "tick#%d: processed=%d failed=%d planned=%d scheduler=on",
            self._tick_count, tick_processed, tick_failed, tick_planned,
        )
        # Emit tick_summary event (fire-and-forget)
        await run_db(
            log_scheduler_event, "tick_summary",
            context={
                "tick": self._tick_count,
                "processed": tick_processed,
                "failed": tick_failed,
                "planned": tick_planned,
                "scheduler": "on",
            },
        )
        # Persist timers every 5 ticks (~5 min) to survive restarts
        if self._tick_count % 5 == 0:
            try:
                await run_db(save_setting, self._TIMERS_SETTING_KEY, self._timers)
            except Exception as e:
                logger.debug("Timer persistence failed: %s", e)
            # Cleanup old events (7-day retention)
            try:
                await run_db(cleanup_scheduler_events, days=7)
            except Exception as e:
                logger.debug("Event cleanup failed: %s", e)

    async def _process_ready_jobs(
        self, *, scheduler_on: bool = True
    ) -> tuple[int, int]:
        """Find and execute jobs that are ready (scheduled_at <= now).

        When ``scheduler_on`` is False, only inbound job types are executed;
        outbound jobs stay in the queue until the scheduler is re-enabled.

        Returns:
            (jobs_processed, jobs_failed) counts for tick summary.
        """
        from .executors import execute_job, categorize_error

        jobs_processed = 0
        jobs_failed = 0
        ready = await run_db(get_ready_jobs, limit=10)
        for job in ready:
            job_id = job["id"]
            job_type = job["job_type"]

            # When outbound scheduler is off, only execute inbound jobs
            if not scheduler_on and job_type not in _INBOUND_JOB_TYPES:
                continue

            # Claim the job atomically (prevents double execution)
            if not await run_db(claim_job, job_id):
                logger.debug("Job %s already claimed, skipping", job_id)
                continue

            cid = new_correlation_id()
            logger.info("Executing scheduler job: %s (type=%s, cid=%s)", job_id, job_type, cid)

            start_ms = int(time.time() * 1000)
            campaign_id = job.get("campaign_id")
            outreach_id = job.get("outreach_id")
            with tracer.start_as_current_span(f"job:{job_type}") as span:
                span.set_attribute("job.id", job_id)
                span.set_attribute("job.type", job_type)
                span.set_attribute("correlation.id", cid)
                if campaign_id:
                    span.set_attribute("campaign.id", campaign_id)
                try:
                    result = await execute_job(job)
                    duration_ms = int(time.time() * 1000) - start_ms
                    span.set_attribute("job.duration_ms", duration_ms)
                    await run_db(complete_job, job_id, duration_ms=duration_ms)
                    logger.info(
                        "Job %s completed in %dms (type=%s)",
                        job_id, duration_ms, job_type,
                    )
                    jobs_processed += 1
                    await run_db(
                        log_scheduler_event, "job_completed",
                        campaign_id=campaign_id, outreach_id=outreach_id,
                        job_id=job_id, duration_ms=duration_ms,
                        context={"job_type": job_type, "correlation_id": cid},
                    )
                except Exception as e:
                    duration_ms = int(time.time() * 1000) - start_ms
                    error_msg = str(e)[:500]
                    error_cat = categorize_error(error_msg)
                    span.set_attribute("job.duration_ms", duration_ms)
                    span.set_attribute("error.category", error_cat)
                    span.record_exception(e)
                    await run_db(
                        complete_job, job_id, error=error_msg, duration_ms=duration_ms,
                    )
                    logger.warning(
                        "Job %s failed in %dms [%s]: %s",
                        job_id, duration_ms, error_cat, error_msg,
                    )
                    jobs_failed += 1
                    await run_db(
                        log_scheduler_event, "job_failed",
                        campaign_id=campaign_id, outreach_id=outreach_id,
                        job_id=job_id, duration_ms=duration_ms,
                        context={
                            "job_type": job_type,
                            "error": error_msg[:200],
                            "error_category": error_cat,
                            "retry_count": job.get("retry_count", 0) + 1,
                            "correlation_id": cid,
                        },
                    )
                finally:
                    clear_correlation_id()

                # Retry if under max retries
                retry_count = job.get("retry_count", 0) + 1
                if retry_count < SCHEDULER_MAX_RETRIES:
                    new_time = int(time.time()) + SCHEDULER_RETRY_DELAY
                    await run_db(retry_job, job_id, new_time)
                    logger.info("Job %s scheduled for retry #%d", job_id, retry_count)

        return jobs_processed, jobs_failed

    async def _schedule_new_work(self) -> None:
        """Schedule new invite and follow-up jobs for active autopilot campaigns."""
        from .planner import plan_campaign_work

        campaigns = await run_db(list_campaigns, status=STATUS_ACTIVE)
        for campaign in campaigns:
            # Only schedule for autopilot campaigns
            if campaign.get("mode") != "autopilot":
                continue

            try:
                await plan_campaign_work(campaign["id"])
            except Exception as e:
                logger.debug("Planning failed for campaign %s: %s", campaign["id"], e)

    async def _check_auto_resume(self) -> None:
        """Resume autopilot campaigns that were system-paused due to weekly limits.

        Copilot campaigns are not auto-resumed — they get a suggestion
        in suggest_next_action() for the user to resume manually.
        """
        import json as _json
        from ..db.queries import log_action, update_campaign
        from ..linkedin.rate_limiter import can_send_now, BLOCK_DAILY, BLOCK_WEEKLY

        paused = await run_db(list_campaigns, status="paused")
        if not paused:
            return

        # All campaigns share the same LinkedIn account, so check limits once
        can_send, reason, block_type = await run_db(can_send_now)
        if block_type in (BLOCK_DAILY, BLOCK_WEEKLY):
            return  # Still limited — nothing to resume

        for campaign in paused:
            cfg = _json.loads(campaign.get("config_json") or "{}")
            pause_reason = cfg.get("pause_reason")

            # Only auto-resume campaigns paused by the system for weekly limits
            if pause_reason != "weekly_limit":
                continue

            # Only auto-resume autopilot campaigns
            if campaign.get("mode") != "autopilot":
                continue

            # Clear pause metadata and resume
            cfg.pop("pause_reason", None)
            cfg.pop("paused_at", None)
            await run_db(
                update_campaign,
                campaign["id"],
                status="active",
                config_json=_json.dumps(cfg),
            )
            await run_db(
                log_action,
                "auto_resume",
                result="resumed",
                details={
                    "campaign_id": campaign["id"],
                    "campaign_name": campaign["name"],
                    "reason": "weekly_limit_cleared",
                },
            )
            await run_db(
                log_scheduler_event,
                "campaign_auto_resumed",
                campaign_id=campaign["id"],
                context={
                    "campaign_name": campaign["name"],
                    "reason": "weekly_limit_cleared",
                },
            )
            logger.info(
                "Auto-resumed campaign %s: %s (weekly limit cleared)",
                campaign["id"], campaign["name"],
            )

    async def _schedule_reply_checks(self) -> None:
        """Schedule reply check jobs for all active campaigns."""
        from ..db.queries import create_scheduler_job, get_pending_job_count

        campaigns = await run_db(list_campaigns, status=STATUS_ACTIVE)
        now = int(time.time())

        for campaign in campaigns:
            campaign_id = campaign["id"]
            # Skip if a reply check is already pending
            if await run_db(get_pending_job_count, campaign_id, JOB_CHECK_REPLIES) > 0:
                continue

            await run_db(
                create_scheduler_job,
                campaign_id=campaign_id,
                job_type=JOB_CHECK_REPLIES,
                scheduled_at=now,  # Execute immediately
            )
            logger.debug("Scheduled reply check for campaign %s", campaign_id)

    async def _schedule_auto_replies(self) -> None:
        """Schedule auto-reply jobs for campaigns with pending reply candidates.

        For autopilot campaigns: auto-sends replies after validation.
        For copilot campaigns: queues replies for user review.
        """
        from .planner import plan_auto_replies

        campaigns = await run_db(list_campaigns, status=STATUS_ACTIVE)
        for campaign in campaigns:
            try:
                await plan_auto_replies(campaign["id"])
            except Exception as e:
                logger.debug(
                    "Auto-reply planning failed for campaign %s: %s",
                    campaign["id"], e,
                )

    async def _schedule_profile_views(self) -> None:
        """Schedule profile view warm-up jobs for active autopilot campaigns.

        Profile views are the lightest warm-up touch. The sequence is:
        Profile View → Follow → Endorse → Engage → Invite → Follow-up DM.
        """
        from .planner import plan_profile_views

        campaigns = await run_db(list_campaigns, status=STATUS_ACTIVE)
        for campaign in campaigns:
            if campaign.get("mode") != "autopilot":
                continue

            try:
                await plan_profile_views(campaign["id"])
            except Exception as e:
                logger.debug("Profile view planning failed for campaign %s: %s", campaign["id"], e)

    async def _schedule_follows(self) -> None:
        """Schedule follow warm-up jobs for active autopilot campaigns.

        Follows prospects before engaging with posts to warm them up.
        The sequence is: Follow → Engage → Invite → Follow-up DM.
        """
        from .planner import plan_follows

        campaigns = await run_db(list_campaigns, status=STATUS_ACTIVE)
        for campaign in campaigns:
            if campaign.get("mode") != "autopilot":
                continue

            try:
                await plan_follows(campaign["id"])
            except Exception as e:
                logger.debug("Follow planning failed for campaign %s: %s", campaign["id"], e)

    async def _schedule_inbound_accepts(self) -> None:
        """Schedule inbound invitation auto-accept jobs.

        Account-level (not campaign-specific) — checks received invitations
        and auto-accepts them. Runs every 15 min.
        """
        from .planner import plan_inbound_accepts

        try:
            await plan_inbound_accepts()
        except Exception as e:
            logger.debug("Inbound invitation planning failed: %s", e)

    async def _schedule_qualify_inbound(self) -> None:
        """Schedule inbound signal qualification + discovery DM sending."""
        from .planner import plan_qualify_inbound

        try:
            await plan_qualify_inbound()
        except Exception as e:
            logger.debug("Inbound qualification planning failed: %s", e)

    async def _schedule_check_post_comments(self) -> None:
        """Schedule checking published posts for new comments."""
        from .planner import plan_check_post_comments

        try:
            await plan_check_post_comments()
        except Exception as e:
            logger.debug("Post comment check planning failed: %s", e)

    async def _schedule_keyword_signal_collection(self) -> None:
        """Schedule keyword signal collection from LinkedIn posts."""
        from .planner import plan_keyword_signal_collection

        try:
            await plan_keyword_signal_collection()
        except Exception as e:
            logger.debug("Keyword signal collection planning failed: %s", e)

    async def _schedule_prospect_post_scan(self) -> None:
        """Schedule scanning campaign contacts' posts for signals."""
        from .planner import plan_prospect_post_scan

        try:
            await plan_prospect_post_scan()
        except Exception as e:
            logger.debug("Prospect post scan planning failed: %s", e)

    async def _schedule_signal_classification(self) -> None:
        """Schedule classification of pending signals."""
        from .planner import plan_signal_classification

        try:
            await plan_signal_classification()
        except Exception as e:
            logger.debug("Signal classification planning failed: %s", e)

    async def _schedule_profile_view_collection(self) -> None:
        """Schedule collection of LinkedIn profile viewers."""
        from .planner import plan_profile_view_collection

        try:
            await plan_profile_view_collection()
        except Exception as e:
            logger.debug("Profile view collection planning failed: %s", e)

    async def _schedule_job_change_detection(self) -> None:
        """Schedule job change detection for campaign contacts."""
        from .planner import plan_job_change_detection

        try:
            await plan_job_change_detection()
        except Exception as e:
            logger.debug("Job change detection planning failed: %s", e)

    async def _schedule_competitor_signal_collection(self) -> None:
        """Schedule competitor mention collection from LinkedIn posts."""
        from .planner import plan_competitor_signal_collection

        try:
            await plan_competitor_signal_collection()
        except Exception as e:
            logger.debug("Competitor signal collection planning failed: %s", e)

    async def _schedule_signal_activation(self) -> None:
        """Schedule signal activation — convert high-scoring signals to outreach."""
        from .planner import plan_signal_activation

        try:
            await plan_signal_activation()
        except Exception as e:
            logger.debug("Signal activation planning failed: %s", e)

    async def _schedule_hiring_signal_collection(self) -> None:
        """Schedule hiring surge detection from LinkedIn job searches."""
        from .planner import plan_hiring_signal_collection

        try:
            await plan_hiring_signal_collection()
        except Exception as e:
            logger.debug("Hiring signal collection planning failed: %s", e)

    async def _schedule_news_signal_collection(self) -> None:
        """Schedule news/funding signal collection from SERPER API."""
        from .planner import plan_news_signal_collection

        try:
            await plan_news_signal_collection()
        except Exception as e:
            logger.debug("News signal collection planning failed: %s", e)

    async def _schedule_compound_intent_detection(self) -> None:
        """Schedule compound intent detection — stack signals into intent events."""
        from .planner import plan_compound_intent_detection

        try:
            await plan_compound_intent_detection()
        except Exception as e:
            logger.debug("Compound intent detection planning failed: %s", e)

    async def _schedule_decay_cycle(self) -> None:
        """Schedule signal decay cycle — expire old signals and recompute scores."""
        from .planner import plan_decay_cycle

        try:
            await plan_decay_cycle()
        except Exception as e:
            logger.debug("Decay cycle planning failed: %s", e)

    async def _schedule_signal_rematch(self) -> None:
        """Schedule re-matching of homeless signals to active campaigns."""
        from .planner import plan_signal_rematch

        try:
            await plan_signal_rematch()
        except Exception as e:
            logger.debug("Signal rematch planning failed: %s", e)

    async def _schedule_orphan_backfill(self) -> None:
        """Schedule backfill of orphan signals to known contacts."""
        from .planner import plan_orphan_signal_backfill

        try:
            await plan_orphan_signal_backfill()
        except Exception as e:
            logger.debug("Orphan signal backfill planning failed: %s", e)

    async def _schedule_endorsements(self) -> None:
        """Schedule skill endorsement warm-up jobs for active autopilot campaigns.

        Endorsements go between follow and engage in the warm-up sequence:
        Follow → Endorse → Engage → Invite → Follow-up DM.
        """
        from .planner import plan_endorsements

        campaigns = await run_db(list_campaigns, status=STATUS_ACTIVE)
        for campaign in campaigns:
            if campaign.get("mode") != "autopilot":
                continue

            try:
                await plan_endorsements(campaign["id"])
            except Exception as e:
                logger.debug("Endorsement planning failed for campaign %s: %s", campaign["id"], e)

    async def _schedule_stale_withdrawals(self) -> None:
        """Schedule stale invitation withdrawal checks.

        Withdraws sent invitations older than STALE_INVITE_DAYS to free up
        LinkedIn invitation quota.
        """
        from .planner import plan_stale_invite_withdrawals

        try:
            await plan_stale_invite_withdrawals()
        except Exception as e:
            logger.debug("Stale invite withdrawal planning failed: %s", e)

    async def _schedule_engagements(self) -> None:
        """Schedule engagement warm-up jobs for active autopilot campaigns."""
        from .planner import plan_engagements

        campaigns = await run_db(list_campaigns, status=STATUS_ACTIVE)
        for campaign in campaigns:
            if campaign.get("mode") != "autopilot":
                continue

            try:
                await plan_engagements(campaign["id"])
            except Exception as e:
                logger.debug("Engagement planning failed for campaign %s: %s", campaign["id"], e)

    # ── Brand strategy scheduling ──

    async def _schedule_brand_lifecycle(self) -> None:
        """Check and manage brand strategy lifecycle (auto-analyze, auto-plan)."""
        from .planner import plan_brand_lifecycle

        try:
            await plan_brand_lifecycle()
        except Exception as e:
            logger.debug("Brand lifecycle planning failed: %s", e)

    async def _schedule_brand_posts(self) -> None:
        """Schedule brand post jobs if plan has pending post actions."""
        from .planner import plan_brand_post

        try:
            await plan_brand_post()
        except Exception as e:
            logger.debug("Brand post planning failed: %s", e)

    async def _schedule_brand_engagements(self) -> None:
        """Schedule brand engagement jobs if plan has pending engagement actions."""
        from .planner import plan_brand_engagement

        try:
            await plan_brand_engagement()
        except Exception as e:
            logger.debug("Brand engagement planning failed: %s", e)
