"""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_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_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_QUALIFY_INBOUND,
    JOB_WITHDRAW_INVITE,
    SCHEDULER_CLEANUP_DAYS,
    SCHEDULER_ENDORSE_SECONDS,
    SCHEDULER_ENGAGEMENT_SECONDS,
    SCHEDULER_FOLLOW_SECONDS,
    SCHEDULER_MAX_RETRIES,
    SCHEDULER_REPLY_CHECK_SECONDS,
    SCHEDULER_RETRY_DELAY,
    SCHEDULER_AB_EVAL_SECONDS,
    SCHEDULER_EXPERIMENT_SECONDS,
    SCHEDULER_STRATEGY_SECONDS,
    SCHEDULER_TICK_SECONDS,
    STATUS_ACTIVE,
    WITHDRAW_CHECK_SECONDS,
)
from ..db.queries import (
    claim_job,
    cleanup_old_jobs,
    complete_job,
    get_ready_jobs,
    get_setting,
    list_campaigns,
    retry_job,
)

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


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

    def __init__(self) -> None:
        self._running = False
        self._task: asyncio.Task[None] | None = None
        self._last_reply_check: float = 0
        self._last_follow_plan: float = 0
        self._last_engagement_plan: float = 0
        self._last_inbound_check: float = 0
        self._last_endorse_plan: float = 0
        self._last_withdraw_check: float = 0
        self._last_cleanup: float = 0
        self._last_qualify_inbound: float = 0
        self._last_post_comment_check: float = 0
        self._last_brand_post_plan: float = 0
        self._last_brand_engage_plan: float = 0
        self._last_brand_lifecycle_check: float = 0
        self._last_ab_eval: float = 0
        self._last_experiment: float = 0
        self._last_keyword_signal_collect: float = 0
        self._last_prospect_post_scan: float = 0
        self._last_signal_classify: float = 0
        self._last_profile_view_collect: float = 0
        self._last_job_change_scan: float = 0
        self._last_competitor_signal_collect: float = 0
        self._last_signal_activate: float = 0
        self._last_hiring_signal_collect: float = 0
        self._last_news_signal_collect: float = 0
        self._last_compound_intent_detect: float = 0
        self._last_decay_cycle: float = 0
        self._last_strategy_cycle: float = 0
        self._tick_count: int = 0
        self._errors_in_row: int = 0

    @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."""
        if self._running:
            logger.warning("Scheduler already running")
            return
        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 get_setting("setup_complete", False):
            return

        now = time.time()
        scheduler_on = is_scheduler_enabled()

        # ── 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.
        await self._process_ready_jobs(scheduler_on=scheduler_on)

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

        # Accept inbound invitations (every 15 min)
        if now - self._last_inbound_check >= INBOUND_CHECK_SECONDS:
            await self._schedule_inbound_accepts()
            self._last_inbound_check = now

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

        # Check post comments for inbound leads (every 30 min)
        if now - self._last_post_comment_check >= POST_COMMENT_CHECK_SECONDS:
            await self._schedule_check_post_comments()
            self._last_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._last_signal_classify >= SIGNAL_CLASSIFY_SECONDS:
            await self._schedule_signal_classification()
            self._last_signal_classify = now

        # Activate classified signals (every 15 min)
        if now - self._last_signal_activate >= SIGNAL_ACTIVATE_SECONDS:
            await self._schedule_signal_activation()
            self._last_signal_activate = now

        # ── OUTBOUND: requires scheduler to be enabled ──
        if not scheduler_on:
            return

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

        # Periodic: check replies (every 5 min)
        if now - self._last_reply_check >= SCHEDULER_REPLY_CHECK_SECONDS:
            await self._schedule_reply_checks()
            self._last_reply_check = now

        # Periodic: follow warm-ups (every 15 min)
        if now - self._last_follow_plan >= SCHEDULER_FOLLOW_SECONDS:
            await self._schedule_follows()
            self._last_follow_plan = now

        # Periodic: engagement warm-ups (every 30 min)
        if now - self._last_engagement_plan >= SCHEDULER_ENGAGEMENT_SECONDS:
            await self._schedule_engagements()
            self._last_engagement_plan = now

        # 6d. Periodic: collect keyword signals (every 30 min)
        if now - self._last_keyword_signal_collect >= SIGNAL_KEYWORD_POLL_SECONDS:
            await self._schedule_keyword_signal_collection()
            self._last_keyword_signal_collect = now

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

        # 6g. Periodic: collect profile views (every 1 hour)
        if now - self._last_profile_view_collect >= SIGNAL_PROFILE_VIEW_SECONDS:
            await self._schedule_profile_view_collection()
            self._last_profile_view_collect = now

        # 6h. Periodic: detect job changes (every 4 hours)
        if now - self._last_job_change_scan >= SIGNAL_JOB_CHANGE_SCAN_SECONDS:
            await self._schedule_job_change_detection()
            self._last_job_change_scan = now

        # 6i. Periodic: collect competitor mentions (every 30 min)
        if now - self._last_competitor_signal_collect >= SIGNAL_COMPETITOR_POLL_SECONDS:
            await self._schedule_competitor_signal_collection()
            self._last_competitor_signal_collect = now

        # 6k. Periodic: detect hiring surges (every 4 hours)
        if now - self._last_hiring_signal_collect >= SIGNAL_HIRING_SCAN_SECONDS:
            await self._schedule_hiring_signal_collection()
            self._last_hiring_signal_collect = now

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

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

        # 6n. Periodic: signal decay cycle (every 6 hours)
        if now - self._last_decay_cycle >= SIGNAL_DECAY_CYCLE_SECONDS:
            await self._schedule_decay_cycle()
            self._last_decay_cycle = now

        # 7. Periodic: skill endorsement warm-ups (every 15 min)
        if now - self._last_endorse_plan >= SCHEDULER_ENDORSE_SECONDS:
            await self._schedule_endorsements()
            self._last_endorse_plan = now

        # 8. Periodic: stale invite withdrawal (every hour)
        if now - self._last_withdraw_check >= WITHDRAW_CHECK_SECONDS:
            await self._schedule_stale_withdrawals()
            self._last_withdraw_check = now

        # 9a. Brand lifecycle (every hour)
        if now - self._last_brand_lifecycle_check >= BRAND_LIFECYCLE_CHECK_SECONDS:
            await self._schedule_brand_lifecycle()
            self._last_brand_lifecycle_check = now

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

        # 9c. Brand engagement (every 4 hours)
        if now - self._last_brand_engage_plan >= BRAND_ENGAGE_CHECK_SECONDS:
            await self._schedule_brand_engagements()
            self._last_brand_engage_plan = now

        # 10. Cleanup old completed jobs (once per hour)
        if now - self._last_cleanup >= 3600:
            try:
                deleted = 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 = cleanup_failed_engage_jobs()
                invite_result = 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)

            self._last_cleanup = now

        # 11. Periodic: A/B test evaluation (every hour)
        if now - self._last_ab_eval >= SCHEDULER_AB_EVAL_SECONDS:
            try:
                from ..services.experiment_service import evaluate_ab_tests
                results = 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._last_ab_eval = now

        # 12. Periodic: experiment analysis (every 6 hours)
        if now - self._last_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._last_experiment = now

        # 13. Periodic: strategy engine cycle (every 4 hours)
        if now - self._last_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._last_strategy_cycle = now

    async def _process_ready_jobs(self, *, scheduler_on: bool = True) -> None:
        """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.
        """
        from .executors import execute_job

        ready = get_ready_jobs(limit=5)
        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 claim_job(job_id):
                logger.debug("Job %s already claimed, skipping", job_id)
                continue

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

            try:
                result = await execute_job(job)
                complete_job(job_id)
                logger.info("Job %s completed successfully", job_id)
            except Exception as e:
                error_msg = str(e)[:500]
                complete_job(job_id, error=error_msg)
                logger.warning("Job %s failed: %s", job_id, error_msg)

                # 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
                    retry_job(job_id, new_time)
                    logger.info("Job %s scheduled for retry #%d", job_id, retry_count)

    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 = 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 _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 = 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 get_pending_job_count(campaign_id, JOB_CHECK_REPLIES) > 0:
                continue

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