"""Sequence engine — multi-touch outreach with purpose progression.

Core concept: envelopes, not fixed schedules. The operator defines
N touches across D days, the system distributes purposes intelligently,
drafts all touches as a coherent sequence.

Accordion model: same purposes and principles apply whether 3 touches
over 30 days or 8 touches over 90 days.
"""

import math
import yaml
import sys
import threading
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime, timedelta
from pathlib import Path
from typing import Optional

from state.backlog import get_lead, update_lead
from capabilities.drafting import (
    draft_touch, _build_context_object, SIGNATURE,
    _build_base_drafting_rules, _determine_greeting, _build_renewal_prompt,
    validate_no_competitor_brands, validate_no_fabricated_conversations,
    validate_no_restricted_models, validate_no_placeholders,
    validate_no_thinking_leak, validate_subject_not_empty,
    validate_renewal_draft,
    COMPETITOR_BRANDS, FABRICATED_CONVERSATION_PHRASES, RESTRICTED_MODELS,
)
from integrations.llm import call_llm, LLMError

SEQUENCES_PATH = Path("./config/sequences.yaml")
SERVICES_KNOWLEDGE_PATH = Path("./config/services-knowledge.yaml")

TOPIC_KEYWORDS = {
    "heavy_media": ["cardstock", "heavy media", "110lb", "thick stock",
                     "heavy stock", "card stock", "glossy", "media kit"],
    "scanning": ["scan-to-email", "scan-to-folder", "scanning",
                  "one-touch scan", "multi-step scan"],
    "toner_supplies": ["toner", "supplies", "supply order", "supply bill"],
    "speed_reliability": ["slow", "jam", "bottleneck", "downtime",
                           "service call", "breaking down"],
    "lease_flexibility": ["month-to-month", "no contract", "cancel anytime",
                           "flexible", "lease", "rental"],
    "setup_speed": ["within days", "quick setup", "fast delivery",
                     "no lengthy process"],
    "satisfaction_guarantee": ["total satisfaction", "replace at no charge",
                                "guarantee"],
    "peer_comparison": ["most practices", "most firms", "most offices",
                         "other practices", "other firms"],
}


COOLING_PERIOD_DAYS = 120


def _get_sequence_state(lead: dict) -> str:
    """Classify a lead's sequence eligibility.

    Returns one of:
      'eligible'      — no sequence, or expired cooling period
      'active'        — touches remaining, campaign in progress
      'paused'        — reply received, operator handling
      'completed'     — all touches sent, in cooling period
      'burned'        — opted out, permanently ineligible
      'preview_only'  — preview exists but never approved
    """
    seq = lead.get("sequence") or {}
    touches = seq.get("touches", [])

    if not touches:
        return "eligible"

    if lead.get("lead_status") == "burned":
        return "burned"

    if lead.get("lead_status") == "paused":
        return "paused"

    sent_touches = [t for t in touches if t.get("status") == "sent"]

    if not sent_touches and not seq.get("approved_at"):
        return "preview_only"

    terminal = all(
        t.get("status") in ("sent", "cancelled", "skipped")
        for t in touches
    )

    if terminal:
        last_sent = max(
            (t.get("sent_at", "") for t in sent_touches),
            default=""
        )
        if last_sent and _cooling_period_active(last_sent, lead):
            return "completed"
        return "eligible"

    return "active"


def _cooling_period_active(last_sent_date: str, lead: dict = None) -> bool:
    """Check if the lead is still in its post-sequence cooling period."""
    from datetime import date
    preset = (lead.get("sequence") or {}).get("definition", "") if lead else ""
    days = _get_cooling_days(preset)
    if days is None:
        return False
    try:
        sent = date.fromisoformat(last_sent_date[:10])
        days_since = (date.today() - sent).days
        return days_since < days
    except (ValueError, TypeError):
        return True


def _get_cooling_days(preset: str) -> int | None:
    """Get cooling days for a preset. None means no cooling."""
    config = _load_sequences_config()
    by_preset = config.get("cooling", {}).get("by_preset", {})
    return by_preset.get(preset, config.get("cooling", {}).get("default_days", COOLING_PERIOD_DAYS))


def _count_remaining_touches(lead: dict) -> int:
    """Count how many touches remain unsent in an active sequence."""
    seq = lead.get("sequence") or {}
    pending = [t for t in seq.get("touches", [])
               if t.get("status") in ("approved", "pending", "paused")]
    return len(pending)


def _cooling_days_remaining(lead: dict) -> int:
    """Days remaining in the cooling period."""
    from datetime import date
    seq = lead.get("sequence") or {}
    sent_touches = [t for t in seq.get("touches", []) if t.get("status") == "sent"]
    last_sent = max((t.get("sent_at", "") for t in sent_touches), default="")
    if not last_sent:
        return 0
    preset = seq.get("definition", "")
    days = _get_cooling_days(preset)
    if days is None:
        return 0
    try:
        sent = date.fromisoformat(last_sent[:10])
        elapsed = (date.today() - sent).days
        return max(0, days - elapsed)
    except (ValueError, TypeError):
        return days


def check_sequence_eligibility(lead_id: str) -> tuple[bool, str]:
    """Check if a lead is eligible for a new sequence.

    Returns (eligible: bool, reason: str).

    Called by:
    - preview_sequence() before generating
    - sequence_batch() for each lead in the batch
    - approve_sequence() as a final safety check
    """
    lead = get_lead(lead_id)
    if not lead:
        return False, "Lead not found"

    if not lead.get("enrichment_status"):
        return False, "Lead not enriched — run enrich first"

    state = _get_sequence_state(lead)

    if state == "eligible":
        return True, "eligible"
    elif state == "active":
        remaining = _count_remaining_touches(lead)
        return False, f"Active sequence: {remaining} touches remaining"
    elif state == "paused":
        return False, "Sequence paused — reply pending operator action"
    elif state == "completed":
        days_left = _cooling_days_remaining(lead)
        return False, f"Cooling period: {days_left} days remaining"
    elif state == "burned":
        return False, "Lead burned — permanently ineligible"
    elif state == "preview_only":
        return True, "eligible (previous preview was never approved)"

    return False, f"Unknown state: {state}"


def extract_topics(text: str) -> list[str]:
    """Extract which topics a touch covers based on keyword matching."""
    topics = []
    lower = text.lower()
    for topic, keywords in TOPIC_KEYWORDS.items():
        if any(kw in lower for kw in keywords):
            topics.append(topic)
    return topics


def _load_sequences_config() -> dict:
    """Load sequence definitions from config."""
    if not SEQUENCES_PATH.exists():
        return {"sequences": {}, "purposes": {}}
    try:
        with open(SEQUENCES_PATH) as f:
            return yaml.safe_load(f)
    except Exception:
        return {"sequences": {}, "purposes": {}}


def _load_services_knowledge() -> dict:
    """Load services knowledge for differentiators."""
    if not SERVICES_KNOWLEDGE_PATH.exists():
        return {}
    try:
        with open(SERVICES_KNOWLEDGE_PATH) as f:
            return yaml.safe_load(f)
    except Exception:
        return {}


def calculate_touch_schedule(touches: int, duration_days: int, min_gap: int = 7, max_gap: int = 45) -> list[int]:
    """Distribute N touches across D days, front-loaded.

    Rules:
    - Touch 1 is always Day 0
    - Touch N is always Day D
    - Intermediate touches follow an exponential curve
    - Minimum gap between any two touches: min_gap days (default 7)
    - Maximum gap between any two touches: max_gap days (default 45)
    - min_gap/max_gap can be overridden by preset config (e.g., urgent_21 uses min_gap=5)

    Returns list of day numbers, e.g. [0, 10, 28, 60]
    """
    if touches <= 1:
        return [0]
    if touches == 2:
        return [0, duration_days]

    days_list = [0] * touches
    days_list[0] = 0
    days_list[-1] = duration_days

    if touches == 3:
        mid = duration_days // 3
        days_list[1] = max(min_gap, min(mid, duration_days - min_gap))
        return days_list

    # Exponential curve: each gap is ~1.5x the previous
    ratio = 1.5
    n_gaps = touches - 1

    geometric_sum = (ratio ** n_gaps - 1) / (ratio - 1)
    base_gap = duration_days / geometric_sum

    gaps = []
    for i in range(n_gaps):
        gap = base_gap * (ratio ** i)
        gap = max(min_gap, min(max_gap, gap))
        gaps.append(gap)

    # Normalize gaps to sum to duration_days
    total_gaps = sum(gaps)
    scale = duration_days / total_gaps
    gaps = [g * scale for g in gaps]

    # Clamp and re-normalize
    for i in range(len(gaps)):
        gaps[i] = max(min_gap, min(max_gap, gaps[i]))
    total_gaps = sum(gaps)
    scale = duration_days / total_gaps
    gaps = [g * scale for g in gaps]

    # Build cumulative days from gaps
    current = 0
    for i in range(touches):
        if i == 0:
            days_list[i] = 0
        else:
            current += gaps[i - 1]
            days_list[i] = int(round(current))

    # Ensure last day is exactly duration_days
    days_list[-1] = duration_days

    # Ensure monotonic increase respecting min_gap
    for i in range(1, touches):
        days_list[i] = max(days_list[i], days_list[i - 1] + min_gap)
    days_list[-1] = duration_days

    return days_list


def _lead_data_score(lead: dict) -> dict:
    """Evaluate what data exists on a lead for purpose selection."""
    equipment = lead.get("equipment") or {}
    return {
        "has_equipment": bool(equipment.get("talk_track")),
        "has_brave": bool(lead.get("brave_summary")),
        "has_county": bool(lead.get("county")),
        "has_naics": bool(lead.get("naics")),
        "has_website": bool(lead.get("website_exists")),
        "has_competitor": bool(lead.get("competitor_displacement")),
        "has_principal": bool(lead.get("primary_principal_name")),
        "score": lead.get("score", 0),
    }


PURPOSE_ZONES = {
    "introduction": "first",
    "value_add": "first_or_middle",
    "social_proof": "middle",
    "problem_acknowledgment": "middle",
    "peer_reference": "middle",
    "specific_offer": "late",
    "final_offer": "late",
    "final": "last",
}


def _max_touches_for_lead(lead: dict) -> int:
    """Maximum useful touches based on available enrichment data.

    Decision tree:
      research_note + (website OR brave):  6
      website OR brave (not both):         4
      NAICS only (no enrichment):          3
      No NAICS:                            2
      Competitor displacement flag:        +1 (cap at 8)
    """
    research_note = lead.get("research_note")
    has_research = bool((research_note or {}).get("findings"))
    has_website = lead.get("website_exists", False)
    has_brave = bool((lead.get("brave_summary") or "").strip())
    has_naics = bool(lead.get("naics"))
    has_competitor = lead.get("competitor_displacement", False)

    if has_research and (has_website or has_brave):
        base = 6
    elif has_website or has_brave:
        base = 4
    elif has_naics:
        base = 3
    else:
        base = 2

    if has_competitor:
        base = min(base + 1, 8)

    return base


def recommend_sequence(lead: dict) -> tuple[str, bool]:
    """Recommend a sequence preset based on lead data richness and profile.

    Decision tree:
    0. Renewal leads → renewal_90
    1. Data richness → determines tier
    2. Temporal category → historical gets longer/slower
    3. Competitor displacement → bumps up one tier
    4. Clamp: if preset touch count exceeds lead's data ceiling, fall
       back to the next lower tier that fits
    5. Return best-fit preset name
    """
    # Renewal leads: classify by contract status to pick preset
    if lead.get("lead_type") == "renewal":
        from capabilities.renewals import classify_renewal
        classification = classify_renewal(lead)
        return classification["recommended_preset"], False

    data = _lead_data_score(lead)
    equipment = lead.get("equipment") or {}
    research_note = lead.get("research_note")
    has_research = bool((research_note or {}).get("findings"))
    has_website = lead.get("website_exists", False)
    has_brave = bool((lead.get("brave_summary") or "").strip())
    has_competitor = lead.get("competitor_displacement", False)
    category = lead.get("temporal_category", "fresh")

    # Determine data tier
    if has_research and (has_website or has_brave):
        data_tier = "rich"
    elif has_website or has_brave:
        data_tier = "medium"
    elif lead.get("naics"):
        data_tier = "thin"
    else:
        data_tier = "minimal"

    # Base recommendation by data tier + temporal
    if data_tier == "rich":
        recommendation = "deep_90" if category == "historical" else "standard_60"
    elif data_tier == "medium":
        recommendation = "standard_60" if category == "historical" else "light_45"
    elif data_tier == "thin":
        recommendation = "light_45"
    else:
        recommendation = "light_45"

    # Competitor displacement bumps up one tier
    if has_competitor:
        tier_order = ["light_45", "standard_60", "deep_90", "extended_120"]
        idx = tier_order.index(recommendation)
        if idx < len(tier_order) - 1:
            recommendation = tier_order[idx + 1]

    # Clamp: ensure preset touch count doesn't exceed lead's data ceiling
    max_touches = _max_touches_for_lead(lead)
    seq_config = _load_sequences_config().get("sequences", {})
    tier_order = ["light_45", "standard_60", "deep_90", "extended_120", "urgent_21"]
    # Walk down from current recommendation until we find one that fits
    while recommendation in seq_config:
        preset_touches = seq_config[recommendation].get("touches", 4)
        if preset_touches <= max_touches:
            break
        idx = tier_order.index(recommendation)
        if idx <= 0:
            break
        recommendation = tier_order[idx - 1]

    throttle = recommendation != "urgent_21"
    return recommendation, throttle


def _validate_density(touches: int, days: int, aggressive: bool = False) -> tuple:
    """No more than 2 touches per 30-day window.

    Formula: touches / (days / 30) <= 2.0

    Returns (passed: bool, message: str).
    """
    density = touches / (days / 30)
    if density <= 2.0:
        return (True, "")

    msg = f"Envelope {touches}/{days} has {density:.1f} touches per 30-day window (max 2.0)"
    if aggressive:
        return (True, f"WARNING: {msg} (--aggressive override)")
    return (False, msg)


def _validate_min_gap(schedule: list, min_gap: int = 7) -> tuple:
    """No two consecutive touches closer than min_gap days."""
    for i in range(1, len(schedule)):
        if schedule[i] - schedule[i - 1] < min_gap:
            return (False, f"Day {schedule[i-1]} to Day {schedule[i]}: "
                           f"{schedule[i] - schedule[i-1]}-day gap "
                           f"(minimum {min_gap} days)")
    return (True, "")


def _validate_max_gap(schedule: list, max_gap: int = 45) -> tuple:
    """No two consecutive touches more than max_gap days apart."""
    for i in range(1, len(schedule)):
        if schedule[i] - schedule[i - 1] > max_gap:
            return (False, f"Day {schedule[i-1]} to Day {schedule[i]}: "
                           f"{schedule[i] - schedule[i-1]}-day gap "
                           f"(maximum {max_gap} days)")
    return (True, "")


def _validate_touch_cap(lead: dict, touches: int) -> tuple:
    """Compare requested touches against per-lead data ceiling."""
    max_t = _max_touches_for_lead(lead)
    if touches <= max_t:
        return (True, "")
    return (False, f"Lead has insufficient data for {touches} touches. "
                   f"Maximum for this lead: {max_t}.")


def _validate_purpose_arc(purposes: list, num_touches: int) -> tuple:
    """Verify purposes are in their correct zone positions.

    Zones:
      First third:      introduction
      First OR middle:  value_add  (early-to-mid, not strictly first)
      Middle third:     social_proof, problem_acknowledgment, peer_reference
      Late third:       specific_offer, final_offer
      Last:             final (always last position)
    """
    import math

    first_third_end = math.ceil(num_touches / 3)
    middle_third_end = math.ceil(2 * num_touches / 3)

    for i, purpose in enumerate(purposes):
        position = i + 1
        zone = PURPOSE_ZONES.get(purpose, "unknown")

        if zone == "first" and position > first_third_end:
            msg = (f"'{purpose}' at position {position}/{num_touches} "
                   f"(belongs in first {first_third_end} touches)")
            return (False, msg)

        if zone == "first_or_middle":
            # Allowed in first OR middle zone (not late, not last)
            if position > middle_third_end:
                msg = (f"'{purpose}' at position {position}/{num_touches} "
                       f"(belongs in touches 1–{middle_third_end})")
                return (False, msg)

        if zone == "middle" and (position <= first_third_end or position > middle_third_end):
            msg = (f"'{purpose}' at position {position}/{num_touches} "
                   f"(belongs in touches {first_third_end+1}–{middle_third_end})")
            return (False, msg)

        if zone == "late" and (position <= middle_third_end or position >= num_touches):
            msg = (f"'{purpose}' at position {position}/{num_touches} "
                   f"(belongs in touches {middle_third_end+1}–{num_touches-1})")
            return (False, msg)

        if zone == "last" and position != num_touches:
            msg = f"'{purpose}' at position {position}/{num_touches} (must be last)"
            return (False, msg)

    return (True, "")


def _validate_final_touch(body: str, position: int, total: int) -> tuple:
    """Final touch must be 45 words or fewer."""
    if position == total and len(body.split()) > 45:
        return (False, f"Final touch is {len(body.split())} words (max 45)")
    return (True, "")


def _validate_envelope(
    lead: dict, touches: int, days: int,
    schedule: list = None, purposes: list = None,
    aggressive: bool = False,
) -> tuple:
    """Run all envelope constraints. Returns (passed, errors[])."""
    errors = []

    passed, msg = _validate_density(touches, days, aggressive)
    if not passed:
        errors.append(msg)

    passed, msg = _validate_touch_cap(lead, touches)
    if not passed:
        errors.append(msg)

    if schedule:
        passed, msg = _validate_max_gap(schedule)
        if not passed:
            errors.append(msg)

    if purposes:
        passed, msg = _validate_purpose_arc(purposes, num_touches=touches)
        if not passed:
            errors.append(msg)

    return (len(errors) == 0, errors)


def select_purposes(lead: dict, num_touches: int, config: Optional[dict] = None) -> dict:
    """Select the best purpose progression for this lead.

    Returns dict with:
    - purposes: ordered list of purpose strings
    - recommended_touches: int (may be fewer than num_touches)
    - reason: str (explanation if fewer touches recommended)
    """
    if config is None:
        config = _load_sequences_config()

    purposes_config = config.get("purposes", {})
    data = _lead_data_score(lead)

    # Count data richness signals
    richness = sum([
        data["has_equipment"],
        data["has_brave"],
        data["has_county"],
        data["has_naics"],
        data["has_website"],
        data["has_competitor"],
    ])

    # Determine viable purposes
    viable_purposes = []
    for purpose_name, purpose_def in purposes_config.items():
        requires = purpose_def.get("requires", [])
        viable = True
        for req in requires:
            if req == "county" and not data["has_county"]:
                viable = False
            elif req == "naics" and not data["has_naics"]:
                viable = False
            elif req == "equipment_talk_track" and not data["has_equipment"]:
                viable = False
        if viable:
            viable_purposes.append(purpose_name)

    # If data is thin (0-1 signals), recommend fewer touches
    if richness <= 1:
        recommended = min(3, num_touches)
        reason = f"Recommended: {recommended} of {num_touches} touches (insufficient data for full sequence)"
    elif richness <= 2:
        recommended = min(4, num_touches)
        reason = f"Recommended: {recommended} of {num_touches} touches (limited data)"
    else:
        recommended = num_touches
        reason = f"Sufficient data for full {num_touches}-touch sequence"

    # Build purpose progression
    purposes = ["introduction"]  # Always first

    # Middle purposes: pick from available pool, excluding first/last
    middle_pool = [p for p in viable_purposes if p not in ("introduction", "final")]

    half_point = recommended // 2
    specific_offer_available = "specific_offer" in middle_pool

    selected_middle = []
    priority_order = []
    if data["has_equipment"] or data["has_brave"]:
        priority_order.append("value_add")
    if data["has_county"] and data["has_naics"]:
        priority_order.append("social_proof")
    priority_order.append("problem_acknowledgment")
    if data["has_equipment"]:
        priority_order.append("peer_reference")
    if specific_offer_available:
        priority_order.append("specific_offer")
    priority_order.append("final_offer")

    # For short sequences, ensure value_add is available as a zone-safe
    # filler. It can go in first or middle zone unlike social_proof or
    # problem_acknowledgment which are middle-only.
    if recommended <= 4 and "value_add" not in priority_order:
        priority_order.insert(0, "value_add")

    for p in priority_order:
        if len(selected_middle) >= recommended - 2:
            break
        if p == "specific_offer":
            if len(selected_middle) >= half_point:
                selected_middle.append(p)
        elif p == "final_offer":
            if recommended >= 7 and len(selected_middle) >= recommended - 3:
                selected_middle.append(p)
        else:
            selected_middle.append(p)

    while len(selected_middle) < recommended - 2:
        if "value_add" not in selected_middle:
            selected_middle.append("value_add")
        elif "problem_acknowledgment" not in selected_middle:
            selected_middle.append("problem_acknowledgment")
        else:
            selected_middle.append("value_add")

    purposes.extend(selected_middle)
    purposes.append("final")

    purposes = purposes[:recommended]
    if purposes[-1] != "final":
        purposes[-1] = "final"

    return {
        "purposes": purposes,
        "recommended_touches": recommended,
        "reason": reason,
    }


def get_available_differentiators(
    lead: dict,
    position: int,
    total_positions: int,
    used_differentiators: list[str],
    config: Optional[dict] = None,
) -> list[dict]:
    """Return differentiators available for this position.

    Filters by:
    - min_position_fraction
    - used_differentiators
    - lead relevance
    """
    if config is None:
        config = _load_services_knowledge()

    differentiators = config.get("close_differentiators", {})
    position_fraction = position / total_positions if total_positions > 0 else 0

    available = []
    for key, diff in differentiators.items():
        if key in used_differentiators:
            continue

        min_frac = diff.get("min_position_fraction", 0.0)
        if position_fraction < min_frac:
            continue

        available.append({"key": key, **diff})

    return available


def _build_sequence_prompt(
    lead: dict,
    position: int,
    total_positions: int,
    purpose: str,
    purpose_config: dict,
    previous_touches: list[dict],
    available_differentiators: list[dict],
    used_differentiators: list[str],
    previous_topics: list[str] | None = None,
    batch_intel: str | None = None,
) -> str:
    """Build the drafting prompt for a specific touch in a sequence.

    Starts with the full base drafting rules from _build_base_drafting_rules()
    (which includes historical framing, close bans, subject-line constraints,
    greeting resolution, competitor/restricted model bans, etc.), then adds
    sequence-specific context on top.
    """
    ctx = _build_context_object(lead)

    # Start with ALL base drafting rules (canonical source)
    lines = _build_base_drafting_rules(ctx)

    # Override length for sequence touches
    lines.append(f"LENGTH: {purpose_config.get('max_words', 100)} words max.")
    lines.append("")

    # Sequence-specific context
    lines.extend([
        f"THIS IS TOUCH {position} OF {total_positions} IN A SEQUENCE.",
        "",
    ])

    # Sequence context for touches 2+
    if position > 1:
        lines.append(
            f"  This is touch {position} of {total_positions} -- the recipient has received "
            f"{position - 1} previous email(s) from you. Do not re-introduce yourself. "
            f"Do not explain why you're reaching out. They know who you are by now."
        )
        lines.append("")

    # Previous touches context with topics
    if previous_touches:
        lines.append("PREVIOUS TOUCHES IN THIS SEQUENCE (do not repeat angles, framing,")
        lines.append("differentiators, topics, or sentence structures from these):")
        lines.append("")
        for prev in previous_touches:
            lines.append(f"Touch {prev['position']} (Day {prev['day']}, {prev['purpose']}):")
            lines.append(f"  Subject: {prev['subject']}")
            body_preview = prev.get("body", "")[:100]
            lines.append(f"  Body: {body_preview}...")
            if prev.get("differentiator_used"):
                lines.append(f"  Differentiator used: {prev['differentiator_used']}")
            prev_topics = prev.get("topics", [])
            if prev_topics:
                lines.append(f"  Topics covered: {', '.join(prev_topics)}")
            lines.append("")

    # Topic dedup instruction
    if previous_topics:
        topic_list = ", ".join(previous_topics)
        lines.extend([
            "TOPICS ALREADY COVERED IN THIS SEQUENCE:",
            f"  {topic_list}",
            "  DO NOT write about these topics again. Choose a DIFFERENT operational",
            "  angle -- cost of supplies, equipment reliability, lease flexibility,",
            "  setup speed, or similar.",
            "",
        ])

    # Your task
    lines.extend([
        "YOUR TASK:",
        f"  Purpose: {purpose}",
        f"  Framing: {purpose_config.get('instruction', '')}",
        f"  Tone: {purpose_config.get('tone', '')}",
        "",
    ])

    # Available differentiators
    if available_differentiators:
        diff_names = [d["key"] for d in available_differentiators]
        lines.append(f"  Available differentiators: {', '.join(diff_names)}")
    if used_differentiators:
        lines.append(f"  Do NOT use: {', '.join(used_differentiators)} (already used)")
    lines.append("")

    # Lead profile (with resolved greeting passed from preview_sequence)
    lines.append("WHO THEY ARE:")
    lines.append(f"  Business: {ctx['name']}")
    greeting = ctx.get("greeting_name")
    if greeting:
        lines.append(f"  Greeting name: {greeting}")
    else:
        lines.append("  Greeting name: (none -- start email without salutation)")
    lines.append(f"  Industry: {ctx['naics']}")
    lines.append(f"  Location: {ctx['city']}, {ctx['state']}")
    lines.append(f"  Filed: {ctx['filing_date']}")
    if ctx.get("business_age_months") is not None:
        lines.append(f"  Business age: {ctx['business_age_months']} months")
    if ctx.get("age_years") is not None:
        lines.append(f"  Years in operation: {ctx['age_years']}")
    if ctx.get("temporal_category"):
        lines.append(f"  Temporal category: {ctx['temporal_category']}")
    if ctx.get("milestone_window_hit") and ctx.get("milestone_year"):
        lines.append(f"  Near milestone: {ctx['milestone_year']}-year anniversary")
    lines.append("")

    if ctx.get("has_substance_context"):
        lines.append("HOW THEY OPERATE - for your understanding:")
        if ctx.get("brave_summary"):
            lines.append(f"  Business description: {ctx['brave_summary']}")
        if ctx.get("equipment_talk_track"):
            lines.append(f"  Workflow context: {ctx['equipment_talk_track']}")
        lines.append("")

    # Operator intel
    from capabilities.drafting import _build_intel_section
    intel_section = _build_intel_section(lead, batch_intel=batch_intel)
    if intel_section:
        for line in intel_section.strip().split("\n"):
            lines.append(line)
        lines.append("")

    lines.extend([
        "SIGNATURE - include EXACTLY:",
        SIGNATURE,
        "",
        "Generate ONLY the email. Start with 'SUBJECT:' on first line,",
        "then blank line, then body. No preamble.",
    ])

    return "\n".join(lines)


def _build_renewal_sequence_prompt(
    lead: dict,
    position: int,
    total_positions: int,
    purpose: str,
    purpose_config: dict,
    previous_touches: list[dict],
    previous_topics: list[str] | None = None,
    batch_intel: str | None = None,
) -> str:
    """Build the drafting prompt for a renewal sequence touch.

    Uses _build_renewal_prompt() as the foundation (warm, service-oriented,
    no cold-outreach framing), then adds sequence-specific context on top.
    """
    ctx = _build_context_object(lead)

    # Start with the renewal prompt foundation
    lines_text = _build_renewal_prompt(ctx, lead, batch_intel=batch_intel)
    lines = lines_text.split("\n")

    # Remove the GENERATE instruction from the base prompt — we'll
    # add sequence-specific instructions instead
    while lines and "Generate ONLY the email" not in lines[-1]:
        lines.pop()
    if lines:
        lines.pop()
        lines.pop()  # blank line before it

    # Sequence length override
    lines.append(f"LENGTH: {purpose_config.get('max_words', 100)} words max.")
    lines.append("")

    # Sequence-specific context
    lines.extend([
        f"THIS IS TOUCH {position} OF {total_positions} IN A SEQUENCE.",
        "",
    ])

    if position > 1:
        lines.append(
            f"  This is touch {position} of {total_positions} -- the recipient has received "
            f"{position - 1} previous email(s) from you. Do not re-introduce yourself. "
            f"Do not explain why you're reaching out. They know who you are by now."
        )
        lines.append("")

    # Previous touches context with topics
    if previous_touches:
        lines.append("PREVIOUS TOUCHES IN THIS SEQUENCE (do not repeat angles, framing,")
        lines.append("or sentence structures from these):")
        lines.append("")
        for prev in previous_touches:
            lines.append(f"Touch {prev['position']} (Day {prev['day']}, {prev['purpose']}):")
            lines.append(f"  Subject: {prev['subject']}")
            body_preview = prev.get("body", "")[:100]
            lines.append(f"  Body: {body_preview}...")
            prev_topics = prev.get("topics", [])
            if prev_topics:
                lines.append(f"  Topics covered: {', '.join(prev_topics)}")
            lines.append("")

    # Topic dedup instruction
    if previous_topics:
        topic_list = ", ".join(previous_topics)
        lines.extend([
            "TOPICS ALREADY COVERED IN THIS SEQUENCE:",
            f"  {topic_list}",
            "  DO NOT write about these topics again. Choose a DIFFERENT angle",
            "  -- equipment reliability, upgrade options, cost per page,",
            "  service response time, or similar.",
            "",
        ])

    # Your task
    lines.extend([
        "YOUR TASK:",
        f"  Purpose: {purpose}",
        f"  Framing: {purpose_config.get('instruction', '')}",
        f"  Tone: {purpose_config.get('tone', '')}",
        "",
    ])

    # Customer profile (renewal-specific fields)
    lines.append("CUSTOMER PROFILE:")
    lines.append(f"  Business: {ctx.get('name', '')}")
    greeting = ctx.get("greeting_name")
    if greeting:
        lines.append(f"  Greeting name: {greeting}")
    else:
        lines.append("  Greeting name: (none -- start email without salutation)")
    equipment_model = lead.get("equipment_model", "")
    if equipment_model:
        lines.append(f"  Equipment model: {equipment_model}")
    lines.append(f"  Location: {ctx.get('city', '')}, {ctx.get('state', 'CT')}")
    mono = lead.get("equipment_mono_meter")
    color = lead.get("equipment_color_meter")
    if mono is not None or color is not None:
        mono_str = f"{mono:,}" if mono else "0"
        color_str = f"{color:,}" if color else "0"
        lines.append(f"  Meter reads: {mono_str} mono / {color_str} color")
    contract_expiration = lead.get("contract_expiration", "")
    if contract_expiration:
        lines.append(f"  Contract expiration: {contract_expiration[:10]}")
    days_until = lead.get("days_until_expiration")
    if days_until is not None:
        lines.append(f"  Days until expiration: {days_until}")
    meter_insight = lead.get("meter_insight", "")
    if meter_insight:
        lines.append(f"  Usage insight: {meter_insight}")
    upgrade = lead.get("recommended_upgrade", "")
    if upgrade:
        lines.append(f"  Recommended upgrade: {upgrade}")
        upgrade_reason = lead.get("upgrade_reason", "")
        lines.append(f"  Upgrade reason: {upgrade_reason}")
    lines.append("")

    lines.extend([
        "SIGNATURE - include EXACTLY:",
        SIGNATURE,
        "",
        "Generate ONLY the email. Start with 'SUBJECT:' on first line,",
        "then blank line, then body. No preamble.",
    ])

    return "\n".join(lines)


def _parse_sequence_response(response: str) -> dict:
    """Parse LLM response into subject/body."""
    raw_lines = response.strip().split("\n")
    subject = ""
    body_start = 0

    for i, line in enumerate(raw_lines):
        if line.upper().startswith("SUBJECT:"):
            subject = line.split(":", 1)[1].strip()
            body_start = i + 1
            break

    while body_start < len(raw_lines) and not raw_lines[body_start].strip():
        body_start += 1

    body = "\n".join(raw_lines[body_start:]).strip()
    if not body:
        body = response.strip()

    subject = subject.replace("\u2014", "--").replace("--", " -- ")
    body = body.replace("\u2014", "--").replace("--", " -- ")

    return {"subject": subject, "body": body}


def draft_sequence_touch(
    lead_id: str,
    position: int,
    total_positions: int,
    purpose: str,
    day: int,
    previous_touches: list[dict],
    used_differentiators: list[str],
    previous_topics: list[str] | None = None,
    resolved_greeting: str | None = None,
    batch_intel: str | None = None,
) -> dict:
    """Draft a single touch within a sequence.

    Returns dict with subject, body, differentiator_used, purpose, day, position, topics.
    Includes post-generation validation for competitor brands and fabricated conversations.

    resolved_greeting: Pre-resolved greeting name (set once by preview_sequence).
        Injected into lead dict so _build_context_object() uses it instead of
        re-resolving from disk (which would lose the greeting).
    batch_intel: Optional per-batch intel (not stored on lead).
    """
    lead = get_lead(lead_id)
    if not lead:
        raise KeyError(f"Lead {lead_id} not found")

    # Inject pre-resolved greeting so _determine_greeting() returns it
    # instead of falling through to business name extraction.
    if resolved_greeting:
        lead["_resolved_greeting"] = resolved_greeting

    config = _load_sequences_config()
    purpose_config = config.get("purposes", {}).get(purpose, {})
    services = _load_services_knowledge()

    is_renewal = lead.get("lead_type") == "renewal"
    equipment_model = lead.get("equipment_model", "")
    days_until = lead.get("days_until_expiration", 0) or 0
    is_expired = is_renewal and days_until <= 0

    available_diffs = get_available_differentiators(
        lead, position, total_positions, used_differentiators, services
    )

    if is_renewal:
        prompt = _build_renewal_sequence_prompt(
            lead, position, total_positions, purpose, purpose_config,
            previous_touches, previous_topics,
            batch_intel=batch_intel,
        )
    else:
        prompt = _build_sequence_prompt(
            lead, position, total_positions, purpose, purpose_config,
            previous_touches, available_diffs, used_differentiators,
            previous_topics,
            batch_intel=batch_intel,
        )

    # Validation loop with auto-regen (max 2 retries)
    max_attempts = 3
    attempt = 0
    validation_failure = None

    while attempt < max_attempts:
        try:
            response, metadata = call_llm(prompt)
        except LLMError as e:
            raise ValueError(
                f"Sequence touch drafting failed: LLM unavailable ({e.error_type}). "
                f"All {len(e.attempts)} attempts failed. Retry later."
            ) from e
        parsed = _parse_sequence_response(response)
        full_text = parsed["subject"] + " " + parsed["body"]

        # Universal checks: empty subject, thinking-process leaks
        passed_subj, subj_reason = validate_subject_not_empty(parsed["subject"], parsed["body"])
        if not passed_subj:
            validation_failure = f"empty_subject:{subj_reason}"
            prompt = prompt + (
                "\n\nVALIDATION ERROR: Subject line is empty. "
                "You MUST include a clear subject line starting with 'SUBJECT:'."
            )
            attempt += 1
            continue

        passed_leak, leak_pattern = validate_no_thinking_leak(full_text)
        if not passed_leak:
            validation_failure = f"thinking_leak:{leak_pattern}"
            prompt = prompt + (
                f"\n\nVALIDATION ERROR: Your previous draft included '{leak_pattern}', "
                "which looks like internal reasoning. Output ONLY the final email — "
                "no thinking process, no analysis, no explanation."
            )
            attempt += 1
            continue

        # Renewal-specific validation
        if is_renewal:
            passed_renewal, reason = validate_renewal_draft(full_text, equipment_model, is_expired=is_expired)
            if not passed_renewal:
                validation_failure = f"renewal_validation:{reason}"
                prompt = prompt + (
                    f"\n\nVALIDATION ERROR: {reason}. "
                    "Regenerate following the renewal email guidelines."
                )
                attempt += 1
                continue
        else:
            # Check for competitor brands
            passed_brand, matched_brand = validate_no_competitor_brands(full_text)
            if not passed_brand:
                validation_failure = f"competitor_brand:{matched_brand}"
                prompt = prompt + (
                    f"\n\nVALIDATION ERROR: Your previous draft mentioned '{matched_brand}'. "
                    "You sell Xerox equipment only. Regenerate without mentioning any "
                    "competitor brands."
                )
                attempt += 1
                continue

            # Check for restricted models (WorkCentre, etc.)
            passed_model, matched_model = validate_no_restricted_models(full_text)
            if not passed_model:
                validation_failure = f"restricted_model:{matched_model}"
                prompt = prompt + (
                    f"\n\nVALIDATION ERROR: Your previous draft mentioned '{matched_model}'. "
                    "This model is end of life and must never appear in outbound outreach. "
                    "Regenerate without mentioning it."
                )
                attempt += 1
                continue

            # Check for fabricated conversations
            passed_conv, matched_phrase = validate_no_fabricated_conversations(full_text)
            if not passed_conv:
                validation_failure = f"fabricated_conversation:{matched_phrase}"
                prompt = prompt + (
                    f"\n\nVALIDATION ERROR: Your previous draft included '{matched_phrase}'. "
                    "You have not spoken to this person before. Regenerate without implying "
                    "any prior conversation."
                )
                attempt += 1
                continue

            # Check for bracket placeholders — reject with retry
            passed_ph, matched_ph = validate_no_placeholders(full_text)
            if not passed_ph:
                validation_failure = f"bracket_placeholder:{matched_ph}"
                prompt = prompt + (
                    f"\n\nVALIDATION ERROR: Your previous draft contained '{matched_ph}'. "
                    "Bracket-delimited placeholders like [Last Name] or [Company] are "
                    "NOT ALLOWED. You MUST use the actual greeting_name provided in the "
                    "lead profile, or if no greeting_name is provided, start the email "
                    "without any salutation. Regenerate without using any bracket placeholders."
                )
                attempt += 1
                continue

        # Passed all validation
        validation_failure = None
        break

    if validation_failure:
        # Flag for operator review after max attempts
        parsed["_validation_flag"] = validation_failure

    # Determine which differentiator was used (best guess from body)
    used_diff = None
    if available_diffs:
        body_lower = parsed["body"].lower()
        for diff in available_diffs:
            fact_lower = diff.get("fact", "").lower()
            alt_lower = diff.get("alt_phrasing", "").lower()
            if fact_lower[:30] in body_lower or alt_lower[:30] in body_lower:
                used_diff = diff["key"]
                break

    # Extract topics from body
    topics = extract_topics(parsed["body"])

    return {
        "position": position,
        "purpose": purpose,
        "day": day,
        "subject": parsed["subject"],
        "body": parsed["body"],
        "differentiator_used": used_diff,
        "topics": topics,
        "_validation_flag": parsed.get("_validation_flag"),
        "model_used": metadata.get("model_used", ""),
        "model_position": metadata.get("model_position", ""),
    }


def _find_topic_repeats(touches: list[dict]) -> list[dict]:
    """Find topics that appear in multiple consecutive touches.

    Returns list of dicts with topic, touch_positions, and severity.
    """
    repeats = []
    for i in range(1, len(touches)):
        prev_topics = set(touches[i - 1].get("topics", []))
        curr_topics = set(touches[i].get("topics", []))
        overlap = prev_topics & curr_topics
        if overlap:
            repeats.append({
                "topic": ", ".join(sorted(overlap)),
                "touches": [touches[i - 1]["position"], touches[i]["position"]],
                "severity": "consecutive" if i > 0 else "non_consecutive",
            })
    return repeats


def preview_sequence(
    lead_id: str,
    num_touches: int,
    duration_days: int,
    sequence_name: Optional[str] = None,
    batch_intel: str | None = None,
    skip_eligibility: bool = False,
) -> dict:
    """Generate a full sequence preview for a lead.

    Args:
        lead_id: Lead to generate a sequence for
        num_touches: Number of touches
        duration_days: Duration in days
        sequence_name: Named preset from config
        batch_intel: Optional per-batch operator intel
        skip_eligibility: Skip dedup guard (used internally by sequence_batch
                          which already checked eligibility)

    Returns dict with:
    - lead_name, lead_score, lead_category
    - agent_info, principal_info
    - touches: list of touch dicts
    - schedule: list of day numbers
    - purposes: list of purpose strings
    - recommended_touches, reason
    - differentiator_check: used/unused lists, no_repeats bool
    """
    lead = get_lead(lead_id)
    if not lead:
        raise KeyError(f"Lead {lead_id} not found")

    # Dedup guard: check sequence eligibility before generating
    if not skip_eligibility:
        eligible, reason = check_sequence_eligibility(lead_id)
        if not eligible:
            raise ValueError(
                f"Lead {lead.get('name', lead_id)} is not eligible for sequencing: {reason}"
            )

    # Gate: filing-service leads should not receive email sequences.
    # Same gate as the push gate in integrations/zoho.py.
    if lead.get("email_is_filing_service", False):
        raise ValueError(
            f"Lead {lead_id} ({lead.get('name', '')}) has a filing-service email "
            f"({lead.get('email', '')}). Sequences are not sent to filing-service leads."
        )

    config = _load_sequences_config()
    services = _load_services_knowledge()

    is_renewal = lead.get("lead_type") == "renewal"

    if is_renewal:
        # Renewal schedule: evenly distributed, deadline-anchored
        from capabilities.renewals import calculate_renewal_schedule
        days_until = lead.get("days_until_expiration")
        if days_until is None:
            days_until = duration_days
        if days_until <= 0:
            schedule = calculate_renewal_schedule(num_touches, days_until, buffer_days=15, lapsed_duration=duration_days)
        else:
            schedule = calculate_renewal_schedule(num_touches, days_until, buffer_days=15)

        # Renewal purposes come from the preset
        if sequence_name:
            seq_def = config.get("sequences", {}).get(sequence_name, {})
            purposes = seq_def.get("purpose_progression", [])[:num_touches]
        else:
            purposes = select_purposes(lead, num_touches, config)["purposes"]
        recommended = len(purposes)
        reason = "Renewal sequence — warm, service-oriented progression"
    else:
        # Cold outreach: exponential front-loaded schedule
        min_gap = 7
        if sequence_name:
            seq_def_cfg = config.get("sequences", {}).get(sequence_name, {})
            min_gap = seq_def_cfg.get("min_gap_override", 7)

        schedule = calculate_touch_schedule(num_touches, duration_days, min_gap=min_gap)

        purpose_result = select_purposes(lead, num_touches, config)
        purposes = purpose_result["purposes"]
        recommended = purpose_result["recommended_touches"]
        reason = purpose_result["reason"]

        # Enforce envelope constraints
        aggressive = False
        if sequence_name:
            seq_def = config.get("sequences", {}).get(sequence_name, {})
            aggressive = seq_def.get("aggressive", False)
        passed_validation, validation_errors = _validate_envelope(
            lead, num_touches, duration_days,
            schedule=schedule, purposes=purposes, aggressive=aggressive,
        )
        if not passed_validation:
            error_text = "\n  - ".join(validation_errors)
            raise ValueError(
                f"Envelope {num_touches}/{duration_days} violates constraints:\n  - {error_text}"
            )

    # Trim schedule to recommended touches
    schedule = schedule[:recommended]

    # Resolve greeting ONCE for the entire sequence (not per touch).
    # Set _resolved_greeting so _determine_greeting() returns this
    # value for all touches, ensuring consistency.
    greeting_name = _determine_greeting(lead)
    if greeting_name:
        lead["_resolved_greeting"] = greeting_name

    # Draft each touch
    touches = []
    used_differentiators = []
    all_previous_topics = []

    for i in range(recommended):
        position = i + 1
        purpose = purposes[i]
        day = schedule[i]

        previous = [
            {
                "position": t["position"],
                "day": t["day"],
                "purpose": t["purpose"],
                "subject": t["subject"],
                "body": t["body"],
                "differentiator_used": t.get("differentiator_used"),
                "topics": t.get("topics", []),
            }
            for t in touches
        ]

        touch = draft_sequence_touch(
            lead_id, position, recommended, purpose, day,
            previous, used_differentiators,
            all_previous_topics if all_previous_topics else None,
            resolved_greeting=greeting_name,
            batch_intel=batch_intel,
        )

        if touch.get("differentiator_used"):
            used_differentiators.append(touch["differentiator_used"])

        # Accumulate topics for dedup
        touch_topics = touch.get("topics", [])
        if touch_topics:
            all_previous_topics.extend(t for t in touch_topics if t not in all_previous_topics)

        touches.append(touch)

    # Differentiator check (not applicable for renewal sequences)
    if is_renewal:
        differentiator_check = {
            "used": [],
            "unused": [],
            "no_repeats": True,
        }
    else:
        all_diffs = list(services.get("close_differentiators", {}).keys())
        unused_diffs = [d for d in all_diffs if d not in used_differentiators]
        no_repeats = len(used_differentiators) == len(set(used_differentiators))
        differentiator_check = {
            "used": used_differentiators,
            "unused": unused_diffs,
            "no_repeats": no_repeats,
        }

    # Lead info
    agent_info = ""
    if lead.get("agent_name"):
        agent_info = f"{lead['agent_name']} ({lead.get('agent_type', 'unknown')})"

    principal_info = ""
    if lead.get("primary_principal_name"):
        designation = lead.get("principal_designation", "")
        principal_info = f"{lead['primary_principal_name']} ({designation})" if designation else lead["primary_principal_name"]

    return {
        "lead_name": lead.get("name", ""),
        "lead_score": lead.get("score", 0),
        "lead_category": lead.get("temporal_category", ""),
        "milestone_year": lead.get("milestone_year"),
        "agent_info": agent_info,
        "principal_info": principal_info,
        "greeting_used": greeting_name,
        "touches": touches,
        "schedule": schedule,
        "purposes": purposes,
        "recommended_touches": recommended,
        "reason": reason,
        "differentiator_check": differentiator_check,
        "topic_check": {
            "all_topics": all_previous_topics,
            "topic_repeats": _find_topic_repeats(touches),
        },
        "envelope": {"touches": num_touches, "duration_days": duration_days},
        "sequence_name": sequence_name or f"ad_hoc_{num_touches}x{duration_days}",
    }


def approve_sequence(lead_id: str, preview: dict) -> dict:
    """Approve a sequence preview and store it on the lead.

    Sets scheduled dates based on today + day offsets.
    """
    # Dedup guard: final safety check before approval
    eligible, reason = check_sequence_eligibility(lead_id)
    if not eligible:
        raise ValueError(f"Cannot approve sequence: {reason}")

    # Gate: filing-service leads should not receive email sequences.
    lead = get_lead(lead_id)
    if not lead:
        raise KeyError(f"Lead {lead_id} not found")
    if lead.get("email_is_filing_service", False):
        raise ValueError(
            f"Lead {lead_id} ({lead.get('name', '')}) has a filing-service email "
            f"({lead.get('email', '')}). Sequences are not sent to filing-service leads."
        )

    now = datetime.utcnow().isoformat() + "Z"
    today = datetime.utcnow().date()

    touches = []
    for touch in preview["touches"]:
        scheduled_date = (today + timedelta(days=touch["day"])).isoformat()
        touches.append({
            "position": touch["position"],
            "purpose": touch["purpose"],
            "scheduled_date": scheduled_date,
            "day": touch["day"],
            "status": "approved",
            "subject": touch["subject"],
            "body": touch["body"],
            "differentiator_used": touch.get("differentiator_used"),
            "drafted_at": now,
        })

    sequence = {
        "definition": preview.get("sequence_name", "ad_hoc"),
        "touches": touches,
        "envelope": preview["envelope"],
        "approved_at": now,
        "paused": False,
        "pause_reason": None,
        "throttle": True,
    }

    update_lead(lead_id, {"sequence": sequence, "last_updated": now})

    # Push sequence trace to Zoho if lead has zoho_id
    if lead.get("zoho_id"):
        try:
            from integrations.zoho import _push_sequence_trace_to_zoho
            _push_sequence_trace_to_zoho(lead["zoho_id"], sequence)
        except Exception:
            pass  # Non-critical — don't block approval
    else:
        # Flag for deferred push when lead gets a zoho_id
        update_lead(lead_id, {"sequence_trace_pending": True, "last_updated": now})

    return sequence


def pause_sequence_on_reply(lead_id: str, classification: Optional[str] = None) -> bool:
    """When a reply is received, pause remaining sequence touches.

    Args:
        lead_id: Lead to pause
        classification: If "autoresponder", do NOT pause (sequence
            continues undisturbed). The caller (process_reply) should
            only call this with classification="autoresponder" if the
            autoresponder ALSO requires a pause (rare).

    Returns True if a sequence was paused, False if no active sequence
    or autoresponder classification.
    """
    if classification == "autoresponder":
        return False

    lead = get_lead(lead_id)
    if not lead:
        raise KeyError(f"Lead {lead_id} not found")

    sequence = lead.get("sequence")
    if not sequence or sequence.get("paused"):
        return False

    paused_count = 0
    for touch in sequence.get("touches", []):
        if touch.get("status") == "approved":
            touch["status"] = "paused"
            paused_count += 1

    if paused_count > 0:
        sequence["paused"] = True
        sequence["pause_reason"] = "reply_received"
        now = datetime.utcnow().isoformat() + "Z"
        update_lead(lead_id, {"sequence": sequence, "last_updated": now})
        return True

    return False


def resume_sequence(lead_id: str) -> dict:
    """Resume a paused sequence."""
    lead = get_lead(lead_id)
    if not lead:
        raise KeyError(f"Lead {lead_id} not found")

    sequence = lead.get("sequence")
    if not sequence or not sequence.get("paused"):
        raise ValueError(f"No paused sequence for lead {lead_id}")

    for touch in sequence.get("touches", []):
        if touch.get("status") == "paused":
            touch["status"] = "approved"

    sequence["paused"] = False
    sequence["pause_reason"] = None
    now = datetime.utcnow().isoformat() + "Z"
    update_lead(lead_id, {"sequence": sequence, "last_updated": now})

    return sequence


def cancel_sequence(lead_id: str) -> dict:
    """Cancel remaining touches in a sequence."""
    lead = get_lead(lead_id)
    if not lead:
        raise KeyError(f"Lead {lead_id} not found")

    sequence = lead.get("sequence")
    if not sequence:
        raise ValueError(f"No sequence for lead {lead_id}")

    skipped_count = 0
    for touch in sequence.get("touches", []):
        if touch.get("status") in ("approved", "paused"):
            touch["status"] = "skipped"
            skipped_count += 1

    sequence["paused"] = True
    sequence["pause_reason"] = "cancelled_by_operator"
    now = datetime.utcnow().isoformat() + "Z"
    update_lead(lead_id, {"sequence": sequence, "last_updated": now})

    return sequence


def skip_touch(lead_id: str, position: int) -> dict:
    """Skip a specific touch in a sequence."""
    lead = get_lead(lead_id)
    if not lead:
        raise KeyError(f"Lead {lead_id} not found")

    sequence = lead.get("sequence")
    if not sequence:
        raise ValueError(f"No sequence for lead {lead_id}")

    for touch in sequence.get("touches", []):
        if touch.get("position") == position and touch.get("status") == "approved":
            touch["status"] = "skipped"
            now = datetime.utcnow().isoformat() + "Z"
            update_lead(lead_id, {"sequence": sequence, "last_updated": now})
            return touch

    raise ValueError(f"No approved touch at position {position} for lead {lead_id}")


def get_due_touches(target_date: Optional[str] = None) -> list[dict]:
    """Get all touches due on a given date (default: today).

    Returns list of dicts with lead info and touch details.
    """
    from state.backlog import load_backlog

    if target_date is None:
        target_date = datetime.utcnow().date().isoformat()

    backlog = load_backlog()
    due = []

    for lead in backlog:
        sequence = lead.get("sequence")
        if not sequence or sequence.get("paused"):
            continue

        for touch in sequence.get("touches", []):
            if touch.get("scheduled_date") == target_date and touch.get("status") == "approved":
                due.append({
                    "lead_id": lead["id"],
                    "lead_name": lead.get("name", ""),
                    "position": touch["position"],
                    "total": len(sequence.get("touches", [])),
                    "purpose": touch.get("purpose", ""),
                    "subject": touch.get("subject", ""),
                    "status": touch.get("status", ""),
                    "lead_score": lead.get("score", 0),
                    "throttle": sequence.get("throttle", True),
                })

    return due


def get_throttled_due_touches(due_touches: list, config: dict = None) -> tuple[list, list]:
    """Split due touches into today's send list and holdback list.

    Unthrottled touches (renewal, urgent) send immediately.
    Throttled touches (cold) obey the weekly spread in config.

    Args:
        due_touches: all touches where scheduled_date <= today
        config: send_throttle config from sequences.yaml (loaded if None)

    Returns:
        (send_today, hold_for_later)
    """
    if config is None:
        config = _load_sequences_config().get("send_throttle", {})

    if not config.get("enabled", True):
        return due_touches, []

    day_name = datetime.now().strftime("%A").lower()
    day_pct = config.get("weekly_spread", {}).get(day_name, 0)

    # Separate unthrottled (send immediately) from throttled (obey spread)
    unthrottled = [t for t in due_touches if not t.get("throttle", True)]
    throttled = [t for t in due_touches if t.get("throttle", True)]

    if day_pct == 0:
        return unthrottled, throttled

    max_per_day = config.get("max_per_day", 15)
    min_per_day = config.get("min_per_day", 3)

    batch_size = len(throttled)
    today_count = int(batch_size * (day_pct / 100))
    today_count = max(today_count, min_per_day)
    today_count = min(today_count, max_per_day)
    today_count = min(today_count, batch_size)

    sorted_touches = sorted(
        throttled,
        key=lambda t: t.get("lead_score", 0) or 0,
        reverse=True,
    )

    send_today = sorted_touches[:today_count]
    hold = sorted_touches[today_count:]

    return unthrottled + send_today, hold


def _find_substitutes(blocked_specs: list[dict],
                      exclude_ids: list[str],
                      backlog: list[dict] = None) -> list[dict]:
    """Find substitute leads matching the blocked leads' verticals.

    For each needed substitution:
    1. Query backlog for leads with matching NAICS prefix (2-digit)
    2. Filter to eligible leads (not in exclude_ids, not already
       sequenced, not burned/suppressed)
    3. Sort by score descending
    4. Take the top one

    If no match found in the same NAICS prefix, try the same
    NAICS tier (e.g., any tier-1 vertical). If still no match,
    skip the substitution.

    Returns list of substitute lead dicts.
    """
    if backlog is None:
        backlog = load_backlog()

    substitutes = []
    used_ids = set(exclude_ids)

    for spec in blocked_specs:
        target_naics = spec.get("naics", "")
        target_county = spec.get("county", "")
        target_prefix = target_naics[:4] if target_naics else ""
        target_tier_prefix = target_naics[:2] if target_naics else ""

        # Pass 1: Same NAICS prefix (4-digit), same county
        candidate = _best_eligible_lead(
            backlog, used_ids,
            naics_prefix=target_prefix,
            county=target_county
        )

        # Pass 2: Same NAICS prefix, any county
        if not candidate:
            candidate = _best_eligible_lead(
                backlog, used_ids,
                naics_prefix=target_prefix
            )

        # Pass 3: Same tier (2-digit prefix), same county
        if not candidate:
            candidate = _best_eligible_lead(
                backlog, used_ids,
                naics_prefix=target_tier_prefix,
                county=target_county
            )

        # Pass 4: Same tier, any county
        if not candidate:
            candidate = _best_eligible_lead(
                backlog, used_ids,
                naics_prefix=target_tier_prefix
            )

        if candidate:
            substitutes.append(candidate)
            used_ids.add(candidate["id"])

    return substitutes


def _best_eligible_lead(backlog: list, exclude_ids: set,
                        naics_prefix: str = "",
                        county: str = "") -> dict | None:
    """Find the highest-scoring eligible lead matching criteria."""
    candidates = []

    for lead in backlog:
        if lead["id"] in exclude_ids:
            continue
        if lead.get("lead_status") in ("burned", "paused"):
            continue
        if lead.get("email_is_filing_service"):
            continue
        if not lead.get("email", "").strip():
            continue
        if lead.get("is_shell"):
            continue

        if naics_prefix:
            lead_naics = lead.get("naics", "")
            if not lead_naics or not lead_naics.startswith(naics_prefix):
                continue

        if county:
            lead_county = (lead.get("county") or "").lower()
            if lead_county != county.lower():
                continue

        state = _get_sequence_state(lead)
        if state != "eligible":
            continue

        candidates.append(lead)

    if not candidates:
        return None

    candidates.sort(key=lambda l: l.get("score", 0) or 0, reverse=True)
    return candidates[0]


def sequence_batch(
    lead_ids: list[str],
    dry_run: bool = False,
    max_workers: int = 6,
    approve: bool = False,
    batch_intel: str | None = None,
) -> dict:
    """Auto-generate sequences for multiple leads in parallel.

    Each lead gets its own auto-selected envelope via recommend_sequence().
    When approve=True, stores with approved status and approved_at timestamp.
    When approve=False, stores as preview (operator approves later).

    Features:
    - Dedup guard: skips leads with active/paused/completed sequences
    - Auto-substitution: blocked leads replaced by same-vertical leads

    Uses ThreadPoolExecutor(max_workers=6) to stay within Featherless
    8-concurrency limit (6 parallel leads × 1 active LLM call each = 6
    concurrent, leaving 2 slots for fallback chain headroom).

    Skips: filing-service leads, burned/paused, no email, gate failures.

    Returns: {sequenced, blocked, substituted, no_substitute, skipped,
              errors, details: [{lead_id, name, status, preset, ...}]}
    """
    from state.backlog import load_backlog

    result = {
        "sequenced": 0,
        "blocked": 0,
        "substituted": 0,
        "no_substitute": 0,
        "skipped": 0,
        "errors": 0,
        "details": [],
    }
    result_lock = threading.Lock()

    # Pre-filter: gate checks (fast, synchronous)
    gated = []
    for lid in lead_ids:
        lead = get_lead(lid)
        if not lead:
            result["details"].append({"lead_id": lid, "status": "error", "reason": "Not found"})
            result["errors"] += 1
            continue
        if lead.get("email_is_filing_service"):
            result["details"].append({"lead_id": lid, "name": lead.get("name", ""), "status": "skipped", "reason": "filing-service email"})
            result["skipped"] += 1
            continue
        if lead.get("is_shell"):
            result["details"].append({"lead_id": lid, "name": lead.get("name", ""), "status": "skipped", "reason": "shell company"})
            result["skipped"] += 1
            continue
        if not lead.get("email", "").strip():
            result["details"].append({"lead_id": lid, "name": lead.get("name", ""), "status": "skipped", "reason": "no email"})
            result["skipped"] += 1
            continue
        gated.append(lead)

    if not gated:
        return result

    # Phase 1: Evaluate eligibility for each proposed lead
    eligible_leads = []
    blocked_specs = []

    for lead in gated:
        lid = lead["id"]
        ok, reason = check_sequence_eligibility(lid)
        if ok:
            eligible_leads.append(lead)
        else:
            result["details"].append({
                "lead_id": lid,
                "name": lead.get("name", ""),
                "status": "blocked",
                "reason": reason,
                "naics": lead.get("naics", ""),
                "county": lead.get("county", ""),
            })
            result["blocked"] += 1
            blocked_specs.append({
                "lead_id": lid,
                "naics": lead.get("naics", ""),
                "county": lead.get("county", ""),
            })

    # Phase 2: Find substitutes for blocked leads
    proposed_ids = [l["id"] for l in gated]
    backlog = load_backlog()
    substitutes = _find_substitutes(blocked_specs, proposed_ids, backlog)

    sub_lead_ids = set(s["id"] for s in substitutes)
    for sub in substitutes:
        eligible_leads.append(sub)
        result["substituted"] += 1
        # Find the blocked lead this replaces and update its detail entry
        for d in result["details"]:
            if d["status"] == "blocked" and d["lead_id"] not in [
                e.get("lead_id") for e in result["details"]
                if e.get("status") == "substituted"
            ]:
                # Mark the first un-matched blocked lead
                pass

    # Map substitutes to the blocked entries they replace
    sub_idx = 0
    for d in result["details"]:
        if d["status"] == "blocked" and sub_idx < len(substitutes):
            sub = substitutes[sub_idx]
            d["status"] = "substituted"
            d["substitute_id"] = sub["id"]
            d["substitute_name"] = sub.get("name", "")
            d["substitute_naics"] = sub.get("naics", "")
            sub_idx += 1
        elif d["status"] == "blocked":
            d["status"] = "no_substitute"
            result["no_substitute"] += 1

    if not eligible_leads:
        return result

    # For dry-run, skip actual generation but show what would happen
    if dry_run:
        for lead in eligible_leads:
            rec, _ = recommend_sequence(lead)
            cfg = _load_sequences_config()
            seq_def = cfg.get("sequences", {}).get(rec, {})
            is_sub = lead["id"] in sub_lead_ids
            status = "would_sequence" if not is_sub else "would_substitute"
            result["details"].append({
                "lead_id": lead["id"],
                "name": lead.get("name", ""),
                "status": status,
                "preset": rec,
                "touches": seq_def.get("touches", 4),
                "days": seq_def.get("duration_days", 60),
                "substitute": is_sub,
            })
            result["sequenced"] += 1
        return result

    # Phase 3: Generate sequences for eligible leads (including substitutes)
    total = len(eligible_leads)
    completed = 0

    def _sequence_one(item: dict) -> dict:
        """Generate sequence for one lead. Runs in a thread."""
        lid = item["id"]
        lead = get_lead(lid)
        rec, throttle = recommend_sequence(lead)
        cfg = _load_sequences_config()
        seq_def = cfg.get("sequences", {}).get(rec, {})
        touches = seq_def.get("touches", 4)
        days = seq_def.get("duration_days", 60)

        is_sub = lid in sub_lead_ids or lid in [s["id"] for s in substitutes]
        preview = preview_sequence(
            lid, touches, days, sequence_name=rec,
            batch_intel=batch_intel,
            skip_eligibility=True,
        )

        now = datetime.utcnow().isoformat() + "Z"
        today = datetime.utcnow().date()
        stored_touches = []
        for touch in preview["touches"]:
            scheduled_date = (today + timedelta(days=touch["day"])).isoformat()
            stored_touches.append({
                "position": touch["position"],
                "purpose": touch["purpose"],
                "scheduled_date": scheduled_date,
                "day": touch["day"],
                "status": "pending",
                "subject": touch["subject"],
                "body": touch["body"],
                "differentiator_used": touch.get("differentiator_used"),
                "drafted_at": now,
            })

        sequence = {
            "definition": rec,
            "touches": stored_touches,
            "envelope": preview["envelope"],
            "drafted_at": now,
            "paused": False,
            "pause_reason": None,
            "throttle": throttle,
        }
        if approve:
            for t in stored_touches:
                t["status"] = "approved"
            sequence["approved_at"] = now
        update_lead(lid, {"sequence": sequence, "last_updated": now})

        # Push sequence trace to Zoho if approved and lead has zoho_id
        if approve:
            lead_for_trace = get_lead(lid)
            if lead_for_trace and lead_for_trace.get("zoho_id"):
                try:
                    from integrations.zoho import _push_sequence_trace_to_zoho
                    _push_sequence_trace_to_zoho(lead_for_trace["zoho_id"], sequence)
                except Exception:
                    pass
            elif lead_for_trace:
                update_lead(lid, {"sequence_trace_pending": True, "last_updated": now})

        status = "substituted" if is_sub else "sequenced"

        return {
            "lead_id": lid,
            "name": item["name"],
            "status": status,
            "preset": rec,
            "touches": len(preview["touches"]),
            "days": days,
        }

    with ThreadPoolExecutor(max_workers=max_workers) as executor:
        futures = {executor.submit(_sequence_one, lead): lead for lead in eligible_leads}

        for future in as_completed(futures):
            lead = futures[future]
            lid = lead["id"]
            completed += 1

            try:
                detail = future.result()
                with result_lock:
                    result["details"].append(detail)
                    result["sequenced"] += 1
                icon = "↺" if detail.get("status") == "substituted" else "✓"
                print(f"  [{completed}/{total}] {icon} {lead.get('name', '')} — {detail['preset']} ({detail['touches']} touches)", file=sys.stderr)
            except Exception as e:
                error_msg = str(e)[:120]
                with result_lock:
                    result["details"].append({"lead_id": lid, "name": lead.get("name", ""), "status": "error", "reason": error_msg})
                    result["errors"] += 1
                print(f"  [{completed}/{total}] ✗ {lead.get('name', '')} — ERROR: {error_msg}", file=sys.stderr)

    return result
