"""Work planner — determines WHAT to schedule and WHEN.

Analyzes active autopilot campaigns and creates scheduler jobs for:
1. Follows: warm-up follows on prospect profiles (new in Sprint 28)
2. Invitations: pending outreaches that need to be sent
3. Follow-ups: connected outreaches that are due based on schedule
4. Engagements: warm-up comments/reactions before inviting

All decisions respect:
- Rate limits (can_send_now, daily/weekly caps)
- Follow-up schedule (PRO_FOLLOWUP_SCHEDULE_DAYS: 1, 7, 14, 21, 28 days)
- Deduplication (no duplicate pending jobs for same outreach)
- Randomized delays (10-25 min follows, 20-40 min invites, etc.)
"""

from __future__ import annotations

import logging
import random
import time
from typing import Any

from ..config import get_tier
from ..constants import (
    DAILY_ENDORSE_LIMIT,
    DAILY_ENGAGEMENT_LIMIT,
    DAILY_FOLLOW_LIMIT,
    ENDORSE_DELAY_MAX,
    ENDORSE_DELAY_MIN,
    ENGAGEMENT_DELAY_MAX,
    ENGAGEMENT_DELAY_MIN,
    FOLLOW_DELAY_MAX,
    FOLLOW_DELAY_MIN,
    FOLLOWUP_DELAY_MAX,
    FOLLOWUP_DELAY_MIN,
    FREE_MAX_FOLLOWUPS,
    INVITE_DELAY_MAX,
    INVITE_DELAY_MIN,
    JOB_ACCEPT_INBOUND,
    JOB_CHECK_POST_COMMENTS,
    JOB_ENDORSE,
    JOB_ENGAGE,
    JOB_FOLLOW,
    JOB_FOLLOWUP,
    JOB_INVITE,
    JOB_QUALIFY_INBOUND,
    JOB_WITHDRAW_INVITE,
    PRO_FOLLOWUP_SCHEDULE_DAYS,
    PRO_MAX_FOLLOWUPS,
    STALE_INVITE_DAYS,
    TIER_PRO,
)
from ..db.queries import (
    create_scheduler_job,
    get_daily_engagement_count,
    get_engagement_candidates,
    get_follow_candidates,
    get_followup_candidates,
    get_messages_for_outreach,
    get_outreach,
    get_pending_job_count,
    get_pending_outreach_job,
    list_campaigns,
)
from ..linkedin.rate_limiter import can_send_now

logger = logging.getLogger(__name__)


async def plan_campaign_work(campaign_id: str) -> None:
    """Analyze an autopilot campaign and schedule invite + follow-up jobs.

    Called by the scheduler engine every tick for each active autopilot campaign.
    Creates jobs in the scheduler_jobs table with randomized future scheduled_at times.
    """
    now = int(time.time())
    tier = get_tier()

    # ── 1. Invitations ──
    await _plan_invitations(campaign_id, now)

    # ── 2. Follow-ups ──
    await _plan_followups(campaign_id, now, tier)


async def plan_follows(campaign_id: str) -> None:
    """Schedule follow jobs for pending outreaches that haven't been followed yet.

    Called every 15 min by the scheduler engine. Follows warm up prospects
    by triggering a "X started following you" notification before connecting.
    """
    now = int(time.time())

    # Check daily follow limit
    daily_count = get_daily_engagement_count()  # follows share the engagement counter
    if daily_count >= DAILY_FOLLOW_LIMIT:
        logger.debug("Daily follow limit reached (%d/%d)", daily_count, DAILY_FOLLOW_LIMIT)
        return

    # Check for existing pending follow jobs for this campaign (dedup)
    pending_count = get_pending_job_count(campaign_id, JOB_FOLLOW)
    if pending_count >= 4:
        logger.debug("Already %d pending follow jobs for campaign %s", pending_count, campaign_id)
        return

    # Find pending outreaches that haven't been followed yet
    candidates = get_follow_candidates(campaign_id)
    if not candidates:
        return

    # Schedule up to 4 follow jobs with staggered delays
    scheduled = 0
    for candidate in candidates[:6]:
        outreach_id = candidate["outreach_id"]

        # Skip if this outreach already has a pending follow job
        if get_pending_outreach_job(outreach_id, JOB_FOLLOW):
            continue

        delay = random.randint(FOLLOW_DELAY_MIN, FOLLOW_DELAY_MAX)
        scheduled_at = now + delay + (scheduled * random.randint(300, 600))

        create_scheduler_job(
            campaign_id=campaign_id,
            job_type=JOB_FOLLOW,
            scheduled_at=scheduled_at,
            outreach_id=outreach_id,
        )
        logger.debug(
            "Scheduled follow for outreach %s in %d min",
            outreach_id, (scheduled_at - now) // 60,
        )
        scheduled += 1
        if scheduled >= 4:
            break


async def plan_engagements(campaign_id: str) -> None:
    """Schedule engagement warm-up jobs for an autopilot campaign.

    Called less frequently (every 30 min) since engagements are lower priority.
    """
    now = int(time.time())

    # Check daily engagement limit
    daily_count = get_daily_engagement_count()
    if daily_count >= DAILY_ENGAGEMENT_LIMIT:
        logger.debug("Daily engagement limit reached (%d/%d)", daily_count, DAILY_ENGAGEMENT_LIMIT)
        return

    # Check for existing pending engagement jobs for this campaign
    pending_count = get_pending_job_count(campaign_id, JOB_ENGAGE)
    if pending_count >= 4:
        logger.debug("Already %d pending engagement jobs for campaign %s", pending_count, campaign_id)
        return

    # Find engagement candidates
    candidates = get_engagement_candidates(campaign_id, max_per_outreach=3)
    if not candidates:
        return

    # Schedule up to 4 engagement jobs with staggered delays
    scheduled = 0
    for candidate in candidates[:6]:
        outreach_id = candidate["outreach_id"]

        # Skip if this outreach already has a pending engagement job
        if get_pending_outreach_job(outreach_id, JOB_ENGAGE):
            continue

        delay = random.randint(ENGAGEMENT_DELAY_MIN, ENGAGEMENT_DELAY_MAX)
        scheduled_at = now + delay + (scheduled * random.randint(300, 600))

        create_scheduler_job(
            campaign_id=campaign_id,
            job_type=JOB_ENGAGE,
            scheduled_at=scheduled_at,
            outreach_id=outreach_id,
        )
        logger.debug(
            "Scheduled engagement for outreach %s in %d min",
            outreach_id, (scheduled_at - now) // 60,
        )
        scheduled += 1
        if scheduled >= 4:
            break


async def _plan_invitations(campaign_id: str, now: int) -> None:
    """Schedule invitation jobs for pending outreaches."""
    # Check rate limits first
    can_send, reason = can_send_now()
    if not can_send:
        logger.debug("Cannot send invitations: %s", reason)
        return

    # Check for existing pending invite jobs (dedup)
    pending_invites = get_pending_job_count(campaign_id, JOB_INVITE)
    if pending_invites >= 2:
        logger.debug("Already %d pending invite jobs for campaign %s", pending_invites, campaign_id)
        return

    # Check if there are pending outreaches to invite
    # (the generate_and_send tool internally picks by fit_score)
    from ..db.queries import count_outreaches_by_status
    pending_count = count_outreaches_by_status(campaign_id, "pending")

    if pending_count == 0:
        return

    # Schedule one invitation with randomized delay
    delay = random.randint(INVITE_DELAY_MIN, INVITE_DELAY_MAX)
    scheduled_at = now + delay

    create_scheduler_job(
        campaign_id=campaign_id,
        job_type=JOB_INVITE,
        scheduled_at=scheduled_at,
    )
    logger.debug(
        "Scheduled invitation for campaign %s in %d min",
        campaign_id, delay // 60,
    )


async def _plan_followups(campaign_id: str, now: int, tier: str) -> None:
    """Schedule follow-up jobs for connected outreaches that are due."""
    max_followups = PRO_MAX_FOLLOWUPS if tier == TIER_PRO else FREE_MAX_FOLLOWUPS

    # Check for existing pending followup jobs (dedup)
    pending_followups = get_pending_job_count(campaign_id, JOB_FOLLOWUP)
    if pending_followups >= 3:
        logger.debug("Already %d pending followup jobs for campaign %s", pending_followups, campaign_id)
        return

    # Find connected outreaches ready for follow-up
    candidates = get_followup_candidates(campaign_id, max_followups)
    if not candidates:
        return

    scheduled = 0
    for candidate in candidates:
        outreach_id = candidate["outreach_id"]

        # Skip if this outreach already has a pending followup job
        if get_pending_outreach_job(outreach_id, JOB_FOLLOWUP):
            continue

        # Check if follow-up is due based on schedule
        if not _is_followup_due(candidate, tier):
            continue

        # Schedule with randomized delay + stagger
        delay = random.randint(FOLLOWUP_DELAY_MIN, FOLLOWUP_DELAY_MAX)
        scheduled_at = now + delay + (scheduled * random.randint(300, 600))

        create_scheduler_job(
            campaign_id=campaign_id,
            job_type=JOB_FOLLOWUP,
            scheduled_at=scheduled_at,
            outreach_id=outreach_id,
        )
        logger.debug(
            "Scheduled follow-up #%d for outreach %s in %d min",
            candidate.get("followup_count", 0) + 1,
            outreach_id,
            (scheduled_at - now) // 60,
        )
        scheduled += 1
        if scheduled >= 2:
            break


async def plan_endorsements(campaign_id: str) -> None:
    """Schedule endorsement jobs for prospects that haven't been endorsed yet.

    Called every 15 min. Endorses prospects' skills as a high-visibility
    warm-up touch (triggers "X endorsed your skills" notification).
    Sequence: Follow → Endorse → Engage → Invite → Follow-up DM.
    """
    now = int(time.time())

    # Check daily endorsement limit
    daily_count = get_daily_engagement_count()  # shares engagement counter
    if daily_count >= DAILY_ENDORSE_LIMIT:
        logger.debug("Daily endorse limit reached (%d/%d)", daily_count, DAILY_ENDORSE_LIMIT)
        return

    # Dedup: check for existing pending endorse jobs
    pending_count = get_pending_job_count(campaign_id, JOB_ENDORSE)
    if pending_count >= 3:
        logger.debug("Already %d pending endorse jobs for campaign %s", pending_count, campaign_id)
        return

    # Find candidates: pending or followed outreaches without an endorsement engagement
    from ..db.schema import get_db
    db = get_db()
    candidates = db.execute(
        """SELECT o.id as outreach_id
           FROM outreaches o
           JOIN contacts c ON o.contact_id = c.id
           WHERE o.campaign_id = ?
             AND o.status IN ('pending', 'invited')
             AND o.next_action IS NULL
             AND NOT EXISTS (
                 SELECT 1 FROM engagements e
                 WHERE e.outreach_id = o.id AND e.action_type = 'endorse'
             )
           ORDER BY c.fit_score DESC
           LIMIT 6""",
        (campaign_id,),
    ).fetchall()
    db.close()

    if not candidates:
        return

    scheduled = 0
    for cand in candidates:
        outreach_id = cand["outreach_id"]

        if get_pending_outreach_job(outreach_id, JOB_ENDORSE):
            continue

        delay = random.randint(ENDORSE_DELAY_MIN, ENDORSE_DELAY_MAX)
        scheduled_at = now + delay + (scheduled * random.randint(300, 600))

        create_scheduler_job(
            campaign_id=campaign_id,
            job_type=JOB_ENDORSE,
            scheduled_at=scheduled_at,
            outreach_id=outreach_id,
        )
        logger.debug(
            "Scheduled endorsement for outreach %s in %d min",
            outreach_id, (scheduled_at - now) // 60,
        )
        scheduled += 1
        if scheduled >= 3:
            break


async def plan_stale_invite_withdrawals() -> None:
    """Schedule a single job to check and withdraw stale sent invitations.

    Called every hour. Finds sent invitations older than STALE_INVITE_DAYS
    and schedules withdrawal to free up invite quota.
    """
    now = int(time.time())

    # Only schedule if no withdraw job is already pending
    pending_count = get_pending_job_count(None, JOB_WITHDRAW_INVITE)
    if pending_count > 0:
        logger.debug("Already %d pending withdraw jobs", pending_count)
        return

    create_scheduler_job(
        campaign_id=None,
        job_type=JOB_WITHDRAW_INVITE,
        scheduled_at=now,
    )
    logger.debug("Scheduled stale invite withdrawal check")


async def plan_inbound_accepts() -> None:
    """Schedule a single job to check and auto-accept inbound invitations.

    Called every 15 min by the scheduler engine. Creates one JOB_ACCEPT_INBOUND
    job that the executor will handle (fetch invitations, filter by ICP, accept).
    """
    now = int(time.time())

    # Only schedule if no accept_inbound job is already pending
    # Use campaign_id="" since inbound accepts are account-level, not campaign-specific
    pending_count = get_pending_job_count(None, JOB_ACCEPT_INBOUND)
    if pending_count > 0:
        logger.debug("Already %d pending inbound accept jobs", pending_count)
        return

    create_scheduler_job(
        campaign_id=None,
        job_type=JOB_ACCEPT_INBOUND,
        scheduled_at=now,  # Execute immediately
    )
    logger.debug("Scheduled inbound invitation check")


async def plan_qualify_inbound() -> None:
    """Schedule a job to qualify new inbound signals and send discovery DMs.

    Called every 15 min by the scheduler engine. Creates one JOB_QUALIFY_INBOUND
    job that qualifies pending signals and sends autopilot discovery DMs.
    """
    now = int(time.time())

    pending_count = get_pending_job_count(None, JOB_QUALIFY_INBOUND)
    if pending_count > 0:
        logger.debug("Already %d pending qualify_inbound jobs", pending_count)
        return

    create_scheduler_job(
        campaign_id=None,
        job_type=JOB_QUALIFY_INBOUND,
        scheduled_at=now,
    )
    logger.debug("Scheduled inbound qualification + discovery DMs")


async def plan_check_post_comments() -> None:
    """Schedule a job to check comments on recently published posts.

    Called every 30 min by the scheduler engine.
    """
    now = int(time.time())

    pending_count = get_pending_job_count(None, JOB_CHECK_POST_COMMENTS)
    if pending_count > 0:
        logger.debug("Already %d pending check_post_comments jobs", pending_count)
        return

    create_scheduler_job(
        campaign_id=None,
        job_type=JOB_CHECK_POST_COMMENTS,
        scheduled_at=now,
    )
    logger.debug("Scheduled post comment check")


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


async def plan_keyword_signal_collection() -> None:
    """Schedule a job to collect keyword signals from LinkedIn post searches.

    Called every 30 min by the scheduler engine. Creates one
    JOB_COLLECT_KEYWORD_SIGNALS job that iterates all active watchlists.
    """
    from ..constants import JOB_COLLECT_KEYWORD_SIGNALS

    now = int(time.time())

    pending_count = get_pending_job_count(None, JOB_COLLECT_KEYWORD_SIGNALS)
    if pending_count > 0:
        logger.debug("Already %d pending keyword signal collection jobs", pending_count)
        return

    # Only schedule if there are active watchlists
    from ..db.signal_queries import list_watchlists
    watchlists = list_watchlists(is_active=True)
    if not watchlists:
        return

    create_scheduler_job(
        campaign_id=None,
        job_type=JOB_COLLECT_KEYWORD_SIGNALS,
        scheduled_at=now,
    )
    logger.debug("Scheduled keyword signal collection (%d active watchlists)", len(watchlists))


async def plan_prospect_post_scan() -> None:
    """Schedule a job to scan campaign contacts' recent posts for signals.

    Called every 1 hour by the scheduler engine. Creates one
    JOB_SCAN_PROSPECT_POSTS job that iterates active campaign contacts.
    """
    from ..constants import JOB_SCAN_PROSPECT_POSTS

    now = int(time.time())

    pending_count = get_pending_job_count(None, JOB_SCAN_PROSPECT_POSTS)
    if pending_count > 0:
        logger.debug("Already %d pending prospect post scan jobs", pending_count)
        return

    # Only schedule if there are active campaigns with contacts
    active = list_campaigns(status="active")
    if not active:
        return

    create_scheduler_job(
        campaign_id=None,
        job_type=JOB_SCAN_PROSPECT_POSTS,
        scheduled_at=now,
    )
    logger.debug("Scheduled prospect post scan (%d active campaigns)", len(active))


async def plan_signal_classification() -> None:
    """Schedule a job to classify pending signals with LLM.

    Called every 15 min by the scheduler engine. Creates one
    JOB_CLASSIFY_SIGNALS job that processes signals with status='new'.
    """
    from ..constants import JOB_CLASSIFY_SIGNALS

    now = int(time.time())

    pending_count = get_pending_job_count(None, JOB_CLASSIFY_SIGNALS)
    if pending_count > 0:
        logger.debug("Already %d pending signal classification jobs", pending_count)
        return

    # Only schedule if there are unclassified signals
    from ..db.signal_queries import list_signals
    from ..constants import SIGNAL_STATUS_NEW

    unclassified = list_signals(status=SIGNAL_STATUS_NEW, limit=1)
    if not unclassified:
        return

    create_scheduler_job(
        campaign_id=None,
        job_type=JOB_CLASSIFY_SIGNALS,
        scheduled_at=now,
    )
    logger.debug("Scheduled signal classification")


async def plan_profile_view_collection() -> None:
    """Schedule a job to collect LinkedIn profile viewers.

    Called every 1 hour by the scheduler engine. Creates one
    JOB_COLLECT_PROFILE_VIEWS job. Requires LinkedIn Premium.
    """
    from ..constants import JOB_COLLECT_PROFILE_VIEWS

    now = int(time.time())

    pending_count = get_pending_job_count(None, JOB_COLLECT_PROFILE_VIEWS)
    if pending_count > 0:
        logger.debug("Already %d pending profile view collection jobs", pending_count)
        return

    create_scheduler_job(
        campaign_id=None,
        job_type=JOB_COLLECT_PROFILE_VIEWS,
        scheduled_at=now,
    )
    logger.debug("Scheduled profile view collection")


async def plan_job_change_detection() -> None:
    """Schedule a job to detect job title/company changes for campaign contacts.

    Called every 4 hours by the scheduler engine. Creates one
    JOB_DETECT_JOB_CHANGES job that scans contacts not recently scanned.
    """
    from ..constants import JOB_DETECT_JOB_CHANGES

    now = int(time.time())

    pending_count = get_pending_job_count(None, JOB_DETECT_JOB_CHANGES)
    if pending_count > 0:
        logger.debug("Already %d pending job change detection jobs", pending_count)
        return

    # Only schedule if there are active campaigns with contacts
    active = list_campaigns(status="active")
    if not active:
        return

    create_scheduler_job(
        campaign_id=None,
        job_type=JOB_DETECT_JOB_CHANGES,
        scheduled_at=now,
    )
    logger.debug("Scheduled job change detection (%d active campaigns)", len(active))


async def plan_competitor_signal_collection() -> None:
    """Schedule a job to collect competitor mention signals from LinkedIn posts.

    Called every 30 min by the scheduler engine. Creates one
    JOB_COLLECT_COMPETITOR_SIGNALS job that searches for competitor names.
    """
    from ..constants import JOB_COLLECT_COMPETITOR_SIGNALS

    now = int(time.time())

    pending_count = get_pending_job_count(None, JOB_COLLECT_COMPETITOR_SIGNALS)
    if pending_count > 0:
        logger.debug("Already %d pending competitor signal collection jobs", pending_count)
        return

    # Only schedule if there are active competitor watchlists
    from ..db.signal_queries import list_watchlists

    comp_watchlists = list_watchlists(is_active=True, watch_type="competitor")
    if not comp_watchlists:
        return

    create_scheduler_job(
        campaign_id=None,
        job_type=JOB_COLLECT_COMPETITOR_SIGNALS,
        scheduled_at=now,
    )
    logger.debug(
        "Scheduled competitor signal collection (%d competitor watchlists)",
        len(comp_watchlists),
    )


async def plan_signal_activation() -> None:
    """Schedule signal activation — convert classified signals to outreach.

    Called every 15 min by the scheduler engine. Creates one
    JOB_ACTIVATE_SIGNALS job if classified signals exist.
    """
    from ..constants import JOB_ACTIVATE_SIGNALS, SIGNAL_STATUS_CLASSIFIED

    now = int(time.time())

    pending_count = get_pending_job_count(None, JOB_ACTIVATE_SIGNALS)
    if pending_count > 0:
        logger.debug("Already %d pending signal activation jobs", pending_count)
        return

    # Only schedule if there are classified signals to process
    from ..db.signal_queries import list_signals

    classified = list_signals(status=SIGNAL_STATUS_CLASSIFIED, limit=1)
    if not classified:
        return

    create_scheduler_job(
        campaign_id=None,
        job_type=JOB_ACTIVATE_SIGNALS,
        scheduled_at=now,
    )
    logger.debug("Scheduled signal activation")


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

    Called every 4 hours by the scheduler engine. Creates one
    JOB_COLLECT_HIRING_SIGNALS job if no pending job exists.
    """
    from ..constants import JOB_COLLECT_HIRING_SIGNALS

    now = int(time.time())

    pending_count = get_pending_job_count(None, JOB_COLLECT_HIRING_SIGNALS)
    if pending_count > 0:
        return

    create_scheduler_job(
        campaign_id=None,
        job_type=JOB_COLLECT_HIRING_SIGNALS,
        scheduled_at=now,
    )
    logger.debug("Scheduled hiring signal collection")


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

    Called every 4 hours by the scheduler engine. Creates one
    JOB_COLLECT_NEWS_SIGNALS job if no pending job exists.
    """
    from ..constants import JOB_COLLECT_NEWS_SIGNALS

    now = int(time.time())

    pending_count = get_pending_job_count(None, JOB_COLLECT_NEWS_SIGNALS)
    if pending_count > 0:
        return

    create_scheduler_job(
        campaign_id=None,
        job_type=JOB_COLLECT_NEWS_SIGNALS,
        scheduled_at=now,
    )
    logger.debug("Scheduled news signal collection")


async def plan_compound_intent_detection() -> None:
    """Schedule compound intent detection — stack weak signals into strong events.

    Called every 30 minutes by the scheduler engine. Creates one
    JOB_DETECT_COMPOUND_INTENT job if no pending job exists.
    """
    from ..constants import JOB_DETECT_COMPOUND_INTENT

    now = int(time.time())

    pending_count = get_pending_job_count(None, JOB_DETECT_COMPOUND_INTENT)
    if pending_count > 0:
        return

    create_scheduler_job(
        campaign_id=None,
        job_type=JOB_DETECT_COMPOUND_INTENT,
        scheduled_at=now,
    )
    logger.debug("Scheduled compound intent detection")


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

    Called every 6 hours by the scheduler engine. Creates one
    JOB_SIGNAL_DECAY_CYCLE job if no pending job exists.
    """
    from ..constants import JOB_SIGNAL_DECAY_CYCLE

    now = int(time.time())

    pending_count = get_pending_job_count(None, JOB_SIGNAL_DECAY_CYCLE)
    if pending_count > 0:
        return

    create_scheduler_job(
        campaign_id=None,
        job_type=JOB_SIGNAL_DECAY_CYCLE,
        scheduled_at=now,
    )
    logger.debug("Scheduled signal decay cycle")


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


async def plan_brand_post() -> None:
    """Schedule a brand post job if the plan has a pending post action.

    Called every hour. Checks:
    1. Brand plan exists with pending post actions
    2. No pending brand_post job already (dedup)
    3. Daily brand post limit not reached
    """
    from ..constants import (
        BRAND_POST_DELAY_MAX,
        BRAND_POST_DELAY_MIN,
        DAILY_BRAND_POST_LIMIT,
        JOB_BRAND_POST,
    )
    from ..services.brand_service import (
        get_next_pending_action_by_type,
        load_brand_plan,
    )

    plan = load_brand_plan()
    if not plan:
        return

    # Check for pending post actions
    next_post = get_next_pending_action_by_type(plan, "post")
    if not next_post:
        return

    # Dedup: no pending brand_post jobs
    pending = get_pending_job_count(None, JOB_BRAND_POST)
    if pending > 0:
        logger.debug("Already %d pending brand_post jobs", pending)
        return

    # Daily limit: count completed brand_post jobs today
    from ..db.queries import get_daily_brand_post_count

    today_count = get_daily_brand_post_count()
    if today_count >= DAILY_BRAND_POST_LIMIT:
        logger.debug("Daily brand post limit reached (%d/%d)", today_count, DAILY_BRAND_POST_LIMIT)
        return

    now = int(time.time())
    delay = random.randint(BRAND_POST_DELAY_MIN, BRAND_POST_DELAY_MAX)
    scheduled_at = now + delay

    create_scheduler_job(
        campaign_id=None,
        job_type=JOB_BRAND_POST,
        scheduled_at=scheduled_at,
    )
    logger.debug("Scheduled brand post in %d min", delay // 60)


async def plan_brand_engagement() -> None:
    """Schedule a brand engagement job if the plan has a pending engagement action.

    Called every 4 hours. Checks:
    1. Brand plan exists with pending engagement actions
    2. No pending brand_engage job already (dedup)
    3. Combined daily engagement limit not exceeded
    """
    from ..constants import (
        BRAND_ENGAGE_DELAY_MAX,
        BRAND_ENGAGE_DELAY_MIN,
        DAILY_BRAND_ENGAGE_LIMIT,
        JOB_BRAND_ENGAGE,
    )
    from ..services.brand_service import (
        get_next_pending_action_by_type,
        load_brand_plan,
    )

    plan = load_brand_plan()
    if not plan:
        return

    next_engage = get_next_pending_action_by_type(plan, "engagement")
    if not next_engage:
        return

    # Dedup
    pending = get_pending_job_count(None, JOB_BRAND_ENGAGE)
    if pending > 0:
        logger.debug("Already %d pending brand_engage jobs", pending)
        return

    # Shared engagement limit check
    daily_total = get_daily_engagement_count()
    if daily_total >= DAILY_ENGAGEMENT_LIMIT:
        logger.debug("Daily engagement limit reached (%d/%d)", daily_total, DAILY_ENGAGEMENT_LIMIT)
        return

    # Brand-specific daily limit
    from ..db.queries import get_daily_brand_engage_count

    brand_today = get_daily_brand_engage_count()
    if brand_today >= DAILY_BRAND_ENGAGE_LIMIT:
        logger.debug("Daily brand engage limit reached (%d/%d)", brand_today, DAILY_BRAND_ENGAGE_LIMIT)
        return

    now = int(time.time())
    delay = random.randint(BRAND_ENGAGE_DELAY_MIN, BRAND_ENGAGE_DELAY_MAX)
    scheduled_at = now + delay

    create_scheduler_job(
        campaign_id=None,
        job_type=JOB_BRAND_ENGAGE,
        scheduled_at=scheduled_at,
    )
    logger.debug("Scheduled brand engagement in %d min", delay // 60)


async def plan_brand_lifecycle() -> None:
    """Manage brand strategy lifecycle: auto-analyze, auto-plan, re-analyze.

    Called every hour. Handles:
    1. No analysis → schedule brand_analyze (first-time auto-analyze)
    2. Analysis exists but no plan → schedule brand_analyze (will auto-plan)
    3. Plan complete + age >= 28 days → schedule brand_analyze (re-analyze cycle)
    """
    from ..constants import BRAND_REANALYZE_DAYS, JOB_BRAND_ANALYZE
    from ..db.queries import get_setting
    from ..services.brand_service import (
        get_brand_age_days,
        is_plan_complete,
        load_brand_analysis,
        load_brand_plan,
    )

    # Dedup
    pending = get_pending_job_count(None, JOB_BRAND_ANALYZE)
    if pending > 0:
        return

    analysis = load_brand_analysis()
    plan = load_brand_plan()
    now = int(time.time())

    should_analyze = False

    # Check if campaign creation flagged a re-analysis (ICP changed)
    reanalyze_needed = get_setting("brand_reanalyze_needed", False)
    if reanalyze_needed:
        should_analyze = True
        logger.debug("Brand: ICP-driven re-analysis flagged by campaign creation")
    elif not analysis:
        # First-time: auto-analyze
        should_analyze = True
        logger.debug("Brand: no analysis found, scheduling auto-analyze")
    elif not plan:
        # Analysis exists but no plan — will auto-plan after analyzing
        should_analyze = True
        logger.debug("Brand: analysis exists but no plan, scheduling auto-plan")
    elif plan and is_plan_complete(plan):
        # Plan completed — check if 28 days have passed for re-analyze
        age_days = get_brand_age_days()
        if age_days >= BRAND_REANALYZE_DAYS:
            should_analyze = True
            logger.debug("Brand: plan complete + %d days old, scheduling re-analyze", age_days)

    if should_analyze:
        create_scheduler_job(
            campaign_id=None,
            job_type=JOB_BRAND_ANALYZE,
            scheduled_at=now + random.randint(300, 900),  # 5-15 min delay
        )


def _is_followup_due(candidate: dict[str, Any], tier: str) -> bool:
    """Check if a follow-up is due based on the schedule.

    Uses PRO_FOLLOWUP_SCHEDULE_DAYS for Pro tier: [1, 7, 14, 21, 28]
    Free tier: simpler check (just needs to have been connected for >= 1 day)

    Args:
        candidate: Outreach candidate from get_followup_candidates()
        tier: User's tier ('free' or 'pro')

    Returns:
        True if the follow-up is due now.
    """
    followup_count = candidate.get("followup_count", 0)
    outreach_updated = candidate.get("outreach_updated_at", 0)

    if not outreach_updated:
        return False

    now = int(time.time())
    days_since = (now - outreach_updated) // 86400

    if tier == TIER_PRO and followup_count > 0:
        # Pro tier: use schedule
        schedule_idx = min(followup_count, len(PRO_FOLLOWUP_SCHEDULE_DAYS) - 1)
        required_days = PRO_FOLLOWUP_SCHEDULE_DAYS[schedule_idx]
        return days_since >= required_days
    else:
        # First follow-up (any tier) or free tier: wait at least 1 day
        return days_since >= 1
