#!/usr/bin/env python3
"""CLI — thin wrapper for capabilities. Single file with all subcommands.

Uses Typer for clean CLI interface. All subcommands defined here.
"""

from dotenv import load_dotenv
load_dotenv()  # Load .env before any imports

import typer
import json
from pathlib import Path
from datetime import date, datetime
from typing import Optional

app = typer.Typer(help="Always Be Selling — Agent-driven content engine")


@app.command("brief")
def brief(
    days: int = typer.Option(1, "--days", "-d", help="Days back to harvest"),
    limit: int = typer.Option(10, "--limit", "-l", help="Max leads to display"),
    no_draft: bool = typer.Option(False, "--no-draft", help="Skip LLM drafting"),
    output: Optional[str] = typer.Option(None, "--output", "-o", help="Save brief to file"),
    category: Optional[str] = typer.Option(None, "--category", "-c", help="Filter by temporal category: fresh, recent, historical"),
    drafted_only: bool = typer.Option(False, "--drafted-only", help="Show only leads with draft_status == 'drafted'"),
    undrafted_only: bool = typer.Option(False, "--undrafted-only", help="Show only leads with draft_status == 'none'"),
    strong_only: bool = typer.Option(False, "--strong-only", help="Show only ★★+ leads"),
):
    """End-to-end pipeline: harvest → score → enrich → draft → brief.

    Default: top leads by priority score across all categories.
    Use --category to filter by temporal category.
    Use --drafted-only / --undrafted-only to filter by draft status.
    Use --strong-only to show only ★★+ leads.
    Flags combine: --category historical --drafted-only shows top drafted historical leads.
    """
    from capabilities.orchestration import run_pipeline

    brief_text = run_pipeline(
        days=days,
        limit=limit,
        draft=not no_draft,
        output_format="markdown",
        category=category,
        drafted_only=drafted_only,
        undrafted_only=undrafted_only,
        strong_only=strong_only,
    )

    print(brief_text)

    # Global intel warning
    from capabilities.drafting import _load_global_intel
    gi = _load_global_intel()
    if gi.get("active"):
        set_at = (gi.get("set_at") or "")[:10] if gi.get("set_at") else "unknown"
        print(f"\n{'─' * 60}")
        print(f"⚠  GLOBAL INTEL ACTIVE (set {set_at})")
        print(f'   "{gi.get("message", "")}"')
        print("   Every draft includes this intel.")
        print("   Run 'absel intel global --off' to deactivate.")
        print(f"{'─' * 60}")

    if output:
        with open(output, 'w') as f:
            f.write(brief_text)
        print(f"\nSaved to: {output}")


@app.command()
def health():
    """Check LLM model availability and latency across the fallback chain."""
    from integrations.llm import health_check
    result = health_check()

    if result.get("error"):
        print(f"LLM Health: ERROR — {result['error']}")
        raise typer.Exit(1)

    print("=== LLM Health Check ===")
    for m in result["models"]:
        status = m["status"]
        icon = "\u2713" if status == "ok" else "\u2717"
        ms = f"{m.get('latency_ms', 0)}ms" if status == "ok" else m.get("detail", "?")
        print(f"{icon} {m['position']:12s} {m['model'][:35]:35s} {status:12s} {ms}")
    print()
    print(f"{result['available_count']} of {result['total_count']} models available.")


@app.command()
def research(
    lead_id: Optional[str] = typer.Argument(None, help="Lead ID to research"),
    force: bool = typer.Option(False, "--force", "-f", help="Re-research (overwrite existing)"),
    batch: bool = typer.Option(False, "--batch", "-b", help="Research eligible leads in batch"),
    limit: int = typer.Option(50, "--limit", "-l", help="Max leads to research in batch mode"),
    category: Optional[str] = typer.Option(None, "--category", "-c", help="Filter by temporal category: fresh, recent, historical"),
    dry_run: bool = typer.Option(False, "--dry-run", "-n", help="Show what would be researched without doing it"),
):
    """Generate research note for a lead from website + public sources.

    Pipeline: fetch website content, extract Google Business data via Brave,
    generate structured research note via LLM, store on lead, push to Zoho.

    Examples:
      absel research <lead_id>
      absel research <lead_id> --force
      absel research --batch --limit 20
      absel research --batch --category historical --limit 10
    """
    from capabilities.research import research_lead, research_batch
    from capabilities.research import _gate_check
    from state.backlog import load_backlog, get_lead

    if lead_id:
        lead = get_lead(lead_id)
        if not lead:
            print(f"Error: Lead {lead_id} not found")
            raise typer.Exit(1)

        try:
            if dry_run:
                print(f"DRY RUN — would research {lead.get('name', '')}")
                passed, reason = _gate_check(lead)
                if not passed:
                    print(f"  Gate check: FAILED — {reason}")
                else:
                    print(f"  Gate check: PASSED")
                    print(f"  Website: {lead.get('website_url', 'N/A')}")
                    print(f"  Brave summary: {'Yes' if lead.get('brave_summary') else 'No'}")
                    print(f"  Already researched: {bool(lead.get('has_research'))}")
                return

            result = research_lead(lead_id, force=force)
            note = result.get("note", {})
            findings = note.get("findings", [])
            print(f"Researched: {result.get('name', '')}")
            print(f"  Findings: {len(findings)}")
            for f in findings:
                print(f"    - {f}")
            if note.get("suggested_angle"):
                print(f"  Angle: {note['suggested_angle']}")
            print(f"  Zoho push: {result.get('zoho_push', {}).get('status', 'N/A')}")
            wf = result.get("website_fetch", {})
            if wf.get("pages_fetched"):
                print(f"  Pages: {', '.join(wf['pages_fetched'])}")
            if wf.get("errors"):
                print(f"  Fetch errors: {wf['errors']}")
        except ValueError as e:
            print(f"Skipped: {e}")
            raise typer.Exit(1)

    elif batch:
        backlog = load_backlog()

        # Filter researchable leads
        researchable = []
        for l in backlog:
            passed, _ = _gate_check(l)
            if not passed:
                continue
            if not force and l.get("has_research"):
                continue
            if category and l.get("temporal_category") != category:
                continue
            researchable.append(l)

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

        if not researchable:
            print("No researchable leads found.")
            return

        if dry_run:
            print(f"DRY RUN — would research {len(researchable)} leads:")
            for l in researchable[:20]:
                print(f"  - {l.get('name', '')} (score={l.get('score', '-')})")
            if len(researchable) > 20:
                print(f"  ... and {len(researchable) - 20} more")
            return

        lead_ids = [l["id"] for l in researchable]
        print(f"Researching {len(lead_ids)} leads...")
        result = research_batch(lead_ids, force=force)
        print(f"\nComplete:")
        print(f"  Researched: {result['researched']}")
        print(f"  Skipped:    {result['skipped']}")
        print(f"  Errors:     {result['errors']}")

        for d in result["details"]:
            if d.get("error"):
                print(f"  Error: {d['lead_id']}: {d['error']}")
            elif d.get("skipped_reason"):
                print(f"  Skipped: {d['lead_id']}: {d['skipped_reason']}")

    else:
        print("Error: Provide a lead_id or use --batch")
        raise typer.Exit(1)


@app.command()
def draft(
    lead_id: str,
    variant: Optional[str] = typer.Option(None, "--variant", "-v", help="Prompt variant (e.g., 'historical')"),
    intel: Optional[str] = typer.Option(None, "--intel", "-i", help="Operator intel for this draft"),
):
    """Draft a touch for a lead."""
    from capabilities.drafting import draft_touch
    result = draft_touch(lead_id, prompt_variant=variant, batch_intel=intel)
    print(json.dumps(result, indent=2))


@app.command()
def respond(
    lead_id: str,
    reply_text: Optional[str] = typer.Argument(None, help="The reply text from the lead"),
    reply_file: Optional[str] = typer.Option(None, "--reply-file", "-f", help="Path to file containing reply text"),
    intel: Optional[str] = typer.Option(None, "--intel", "-i", help="Operator intel for this response"),
):
    """Draft a contextual response to a lead's reply.

    Reads the lead's original touch, enrichment data, and the reply text
    to produce a short, contextual response (50-80 words).

    The response is printed for review — NOT sent automatically.
    Copy it to Outlook manually.

    Examples:
      absel respond <lead_id> "sounds interesting, what models do you carry?"
      absel respond <lead_id> --reply-file reply.txt
    """
    from capabilities.respond import draft_response

    if reply_file:
        with open(reply_file) as f:
            reply_text = f.read().strip()

    if not reply_text:
        print("Error: Provide reply text as argument or use --reply-file")
        raise typer.Exit(1)

    result = draft_response(lead_id, reply_text, batch_intel=intel)

    print(f"Response drafted for lead {lead_id}")
    print(f"Context used: {', '.join(result['context_used'])}")
    print()
    print(f"Subject: {result['subject']}")
    print()
    print(result['body'])
    print()
    print("--- Copy this to Outlook manually. NOT sent automatically. ---")


@app.command()
def push(
    lead_id: Optional[str] = typer.Argument(None, help="Lead ID to push"),
    batch: bool = typer.Option(False, "--batch", "-b", help="Push all approved, un-pushed leads"),
    dry_run: bool = typer.Option(False, "--dry-run", "-n", help="Show what would be pushed"),
):
    """Push approved leads to Zoho CRM.

    Prerequisites:
    - Lead must have reviewed_at set (approval gate)
    - Lead must have draft_subject and draft_body
    - Lead must NOT have email_is_filing_service
    - Lead must have a usable email
    - Gate must be OPEN

    Examples:
      absel push <lead_id>
      absel push --batch
      absel push --batch --dry-run
    """
    from integrations.zoho import push_lead_to_zoho, push_batch

    if lead_id:
        lead = get_lead(lead_id)
        if not lead:
            print(f"Error: Lead {lead_id} not found")
            raise typer.Exit(1)

        try:
            result = push_lead_to_zoho(lead, dry_run=dry_run)
            if dry_run:
                print(f"DRY RUN — would push {lead.get('name', '')}")
                print(f"  Zoho fields: {json.dumps(result.get('data', {}), indent=2)}")
            else:
                print(f"Pushed {lead.get('name', '')} to Zoho")
                print(f"  Zoho ID: {result.get('zoho_id')}")
                print(f"  Status: {result.get('status')}")
        except ValueError as e:
            print(f"Rejected: {e}")
            raise typer.Exit(1)

    elif batch:
        backlog = load_backlog()
        approved_ids = [
            l["id"] for l in backlog
            if l.get("reviewed_at")
            and l.get("draft_status") == "drafted"
            and l.get("lead_status") == "active"
            and not l.get("pushed_to_zoho")
        ]

        if not approved_ids:
            print("No approved, un-pushed leads found.")
            return

        if dry_run:
            print(f"DRY RUN — would push {len(approved_ids)} leads:")
            for lid in approved_ids:
                l = get_lead(lid)
                if l:
                    print(f"  - {l.get('name', '')} ({l.get('email', '')})")
            return

        result = push_batch(approved_ids)
        print(f"Push complete:")
        print(f"  Pushed: {result['pushed']}")
        print(f"  Skipped: {result['skipped']}")
        print(f"  Failed: {result['failed']}")
        if result["errors"]:
            print(f"  Errors:")
            for err in result["errors"][:10]:
                print(f"    - {err}")

    else:
        print("Error: Provide a lead_id or use --batch")
        raise typer.Exit(1)


@app.command()
def replies(
    action: str = typer.Argument("check", help="Action: check, list"),
    since: Optional[str] = typer.Option(None, "--since", help="Check since (e.g., 48h)"),
    unhandled: bool = typer.Option(False, "--unhandled", help="Show only unhandled replies"),
):
    """Manage reply activity from Zoho.

    Actions:
      check — Poll Zoho for new replies, process opt-outs
      list  — Show all leads with replies
    """
    from integrations.zoho import check_for_replies, process_reply, get_replies_summary, _get_last_reply_check

    if action == "check":
        last_check = _get_last_reply_check()
        print(f"Checking for replies since: {last_check or 'never'}")

        replies = check_for_replies()
        if not replies:
            print("No new replies found.")
            return

        print(f"Found {len(replies)} new replies:")
        for reply in replies:
            zoho_id = reply.get("zoho_id", "")
            backlog = load_backlog()
            matched = None
            for lead in backlog:
                if lead.get("zoho_id") == zoho_id:
                    matched = lead
                    break

            if matched:
                result = process_reply(matched["id"], reply["reply_text"])
                print(f"  {matched.get('name', '')}: {result['action']} — {result['details']}")
            else:
                print(f"  (unmatched zoho_id: {zoho_id})")

    elif action == "list":
        results = get_replies_summary(unhandled_only=unhandled)
        if not results:
            msg = "No replies found"
            if unhandled:
                msg += " (unhandled)"
            print(msg + ".")
            return

        print(f"Replies ({len(results)}):")
        for r in results:
            action_icon = {
                "hard": "🔥",
                "soft": "⏸",
                "engagement": "💬",
                "none": "?",
            }.get(r.get("reply_action", ""), "?")
            print(f"  {action_icon} {r['lead_name']} ({r['reply_received_at'][:10]})")
            print(f"     Action: {r.get('reply_action', 'none')} | Status: {r['lead_status']}")
            preview = (r.get('reply_text', '') or '')[:80]
            if preview:
                print(f"     \"{preview}...\"")
            print()

    else:
        print(f"Error: Unknown action '{action}'. Use: check, list")
        raise typer.Exit(1)


@app.command()
def enrich(lead_id: str, pattern: str = "standard"):
    """Enrich a lead (Pattern A=standard, Pattern B=deep)."""
    from capabilities.enrichment import enrich_lead
    result = enrich_lead(lead_id, pattern)
    print(json.dumps(result, indent=2))


@app.command("backfill-ids")
def backfill_ids(
    top_n: int = typer.Option(0, "--top", "-n", help="Process top N by score (0=all missing)"),
    dry_run: bool = typer.Option(False, "--dry-run", "-d", help="Print matches without writing"),
    enrich: bool = typer.Option(False, "--enrich", "-e", help="Re-enrich leads after backfill"),
):
    """Backfill business_id for leads missing it via multi-pass CT SoS matching.

    Uses 3-pass strategy:
    Pass 1: Match by street address + billingcity
    Pass 2: Match by normalized business name
    Pass 3: Token-based fuzzy name match
    """
    from capabilities.backfill import backfill_business_ids
    from capabilities.enrichment import enrich_lead
    from state.backlog import load_backlog

    leads = load_backlog()
    sorted_leads = sorted(leads, key=lambda l: l.get("score", 0) or 0, reverse=True)

    target_ids = []
    for l in sorted_leads:
        if l.get("business_id"):
            continue
        target_ids.append(l["id"])
        if top_n > 0 and len(target_ids) >= top_n:
            break

    if not target_ids:
        print("No leads missing business_id found.")
        raise typer.Exit()

    print(f"Scanning {len(target_ids)} leads for missing business_id (dry_run={dry_run})...")

    result = backfill_business_ids(target_ids, dry_run=dry_run)

    print(f"\nResults:")
    print(f"  Already had ID:  {result['already_had_id']}")
    print(f"  Pass 1 (addr):   {result['pass1_address']}")
    print(f"  Pass 2 (name):   {result['pass2_name_normalized']}")
    print(f"  Pass 3 (fuzzy):  {result['pass3_token_fuzzy']}")
    print(f"  Failed:          {result['failed']}")
    print(f"  Total found:     {result['total_found']}")

    if result["errors"]:
        print(f"\nErrors ({len(result['errors'])}):")
        for e in result["errors"]:
            print(f"  {e}")

    # Re-enrich leads that got a business_id
    if enrich and not dry_run and result["total_found"] > 0:
        enriched_ids = [d["lead_id"] for d in result["details"] if d.get("business_id")]
        print(f"\nRe-enriching {len(enriched_ids)} leads with new business_ids...")
        enriched_count = 0
        error_count = 0
        for lid in enriched_ids:
            try:
                enrich_lead(lid)
                enriched_count += 1
            except Exception as e:
                error_count += 1
                print(f"  Enrich error {lid}: {e}")
        print(f"  Enriched: {enriched_count}, Errors: {error_count}")

    # Show details
    print(f"\nDetail:")
    for d in result["details"]:
        status = "OK  " if d.get("business_id") else "FAIL"
        match = d["matched_by"].ljust(16)
        bid = d["business_id"] or "---"
        print(f"  [{status}] {match} {bid} - {d['name']}")


@app.command()
def status(lead_id: str):
    """Get complete state for a lead."""
    from capabilities.state_queries import get_lead_state
    result = get_lead_state(lead_id)
    print(json.dumps(result, indent=2))


@app.command()
def legacy_review():
    """Show legacy leads awaiting 'clear for touch' decision."""
    from state.backlog import get_leads_by_status
    leads = get_leads_by_status("imported_legacy")
    print(f"Legacy leads awaiting activation: {len(leads)}")
    for lead in leads[:10]:
        print(f"  - {lead['name']} ({lead['city']})")


@app.command()
def burn(lead_id: str, reason: str = typer.Argument("", help="Why this lead is being burned")):
    """Burn a lead — permanently remove from all future outreach.

    Sets status to 'burned', records reason and timestamp.
    Burned leads are excluded from all pipeline operations forever.
    """
    from state.backlog import burn_lead, get_lead

    lead = get_lead(lead_id)
    if not lead:
        print(f"Error: Lead {lead_id} not found")
        raise typer.Exit(1)

    updated = burn_lead(lead_id, reason=reason)
    print(f"BURNED: {updated['name']} ({updated['city']})")
    print(f"  Status: {updated['lead_status']}")
    print(f"  Reason: {updated.get('burn_reason', '(not specified)')}")
    print(f"  Burned at: {updated.get('burned_at', '')}")


@app.command()
def pause(lead_id: str, reason: str = typer.Argument("", help="Why this lead is being paused")):
    """Pause a lead — stops touch clock until explicitly unpaused.

    Sets status to 'paused', records reason and timestamp.
    Paused leads are excluded from pipeline selection.
    """
    from state.backlog import pause_lead, get_lead

    lead = get_lead(lead_id)
    if not lead:
        print(f"Error: Lead {lead_id} not found")
        raise typer.Exit(1)

    updated = pause_lead(lead_id, reason=reason)
    print(f"PAUSED: {updated['name']} ({updated['city']})")
    print(f"  Status: {updated['lead_status']}")
    print(f"  Reason: {updated.get('paused_reason', '(not specified)')}")
    print(f"  Paused at: {updated.get('paused_at', '')}")
    print(f"  Run 'absel unpause {lead_id}' to resume.")


@app.command()
def unpause(lead_id: str):
    """Unpause a lead — resumes touch clock and returns to active status."""
    from state.backlog import unpause_lead, get_lead

    lead = get_lead(lead_id)
    if not lead:
        print(f"Error: Lead {lead_id} not found")
        raise typer.Exit(1)

    try:
        updated = unpause_lead(lead_id)
        print(f"UNPAUSED: {updated['name']} ({updated['city']})")
        print(f"  Status: {updated['lead_status']}")
    except ValueError as e:
        print(f"Error: {e}")
        raise typer.Exit(1)


@app.command()
def counts():
    """Show lead counts by status and stage."""
    from state.backlog import count_by_status, count_by_stage

    status_counts = count_by_status()
    stage_counts = count_by_stage()

    print("By status:")
    for status, count in sorted(status_counts.items()):
        print(f"  {status}: {count}")

    print("\nBy stage (temporal x draft):")
    for stage, count in sorted(stage_counts.items()):
        if count > 0:
            print(f"  {stage}: {count}")


@app.command()
def gate(
    action: str = typer.Argument("status", help="Action: status, close, open"),
    reason: str = typer.Option("", "--reason", "-r", help="Reason for closing/opening"),
    force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation for purge"),
):
    """Control the outreach gate.

    When CLOSED: harvesting, enrichment, and drafting continue normally,
    but no leads are selected for outreach. Leads are prepared and stored
    until the gate is opened.

    When OPEN: full pipeline operates normally.
    """
    from state.gate import close_gate, open_gate, gate_status

    if action == "status":
        status = gate_status()
        print(f"Gate: {status['status']}")
        if status.get("closed_at"):
            print(f"  Closed at: {status['closed_at']}")
            print(f"  Reason: {status.get('closed_reason', '(not specified)')}")
        if status.get("opened_at"):
            print(f"  Opened at: {status['opened_at']}")
            print(f"  Reason: {status.get('opened_reason', '(not specified)')}")

    elif action == "close":
        status = close_gate(reason=reason)
        print(f"Gate CLOSED")
        print(f"  Reason: {reason or '(not specified)'}")
        print(f"  Harvesting, enrichment, and drafting continue.")
        print(f"  Outreach is paused until gate is opened.")
        print(f"  Run 'absel gate open' to resume.")

    elif action == "open":
        status = open_gate(reason=reason)
        print(f"Gate OPENED")
        print(f"  Reason: {reason or '(not specified)'}")
        print(f"  Full pipeline operations resumed.")

    else:
        print(f"Error: Unknown action '{action}'. Use: status, close, open")
        raise typer.Exit(1)


@app.command()
def purge(
    confirm: bool = typer.Option(False, "--confirm", "-y", help="Skip confirmation prompt"),
):
    """Wipe the entire backlog database. DESTRUCTIVE — cannot be undone.

    Removes all leads, drafts, enrichment data, and tracking information.
    Gate state is preserved.
    """
    from state.backlog import save_backlog, load_backlog, count_by_status

    if not confirm:
        counts = count_by_status()
        total = sum(counts.values())
        print(f"WARNING: This will delete {total} leads from the backlog.")
        print("This action cannot be undone.")
        print("Use --confirm or -y to skip this prompt.")
        raise typer.Exit(1)

    save_backlog([])
    print("Backlog purged. Database is empty.")
    print("Gate state is preserved. Run 'absel gate status' to check.")


@app.command()
def review(
    category: Optional[str] = typer.Option(None, "--category", "-c", help="Filter by temporal category: fresh, recent, historical"),
    page_size: int = typer.Option(25, "--page-size", "-p", help="Leads per page"),
    strong_only: bool = typer.Option(False, "--strong-only", help="Show only ★★+ leads"),
):
    """Interactive review of drafted emails.

    Displays a numbered list of drafted leads with tags, subjects, and previews.
    Use quick commands to burn, pause, delete, regen, or approve leads by number.

    Commands:
      burn #N [reason]   — Permanently remove lead
      pause #N [reason]  — Pause lead until unpaused
      delete #N          — Remove draft only, lead stays
      regen #N           — Regenerate draft with LLM
      approve #N         — Mark as reviewed and cleared for sending
      next / prev        — Navigate pages
      quit / q           — Exit review
    """
    from cli.review import review_command
    review_command(category=category, page_size=page_size, strong_only=strong_only)


# Intel subcommands
intel_app = typer.Typer(help="Competitive intelligence queries")
app.add_typer(intel_app, name="intel")


@intel_app.command("agents")
def intel_agents(
    min_filings: int = typer.Option(10, "--min-filings", "-m", help="Minimum filings for agent to appear"),
):
    """Agent competitive intelligence — displacement rates by agent.

    Groups leads by agent_name, calculates competitor displacement rates,
    and flags agents with rates >1.5x baseline.
    """
    from capabilities.competitive_intel import agent_displacement_report
    print(agent_displacement_report(min_filings=min_filings))


@intel_app.command("principals")
def intel_principals(
    competitor_only: bool = typer.Option(False, "--competitor-only", "-c", help="Only show principals from competitor leads"),
):
    """Principal overlap detection — find principals with competitor equipment at multiple entities.

    Updates principal_competitor_overlap flags on linked leads.
    """
    from capabilities.competitive_intel import principal_overlap_report
    print(principal_overlap_report(competitor_only=competitor_only))


@intel_app.command("addresses")
def intel_addresses(
    min_businesses: int = typer.Option(2, "--min-businesses", "-m", help="Minimum businesses at address to report"),
):
    """Address clustering — find multi-business addresses with competitor presence.

    Updates address_competitor_overlap flags on non-competitor businesses.
    """
    from capabilities.competitive_intel import address_clustering_report
    print(address_clustering_report(min_businesses=min_businesses))


@intel_app.command("wins")
def intel_wins():
    """Win-rate tracking by agent (requires Zoho deal outcome integration)."""
    from capabilities.competitive_intel import win_rate_report
    print(win_rate_report())


# ── Operator intel management ──────────────────────────────────────


@intel_app.command("set")
def intel_set(
    lead_id: str = typer.Argument(..., help="Lead ID"),
    intel_text: str = typer.Argument(..., help="Operator intel text"),
):
    """Set per-lead operator intel. Stored on the lead, applied to every draft.

    Example:
      absel intel set <lead_id> "Waive month-to-month fee, she's a referral from Dr. Freeman"
    """
    from state.backlog import get_lead, update_lead
    from datetime import datetime, timezone

    lead = get_lead(lead_id)
    if not lead:
        print(f"Error: Lead {lead_id} not found")
        raise typer.Exit(1)

    now = datetime.now(timezone.utc).isoformat() + "Z"
    update_lead(lead_id, {"operator_intel": intel_text, "last_updated": now})
    print(f"Operator intel set for {lead.get('name', lead_id)}:")
    print(f"  {intel_text}")


@intel_app.command("clear")
def intel_clear(
    lead_id: str = typer.Argument(..., help="Lead ID"),
):
    """Clear per-lead operator intel.

    Example:
      absel intel clear <lead_id>
    """
    from state.backlog import get_lead, update_lead
    from datetime import datetime, timezone

    lead = get_lead(lead_id)
    if not lead:
        print(f"Error: Lead {lead_id} not found")
        raise typer.Exit(1)

    now = datetime.now(timezone.utc).isoformat() + "Z"
    update_lead(lead_id, {"operator_intel": "", "last_updated": now})
    print(f"Operator intel cleared for {lead.get('name', lead_id)}.")


@intel_app.command("show")
def intel_show(
    lead_id: str = typer.Argument(..., help="Lead ID"),
):
    """Show current operator intel for a lead.

    Example:
      absel intel show <lead_id>
    """
    from state.backlog import get_lead

    lead = get_lead(lead_id)
    if not lead:
        print(f"Error: Lead {lead_id} not found")
        raise typer.Exit(1)

    intel_text = (lead.get("operator_intel") or "").strip()
    if intel_text:
        print(f"Operator intel for {lead.get('name', lead_id)}:")
        print(f"  {intel_text}")
    else:
        print(f"No operator intel set for {lead.get('name', lead_id)}.")


@intel_app.command("global")
def intel_global(
    message: Optional[str] = typer.Argument(None, help="Global intel message to activate"),
    off: bool = typer.Option(False, "--off", help="Deactivate global intel"),
    status: bool = typer.Option(False, "--status", "-s", help="Show current global intel status"),
):
    """Manage global operator intel — applied to ALL drafts when active.

    Off by default. Set it for company-wide messaging (pricing, promotions).
    WARNING: Affects every draft until turned off.

    Examples:
      absel intel global "15% off all service contracts through Q3"
      absel intel global --status
      absel intel global --off
    """
    import yaml
    from datetime import datetime
    from pathlib import Path

    config_path = Path(__file__).parent.parent / "config" / "intel.yaml"

    if status:
        if config_path.exists():
            with open(config_path) as f:
                data = yaml.safe_load(f) or {}
            gi = data.get("global_intel", {})
            if gi.get("active"):
                print("⚠ GLOBAL INTEL ACTIVE")
                print(f'  Message: {gi.get("message", "")}')
                print(f'  Set at: {gi.get("set_at", "unknown")}')
            else:
                print("Global intel is OFF.")
        else:
            print("Global intel is OFF (config/intel.yaml not found).")
        return

    if off:
        data = {"global_intel": {"active": False, "message": "", "set_at": None, "set_by": "operator"}}
        config_path.parent.mkdir(parents=True, exist_ok=True)
        with open(config_path, "w") as f:
            yaml.dump(data, f, default_flow_style=False)
        print("Global intel deactivated.")
        return

    if not message:
        print("Error: Provide a message or use --off to deactivate.")
        raise typer.Exit(1)

    now = datetime.utcnow().isoformat() + "Z"
    data = {
        "global_intel": {
            "active": True,
            "message": message,
            "set_at": now,
            "set_by": "operator",
        }
    }
    config_path.parent.mkdir(parents=True, exist_ok=True)
    with open(config_path, "w") as f:
        yaml.dump(data, f, default_flow_style=False)

    print("⚠ GLOBAL INTEL ACTIVATED")
    print(f"  Message: {message}")
    print(f"  Set at: {now}")
    print()
    print("  Every draft now includes this intel.")
    print("  Run 'absel intel global --off' to deactivate.")
    print("  Check status with 'absel intel global --status'.")


# Suppress subcommands
suppress_app = typer.Typer(help="Global opt-out suppression registry")
app.add_typer(suppress_app, name="suppress")


@suppress_app.command("list")
def suppress_list():
    """List all suppressed emails and domains."""
    from state.suppressions import list_suppressions
    data = list_suppressions()
    emails = data.get("emails", {})
    domains = data.get("domains", {})
    if not emails and not domains:
        print("No suppressions.")
        return
    if emails:
        print(f"Suppressed emails ({len(emails)}):")
        for email, info in sorted(emails.items()):
            print(f"  {email} — {info.get('reason', '?')} ({info.get('suppressed_at', '')[:10]})")
    if domains:
        print(f"\nSuppressed domains ({len(domains)}):")
        for domain, info in sorted(domains.items()):
            print(f"  {domain} — {info.get('reason', '?')} ({info.get('suppressed_at', '')[:10]})")


@suppress_app.command("add")
def suppress_add(
    email: str = typer.Argument(..., help="Email address to suppress"),
    reason: str = typer.Option("operator_added", "--reason", "-r", help="Reason for suppression"),
):
    """Suppress an email address."""
    from state.suppressions import suppress_email
    suppress_email(email, reason=reason)
    print(f"Suppressed: {email} ({reason})")


@suppress_app.command("add-domain")
def suppress_add_domain(
    domain: str = typer.Argument(..., help="Domain to suppress (e.g. badco.com)"),
    reason: str = typer.Option("operator_added", "--reason", "-r", help="Reason for suppression"),
):
    """Suppress an entire email domain."""
    from state.suppressions import suppress_domain
    domain = domain.lower().strip().lstrip("@")
    suppress_domain(domain, reason=reason)
    print(f"Suppressed domain: {domain} ({reason})")


@suppress_app.command("remove")
def suppress_remove(
    email_or_domain: str = typer.Argument(..., help="Email or domain to unsuppress"),
):
    """Remove an email or domain from the suppression list."""
    from state.suppressions import unsuppress
    unsuppress(email_or_domain)
    print(f"Removed suppression: {email_or_domain}")


@suppress_app.command("check")
def suppress_check(
    email: str = typer.Argument(..., help="Email address to check"),
):
    """Check if an email or its domain is suppressed."""
    from state.suppressions import is_suppressed
    suppressed, reason = is_suppressed(email)
    if suppressed:
        domain = email.split("@", 1)[1] if "@" in email else ""
        print(f"SUPPRESSED: {email}")
        print(f"  Reason: {reason}")
        print(f"  Domain: {domain}")
    else:
        print(f"NOT SUPPRESSED: {email}")


# Export subcommands
export_app = typer.Typer(help="Export operations for outside sales and reports")
app.add_typer(export_app, name="export")


@export_app.command("outside-sales")
def export_outside_sales(
    county: Optional[str] = typer.Option(None, "--county", "-c", help="Filter to a single county"),
    limit: int = typer.Option(0, "--limit", "-l", help="Max leads to show (0 = all)"),
    fmt: str = typer.Option("txt", "--format", "-f", help="Output format: txt or csv"),
    output: Optional[str] = typer.Option(None, "--output", "-o", help="Save to file"),
):
    """Export leads that cannot be contacted digitally.

    These leads have no usable email and no phone, but have a physical
    address. Grouped by county, sorted by score within each county.

    County display order (when no filter): New Haven, Fairfield, Hartford,
    other CT counties alphabetically, then OTHER / UNKNOWN.
    """
    from cli.export_outside_sales import get_outside_sales_leads, format_txt, format_csv

    leads = get_outside_sales_leads(county_filter=county)

    if limit and limit > 0:
        leads = leads[:limit]

    if not leads:
        msg = f"No outside sales leads found"
        if county:
            msg += f" in {county} County"
        print(msg + ".")
        return

    if fmt == "csv":
        result = format_csv(leads)
    else:
        result = format_txt(leads, county_filter=county)

    print(result)

    if output:
        with open(output, 'w') as f:
            f.write(result)
        print(f"\nSaved to: {output}")


# Sequence subcommands
sequence_app = typer.Typer(help="Multi-touch sequence engine")
app.add_typer(sequence_app, name="sequence")


@sequence_app.command("preview")
def sequence_preview(
    lead_id: str,
    sequence_name: Optional[str] = typer.Option(None, "--sequence", "-s", help="Named sequence from config"),
    touches: int = typer.Option(6, "--touches", "-t", help="Number of touches"),
    days: int = typer.Option(60, "--days", "-d", help="Duration in days"),
    intel: Optional[str] = typer.Option(None, "--intel", "-i", help="Operator intel for this sequence"),
):
    """Preview a full sequence for a lead.

    Shows all touches on one screen with subjects, body previews,
    purposes, and differentiators.
    """
    from capabilities.sequencing import preview_sequence, check_sequence_eligibility
    from state.backlog import get_lead

    if sequence_name:
        from capabilities.sequencing import _load_sequences_config
        config = _load_sequences_config()
        seq_def = config.get("sequences", {}).get(sequence_name)
        if seq_def:
            touches = seq_def["touches"]
            days = seq_def["duration_days"]

    try:
        preview = preview_sequence(lead_id, touches, days, sequence_name, batch_intel=intel)
    except ValueError as e:
        print(f"Error: {e}")
        # Show existing sequence status for diagnostics
        lead = get_lead(lead_id)
        if lead and lead.get("sequence"):
            print(f"\nExisting sequence: {lead['sequence'].get('definition', 'ad_hoc')}")
            remaining = sum(1 for t in lead['sequence'].get('touches', [])
                          if t.get('status') in ('approved', 'pending'))
            print(f"Remaining touches: {remaining}")
            print(f"To see full status: absel sequence status {lead_id}")
            print(f"To cancel and re-sequence: absel sequence cancel {lead_id}")
        raise typer.Exit(1)

    print(f"\n=== SEQUENCE PREVIEW: {preview['lead_name']} ===")
    print(f"Envelope: {preview['envelope']['touches']} touches / {preview['envelope']['duration_days']} days")
    print(f"Score: {preview['lead_score']} | Category: {preview['lead_category']}")
    if preview.get("milestone_year"):
        print(f"Milestone: {preview['milestone_year']}-year")
    if preview.get("agent_info"):
        print(f"Agent: {preview['agent_info']}")
    if preview.get("principal_info"):
        print(f"Principal: {preview['principal_info']}")
    print(f"{preview['reason']}")
    print()

    for touch in preview["touches"]:
        body_preview = touch["body"][:100].replace("\n", " ")
        diff = touch.get("differentiator_used") or "none"
        print(f"TOUCH {touch['position']} — Day {touch['day']} ({touch['purpose']})")
        print(f"  Subject: {touch['subject']}")
        print(f"  {body_preview}...")
        print(f"  Differentiator: {diff}")
        print()

    dc = preview["differentiator_check"]
    print("=== DIFFERENTIATOR CHECK ===")
    print(f"Used: {', '.join(dc['used']) if dc['used'] else 'none'}")
    print(f"Unused: {', '.join(dc['unused']) if dc['unused'] else 'none'}")
    print(f"No repeats: {'✓' if dc['no_repeats'] else '✗'}")
    print()
    print("--- Commands: approve | edit #N | regen #N | regen all | cancel ---")


@sequence_app.command("status")
def sequence_status(lead_id: str):
    """Show current sequence state for a lead."""
    from state.backlog import get_lead

    lead = get_lead(lead_id)
    if not lead:
        print(f"Error: Lead {lead_id} not found")
        raise typer.Exit(1)

    sequence = lead.get("sequence")
    if not sequence:
        print(f"No sequence defined for lead {lead_id}")
        return

    print(f"\n=== SEQUENCE STATUS: {lead.get('name', '')} ===")
    print(f"Definition: {sequence.get('definition', 'unknown')}")
    print(f"Envelope: {sequence.get('envelope', {})}")
    print(f"Approved: {sequence.get('approved_at', 'never')}")
    print(f"Paused: {sequence.get('paused', False)}")
    if sequence.get("pause_reason"):
        print(f"Pause reason: {sequence['pause_reason']}")
    print()

    for touch in sequence.get("touches", []):
        status_icon = {"approved": "✓", "sent": "→", "skipped": "✗", "paused": "⏸"}.get(touch.get("status", "?"), "?")
        print(f"  Touch {touch['position']} (Day {touch.get('day', '?')}, {touch.get('purpose', '?')})")
        print(f"    Status: {touch.get('status', '?')} {status_icon}")
        print(f"    Scheduled: {touch.get('scheduled_date', '?')}")
        if touch.get("subject"):
            print(f"    Subject: {touch['subject']}")
        print()


@sequence_app.command("approve")
def sequence_approve(
    lead_id: str,
    touches: int = typer.Option(0, "--touches", "-t", help="Number of touches (uses preview envelope if not specified)"),
    days: int = typer.Option(0, "--days", "-d", help="Duration in days (uses preview envelope if not specified)"),
):
    """Approve the previewed sequence and store it on the lead.

    If no preview exists on the lead, requires --touches and --days.
    Otherwise uses the stored preview envelope parameters.
    """
    from capabilities.sequencing import preview_sequence, approve_sequence, recommend_sequence
    from state.backlog import get_lead

    lead = get_lead(lead_id)
    if not lead:
        print(f"Error: Lead {lead_id} not found")
        raise typer.Exit(1)

    if lead.get("sequence"):
        print(f"Lead already has a sequence. Cancel it first with 'absel sequence cancel {lead_id}'")
        raise typer.Exit(1)

    # Determine envelope
    if touches > 0 and days > 0:
        pass  # Use explicit flags
    elif lead.get("_sequence_preview"):
        stored = lead["_sequence_preview"]
        touches = stored.get("touches", 0)
        days = stored.get("days", 0)
    else:
        # Auto-select
        rec, throttle = recommend_sequence(lead)
        print(f"Auto-selected: {rec}")
        from capabilities.sequencing import _load_sequences_config
        cfg = _load_sequences_config()
        seq_def = cfg.get("sequences", {}).get(rec, {})
        touches = seq_def.get("touches", 4)
        days = seq_def.get("duration_days", 60)

    if touches < 2 or days < 1:
        print("Error: Invalid envelope. Use --touches and --days, or generate a preview first.")
        raise typer.Exit(1)

    preview = preview_sequence(lead_id, touches, days)
    sequence = approve_sequence(lead_id, preview)

    print(f"Sequence approved for {lead.get('name', '')}")
    print(f"  {len(sequence['touches'])} touches over {days} days")
    print(f"  Approved at: {sequence['approved_at']}")


@sequence_app.command("resume")
def sequence_resume(lead_id: str):
    """Resume a paused sequence."""
    from capabilities.sequencing import resume_sequence

    try:
        sequence = resume_sequence(lead_id)
        print(f"Sequence resumed for lead {lead_id}")
    except ValueError as e:
        print(f"Error: {e}")
        raise typer.Exit(1)


@sequence_app.command("cancel")
def sequence_cancel(lead_id: str):
    """Cancel remaining touches in a sequence."""
    from capabilities.sequencing import cancel_sequence

    try:
        sequence = cancel_sequence(lead_id)
        print(f"Sequence cancelled for lead {lead_id}")
    except ValueError as e:
        print(f"Error: {e}")
        raise typer.Exit(1)


@sequence_app.command("skip")
def sequence_skip(lead_id: str, position: int):
    """Skip a specific touch in a sequence."""
    from capabilities.sequencing import skip_touch

    try:
        touch = skip_touch(lead_id, position)
        print(f"Touch {position} skipped for lead {lead_id}")
    except ValueError as e:
        print(f"Error: {e}")
        raise typer.Exit(1)


@sequence_app.command("set-throttle")
def sequence_set_throttle(
    lead_id: str,
    on: bool = typer.Option(False, "--on", help="Enable throttling for this sequence"),
    off: bool = typer.Option(False, "--off", help="Disable throttling for this sequence"),
):
    """Override the throttle flag on an existing sequence.

    --on: sequence obeys the weekly spread (default for cold)
    --off: sequence sends immediately when due (default for renewals/urgent)
    """
    from state.backlog import get_lead, update_lead

    if on == off:
        print("Error: Specify either --on or --off.")
        raise typer.Exit(1)

    lead = get_lead(lead_id)
    if not lead:
        print(f"Error: Lead {lead_id} not found.")
        raise typer.Exit(1)

    seq = lead.get("sequence")
    if not seq:
        print(f"Lead {lead_id} has no sequence.")
        raise typer.Exit(1)

    seq["throttle"] = on
    update_lead(lead_id, {"sequence": seq, "last_updated": datetime.utcnow().isoformat() + "Z"})
    print(f"Throttle {'enabled' if on else 'disabled'} for {lead.get('name', lead_id)}.")


@sequence_app.command("due")
def sequence_due(
    push: bool = typer.Option(False, "--push", "-p", help="Push due touches to Zoho"),
    date_str: Optional[str] = typer.Option(None, "--date", help="Target date YYYY-MM-DD (default: today)"),
    limit: Optional[int] = typer.Option(None, "--limit", "-l", help="Override today's send count"),
    no_throttle: bool = typer.Option(False, "--no-throttle", help="Send all due touches (requires confirmation)"),
):
    """List all touches due today (or on a specific date).

    Runs reply check BEFORE listing due touches — prevents sending
    to someone who replied 'stop' between touches.
    """
    from integrations.zoho import get_due_touches_safe, push_touch_to_zoho, get_lead
    from capabilities.sequencing import get_throttled_due_touches, _load_sequences_config

    due = get_due_touches_safe(date_str)

    if not due:
        print("No touches due" + (f" on {date_str}" if date_str else " today") + ".")
        return

    target = date_str or datetime.utcnow().date().isoformat()
    day_name = datetime.now().strftime("%A").lower()
    throttle_cfg = _load_sequences_config().get("send_throttle", {})

    if push:
        if no_throttle:
            unthrottled_count = sum(1 for t in due if not t.get("throttle", True))
            throttled_count = len(due) - unthrottled_count
            print(f"\n⚠  WARNING: {len(due)} touches are due ({throttled_count} throttled, {unthrottled_count} immediate).")
            print("   Sending all at once may trigger spam filters.")
            try:
                confirm = input("   Are you sure? [y/N] ")
            except (EOFError, KeyboardInterrupt):
                confirm = "n"
            if confirm.lower() not in ("y", "yes"):
                print("   Cancelled.")
                return
            send_today = due
            hold = []
        else:
            send_today, hold = get_throttled_due_touches(due, throttle_cfg)
            if limit is not None:
                send_today = send_today[:limit]

        if not send_today:
            print("No touches to send today.")
            return

        pushed = 0
        for touch in send_today:
            lead = get_lead(touch["lead_id"])
            if not lead:
                continue
            seq = lead.get("sequence", {})
            # Find the matching touch in the sequence
            seq_touch = None
            for st in seq.get("touches", []):
                if st["position"] == touch["position"]:
                    seq_touch = st
                    break
            if seq_touch is None:
                continue
            result = push_touch_to_zoho(lead, seq_touch, dry_run=False)
            icon = "✓" if result.get("status") == "sent" else "✗"
            print(f"  {icon} {touch['lead_name']} — Touch {touch['position']} ({result.get('status', 'failed')})")
            if result.get("status") == "sent":
                pushed += 1

        print(f"\n--- {pushed} touch{'es' if pushed != 1 else ''} pushed ---")
        if hold:
            print(f"--- {len(hold)} touch{'es' if len(hold) != 1 else ''} held for later ---")
    else:
        # Dry-run: show what would send
        send_today, hold = get_throttled_due_touches(due, throttle_cfg)

        day_pct = throttle_cfg.get("weekly_spread", {}).get(day_name, 0)
        unthrottled = [t for t in due if not t.get("throttle", True)]

        print(f"\n=== TOUCHES DUE {target} ===")
        print(f"Today is {day_name.capitalize()} ({day_pct}% allocation).")
        print()

        if unthrottled:
            print(f"IMMEDIATE (unthrottled): {len(unthrottled)} touch{'es' if len(unthrottled) != 1 else ''}")
            for t in unthrottled[:5]:
                print(f"  {t['lead_name']} — Touch {t['position']} (score: {t['lead_score']})")
            print()

        if send_today:
            display_list = send_today[:5]
            print(f"Would send: {len(send_today)} of {len(due)} (top {len(display_list)} shown)")
            for t in display_list:
                print(f"  → {t['lead_name']} — Touch {t['position']} (score: {t['lead_score']})")
            print()
            print(f"Run with --push to send. Use --limit N to override count.")
        else:
            print("No touches would send today (weekend or all held).")

        if hold:
            print(f"\nHolding: {len(hold)} touch{'es' if len(hold) != 1 else ''} for later in the week")


@sequence_app.command("batch")
def sequence_batch(
    county: Optional[str] = typer.Option(None, "--county", "-c", help="Filter by county"),
    limit: int = typer.Option(20, "--limit", "-l", help="Max leads to process"),
    workers: int = typer.Option(4, "--workers", "-w", help="Parallel workers (default 4, max 8)"),
    dry_run: bool = typer.Option(False, "--dry-run", "-n", help="Show what would be sequenced without generating"),
    intel: Optional[str] = typer.Option(None, "--intel", "-i", help="Operator intel for this batch (not stored on leads)"),
):
    """Auto-generate sequences for eligible leads.

    Each lead gets its own auto-selected envelope based on data richness.
    Skips filing-service leads, burned/paused, shells, and leads with no email.
    Generates previews only — does NOT auto-approve. Review and approve individually.

    Examples:
      absel sequence batch --county Fairfield --limit 20
      absel sequence batch --dry-run
    """
    from capabilities.sequencing import sequence_batch, check_sequence_eligibility
    from state.backlog import load_backlog

    backlog = load_backlog()
    eligible = []

    for l in backlog:
        if l.get("lead_status") in ("burned", "paused"):
            continue
        if l.get("email_is_filing_service"):
            continue
        if l.get("is_shell"):
            continue
        if not l.get("email", "").strip():
            continue
        if county and county.lower() not in (l.get("county") or "").lower():
            continue
        eligible.append(l)

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

    if not eligible:
        print("No eligible leads found.")
        return

    if dry_run:
        print(f"DRY RUN — would process {len(eligible)} leads:\n")
        from capabilities.sequencing import recommend_sequence, _load_sequences_config
        cfg = _load_sequences_config()
        total_eligible = 0
        total_blocked = 0
        for l in eligible:
            ok, reason = check_sequence_eligibility(l["id"])
            if ok:
                rec, _ = recommend_sequence(l)
                seq_def = cfg.get("sequences", {}).get(rec, {})
                print(f"  {l.get('name', '')} → {rec} ({seq_def.get('touches', '?')}t/{seq_def.get('duration_days', '?')}d)")
                total_eligible += 1
            else:
                print(f"  ⊘ {l.get('name', '')} — BLOCKED ({reason})")
                total_blocked += 1
        print(f"\nSummary: {total_eligible} eligible, {total_blocked} blocked")
        if total_blocked > 0:
            print("(Blocked leads will be auto-substituted in actual run)")
        return

    lead_ids = [l["id"] for l in eligible]
    result = sequence_batch(lead_ids, max_workers=workers, batch_intel=intel)

    print(f"Sequencing complete:")
    print(f"  Sequenced:    {result['sequenced']}")
    print(f"  Blocked:      {result['blocked']}")
    print(f"  Substituted:  {result['substituted']}")
    print(f"  No substitute: {result['no_substitute']}")
    print(f"  Skipped:      {result['skipped']}")
    print(f"  Errors:       {result['errors']}")
    print()

    for d in result["details"]:
        status = d["status"]
        name = d.get("name", d.get("lead_id", "?"))
        if status == "sequenced":
            print(f"  ✓ {name} — {d.get('preset', '?')} ({d.get('touches', '?')} touches)")
        elif status == "substituted":
            sub_name = d.get("substitute_name", "?")
            print(f"  ↺ {d.get('lead_id', '?')} — BLOCKED ({d.get('reason', '?')})")
            print(f"    → Substituted: {sub_name}")
        elif status == "no_substitute":
            print(f"  ⊘ {name} — BLOCKED ({d.get('reason', '?')}) — no substitute found")
        elif status == "would_sequence":
            print(f"  ✓ {name} — {d.get('preset', '?')} ({d.get('touches', '?')} touches)")
        elif status == "would_substitute":
            print(f"  ↺ {name} — (substitute for blocked lead)")
        elif status == "skipped":
            print(f"  ⊘ {name} — SKIPPED ({d.get('reason', '?')})")
        else:
            print(f"  ✗ {name} — ERROR: {d.get('reason', '?')}")

    preset_counts = {}
    for d in result["details"]:
        if d["status"] in ("sequenced", "substituted", "would_sequence", "would_substitute"):
            p = d.get("preset", "?")
            preset_counts[p] = preset_counts.get(p, 0) + 1
    if preset_counts:
        parts = ", ".join(f"{n}× {p}" for p, n in sorted(preset_counts.items()))
        print(f"\nPresets: {parts}")


# Harvest subcommands
harvest_app = typer.Typer(help="CT SoS harvest operations")
app.add_typer(harvest_app, name="harvest")


@harvest_app.command("recent")
def harvest_recent(
    days: int = typer.Option(1, "--days", "-d", help="Days back to harvest"),
    skip_zoho_check: bool = typer.Option(False, "--skip-zoho-check", help="Skip Zoho dedup check (faster for fresh leads)"),
):
    """Harvest filings from the last N days."""
    from capabilities.harvest import harvest_recent
    result = harvest_recent(days, skip_zoho_check=skip_zoho_check)
    print(str(result))


@harvest_app.command("range")
def harvest_range(
    start: str = typer.Option(..., "--start", "-s", help="Start date YYYY-MM-DD"),
    end: str = typer.Option(..., "--end", "-e", help="End date YYYY-MM-DD"),
    resume: bool = typer.Option(False, "--resume", "-r", help="Resume from checkpoint"),
    skip_zoho_check: bool = typer.Option(False, "--skip-zoho-check", help="Skip Zoho dedup check (faster for fresh leads)"),
):
    """Harvest filings from a historical date range."""
    from capabilities.harvest import harvest_historical_range
    
    start_date = date.fromisoformat(start)
    end_date = date.fromisoformat(end)
    checkpoint_path = Path("/tmp/harvest-checkpoint.json")
    
    result = harvest_historical_range(start_date, end_date, resume, checkpoint_path, skip_zoho_check=skip_zoho_check)
    print(str(result))


@harvest_app.command("milestone")
def harvest_milestone(
    years: int = typer.Option(..., "--years", "-y", help="Target milestone year (3, 5, 7, 10)"),
    window_days: int = typer.Option(90, "--window-days", "-w", help="Days around milestone"),
    resume: bool = typer.Option(False, "--resume", "-r", help="Resume from checkpoint"),
    skip_zoho_check: bool = typer.Option(False, "--skip-zoho-check", help="Skip Zoho dedup check (faster for fresh leads)"),
):
    """Harvest filings near a milestone anniversary."""
    from capabilities.harvest import harvest_for_milestone
    
    checkpoint_path = Path("/tmp/harvest-checkpoint.json")
    result = harvest_for_milestone(years, window_days, resume, checkpoint_path, skip_zoho_check=skip_zoho_check)
    print(str(result))


# Renewal subcommands
renewal_app = typer.Typer(help="Contract renewal operations")
app.add_typer(renewal_app, name="renewals")


@renewal_app.command("scan")
def renewals_scan(
    days: int = typer.Option(120, "--days", "-d", help="Days ahead to scan"),
    dry_run: bool = typer.Option(False, "--dry-run", "-n", help="Show what would be created without creating"),
    skip_zoho_check: bool = typer.Option(True, "--skip-zoho-check", help="Skip Zoho dedup (default: skip — renewals come FROM Zoho)"),
):
    """Scan Zoho for upcoming contract renewals and create renewal leads.

    Queries the Deals module for contracts expiring within N days,
    enriches with equipment data, and creates renewal leads in the
    backlog with lead_type="renewal".

    Skips duplicates (same zoho_deal_id already in backlog).
    Zoho dedup is skipped by default (renewals are sourced from Zoho).

    Examples:
      absel renewals scan
      absel renewals scan --days 90
      absel renewals scan --dry-run
    """
    try:
        from zohar.queries.renewals import get_upcoming_renewals
    except ImportError:
        print("Error: zohar module not available. Ensure it's installed.")
        raise typer.Exit(1)

    from capabilities.renewals import create_renewal_leads

    renewals = get_upcoming_renewals(days_ahead=days)

    if not renewals:
        print(f"No upcoming renewals found in the next {days} days.")
        return

    print(f"Found {len(renewals)} upcoming renewals in the next {days} days:")

    # Group by urgency
    urgent = [r for r in renewals if r.get("days_until_expiration") is not None and r["days_until_expiration"] <= 30]
    near = [r for r in renewals if r.get("days_until_expiration") is not None and 30 < r["days_until_expiration"] <= 60]
    upcoming = [r for r in renewals if r.get("days_until_expiration") is None or r["days_until_expiration"] > 60]

    if urgent:
        print(f"\n  URGENT (≤30 days): {len(urgent)}")
        for r in urgent:
            model = r.get("equipment", {}).get("model", "?")
            print(f"    - {r['account_name']} ({r['days_until_expiration']}d) — {model}")

    if near:
        print(f"\n  NEAR (31-60 days): {len(near)}")
        for r in near:
            model = r.get("equipment", {}).get("model", "?")
            print(f"    - {r['account_name']} ({r['days_until_expiration']}d) — {model}")

    if upcoming:
        print(f"\n  UPCOMING (>60 days): {len(upcoming)}")
        for r in upcoming[:10]:
            model = r.get("equipment", {}).get("model", "?")
            days_str = f"{r['days_until_expiration']}d" if r['days_until_expiration'] else "?"
            print(f"    - {r['account_name']} ({days_str}) — {model}")
        if len(upcoming) > 10:
            print(f"    ... and {len(upcoming) - 10} more")

    if dry_run:
        print(f"\nDRY RUN — would create {len(renewals)} renewal leads. No changes made.")
        return

    result = create_renewal_leads(renewals, skip_zoho_check=skip_zoho_check)
    print(f"\nCreated: {result['created']} new renewal leads")
    print(f"Skipped: {result['skipped']}")
    print(f"Total from Zoho: {result['total']}")


@renewal_app.command("list")
def renewals_list(
    urgent_only: bool = typer.Option(False, "--urgent", "-u", help="Show only renewals within 30 days"),
    days: int = typer.Option(0, "--days", "-d", help="Show renewals within N days (0=all)"),
):
    """List renewal leads from the backlog.

    Examples:
      absel renewals list
      absel renewals list --urgent
    """
    from capabilities.renewals import get_renewal_leads

    leads = get_renewal_leads(urgent_only=urgent_only)

    if days > 0:
        leads = [l for l in leads if (l.get("days_until_expiration") or 999) <= days]

    if not leads:
        msg = "No renewal leads found"
        if urgent_only:
            msg += " (urgent)"
        if days > 0:
            msg += f" within {days} days"
        print(msg + ".")
        return

    print(f"Renewal leads ({len(leads)}):")
    for l in leads:
        model = l.get("equipment_model", "?")
        days = l.get("days_until_expiration")
        classification = l.get("renewal_classification", "")
        days_str = f"{days}d" if days is not None else "?"
        is_expired = days is not None and days < 0
        flag = " ⚠" if is_expired else ""
        seq_status = "✓ sequenced" if l.get("sequence") else "— pending"
        label = f" {classification}" if classification else ""
        print(f"  {l.get('name', '')}{flag}")
        print(f"    {model}{label} | expires {str(l.get('contract_expiration', ''))[:10]} ({days_str})")
        print(f"    upgrade: {l.get('recommended_upgrade', '?')} ({l.get('upgrade_urgency', '?')})")
        print(f"    sequence: {seq_status}")
        print()


@renewal_app.command("create")
def renewals_create(
    email: str = typer.Option(..., "--email", "-e", prompt=True, help="Contact email address"),
    name: Optional[str] = typer.Option(None, "--name", "-n", help="Company name (default: extracted from email domain)"),
    contact: Optional[str] = typer.Option(None, "--contact", "-c", help="Contact name (default: extracted from email prefix)"),
    model: str = typer.Option(..., "--model", "-m", prompt=True, help="Equipment model number"),
    serial: str = typer.Option("unknown", "--serial", "-s", help="Equipment serial number"),
    expiration: str = typer.Option(..., "--expiration", "-x", prompt=True, help="Contract expiration date (M/DD/YYYY or YYYY-MM-DD)"),
    mono: int = typer.Option(0, "--mono", help="Mono meter count"),
    color: int = typer.Option(0, "--color", help="Color meter count"),
    phone: Optional[str] = typer.Option(None, "--phone", "-p", help="Phone number"),
    notes: Optional[str] = typer.Option(None, "--notes", help="Internal notes"),
    intel: Optional[str] = typer.Option(None, "--intel", "-i", help="Operator intel (stored on lead, used in all drafts)"),
    force: bool = typer.Option(False, "--force", "-f", help="Create even if duplicate exists"),
):
    """Create a renewal lead manually — no Zoho scan needed.

    Operator provides contact info, equipment details, and expiration date.
    The system computes upgrade recommendation and saves the lead for sequencing.

    Examples:
      absel renewals create --email amy@starline.com --model C8145 --expiration 7/27/2026
      absel renewals create --email amy@starline.com --name "Starline Graphics" --contact "Amy Johnson" --model "AltaLink C8145" --serial EHQ555555 --expiration 7/27/2026 --mono 45000 --color 12000
    """
    from capabilities.renewals import create_renewal_lead_manual

    try:
        lead = create_renewal_lead_manual(
            email=email,
            contact_name=contact,
            company_name=name,
            equipment_model=model,
            equipment_serial=serial,
            contract_expiration=expiration,
            mono_meter=mono,
            color_meter=color,
            phone=phone,
            notes=notes,
            intel=intel,
            force=force,
        )
    except ValueError as e:
        print(f"Error: {e}")
        raise typer.Exit(1)

    total_meters = lead.get("equipment_mono_meter", 0) + lead.get("equipment_color_meter", 0)
    days = lead.get("days_until_expiration", "?")

    print(f"\n=== RENEWAL LEAD CREATED ===")
    print()
    print(f"  Company: {lead.get('name', '')}")
    print(f"  Contact: {contact or lead.get('greeting_name', '')} (greeting: \"{lead.get('greeting_name', '')}\")")
    print(f"  Email: {lead.get('email', '')}")
    print(f"  Equipment: {lead.get('equipment_model', '')} ({lead.get('equipment_serial', '')})")
    print(f"  Meters: {lead.get('equipment_mono_meter', 0):,} mono / {lead.get('equipment_color_meter', 0):,} color ({total_meters:,} total)")
    print(f"  Contract expires: {str(lead.get('contract_expiration', ''))[:10]} ({days} days)")
    print()
    print(f"  Upgrade recommendation: {lead.get('recommended_upgrade', '')}")
    print(f"    Reason: {lead.get('upgrade_reason', '')}")
    print(f"    Meter insight: {lead.get('meter_insight', '')}")
    print(f"    Urgency: {lead.get('upgrade_urgency', '')}")
    print()
    print(f"  Lead ID: {lead['id']}")
    print()
    print(f"  Next: absel renewals sequence {lead['id']}")


@renewal_app.command("cancel")
def renewals_cancel(
    lead_id: str = typer.Argument(..., help="Lead ID to cancel"),
    reason: str = typer.Option("", "--reason", "-r", help="Why this renewal is being cancelled"),
):
    """Cancel a renewal lead — removes from active renewal pipeline.

    Sets lead_status to "cancelled". The lead stays in backlog for audit
    but is excluded from all renewal operations (list, sequence, due).

    Examples:
      absel renewals cancel <lead_id>
      absel renewals cancel <lead_id> --reason "Customer switching vendors"
    """
    from capabilities.renewals import cancel_renewal_lead
    from state.backlog import get_lead

    lead = get_lead(lead_id)
    if not lead:
        print(f"Error: Lead {lead_id} not found")
        raise typer.Exit(1)

    try:
        updated = cancel_renewal_lead(lead_id)
    except (KeyError, ValueError) as e:
        print(f"Error: {e}")
        raise typer.Exit(1)

    # Also store the reason if provided
    if reason:
        from state.backlog import update_lead
        from datetime import datetime, timezone
        now = datetime.now(timezone.utc).isoformat() + "Z"
        update_lead(lead_id, {"cancelled_reason": reason, "last_updated": now})
        updated = get_lead(lead_id)

    model = updated.get("equipment_model", "?")
    print(f"CANCELLED: {updated.get('name', '')}")
    print(f"  Equipment: {model}")
    print(f"  Lead ID: {lead_id}")
    print(f"  Reason: {reason or '(not specified)'}")
    print(f"  Cancelled at: {updated.get('cancelled_at', '')}")


@renewal_app.command("sequence")
def renewals_sequence(
    lead_id: Optional[str] = typer.Argument(None, help="Specific lead ID to sequence (omit for batch mode)"),
    limit: int = typer.Option(20, "--limit", "-l", help="Max renewals to sequence (batch mode)"),
    dry_run: bool = typer.Option(False, "--dry-run", "-n", help="Show what would be sequenced without generating"),
    workers: int = typer.Option(6, "--workers", "-w", help="Parallel workers (default 6, max 8)"),
    approve: bool = typer.Option(False, "--approve", "-a", help="Auto-approve after generation"),
    intel: Optional[str] = typer.Option(None, "--intel", "-i", help="Operator intel for this sequence/batch"),
):
    """Generate sequences for renewal leads.

    Provide a lead ID to sequence a specific renewal, or omit for batch mode.
    Each renewal gets a renewal_90 preset sequence (4 touches over 75 days).

    Examples:
      absel renewals sequence <lead_id>
      absel renewals sequence --limit 10
      absel renewals sequence --dry-run
    """
    from capabilities.renewals import get_renewal_leads
    from capabilities.sequencing import (
        preview_sequence, approve_sequence, _load_sequences_config,
        sequence_batch,
    )
    from state.backlog import get_lead

    # Single lead mode
    if lead_id:
        lead = get_lead(lead_id)
        if not lead:
            print(f"Error: Lead {lead_id} not found")
            raise typer.Exit(1)
        if lead.get("lead_type") != "renewal":
            print(f"Error: Lead {lead_id} is not a renewal lead")
            raise typer.Exit(1)
        if lead.get("sequence"):
            print(f"Lead {lead_id} already has a sequence.")
            return
        lead_ids = [lead_id]
        total = 1
        unsequenced = [lead]
    else:
        # Batch mode
        leads = get_renewal_leads()
        unsequenced = [l for l in leads if not l.get("sequence")]

        if not unsequenced:
            print("No unsequenced renewal leads found.")
            return

        unsequenced = unsequenced[:limit]
        lead_ids = [l["id"] for l in unsequenced]
        total = len(lead_ids)

    if dry_run:
        print(f"DRY RUN — would sequence {len(lead_ids)} renewal leads:")
        for l in unsequenced:
            model = l.get("equipment_model", "?")
            days = l.get("days_until_expiration", "?")
            print(f"  - {l.get('name', '')} ({model}, {days}d until expiration)")
        return

    result = sequence_batch(lead_ids, dry_run=False, max_workers=workers, approve=approve, batch_intel=intel)

    print(f"Renewal sequencing complete:")
    print(f"  Sequenced: {result['sequenced']}")
    print(f"  Skipped:   {result['skipped']}")
    print(f"  Errors:    {result['errors']}")
    print()

    for d in result["details"]:
        status = d["status"]
        icon = "✓" if status == "sequenced" else ("⊘" if status == "skipped" else "✗")
        name = d.get("name", d.get("lead_id", "?"))
        if status == "sequenced":
            print(f" {icon} {name} — {d.get('preset', '?')} ({d.get('touches', '?')} touches)")
        elif status == "skipped":
            print(f" {icon} {name} — SKIPPED ({d.get('reason', '?')})")
        else:
            print(f" {icon} {name} — ERROR: {d.get('reason', '?')}")


@renewal_app.command("due")
def renewals_due(
    push: bool = typer.Option(False, "--push", "-p", help="Push due touches to Zoho"),
    date_str: Optional[str] = typer.Option(None, "--date", help="Target date YYYY-MM-DD (default: today)"),
    limit: Optional[int] = typer.Option(None, "--limit", "-l", help="Max touches to push"),
    no_throttle: bool = typer.Option(False, "--no-throttle", help="Send all due touches (confirmation required)"),
):
    """List due touches for renewal leads only.

    Runs reply check BEFORE listing due touches.

    Examples:
      absel renewals due
      absel renewals due --push
    """
    from integrations.zoho import get_due_touches_safe, push_touch_to_zoho, get_lead
    from capabilities.renewals import get_renewal_leads
    from capabilities.sequencing import get_throttled_due_touches, _load_sequences_config

    all_due = get_due_touches_safe(date_str)

    # Filter to only renewal leads
    renewal_ids = {l["id"] for l in get_renewal_leads()}
    due = [t for t in all_due if t["lead_id"] in renewal_ids]

    if not due:
        print("No renewal touches due" + (f" on {date_str}" if date_str else " today") + ".")
        return

    target = date_str or datetime.utcnow().date().isoformat()

    if push:
        throttle_cfg = _load_sequences_config().get("send_throttle", {})
        day_name = datetime.now().strftime("%A").lower()

        if no_throttle:
            print(f"\n⚠  WARNING: {len(due)} renewal touches are due.")
            try:
                confirm = input("   Send all now? [y/N] ")
            except (EOFError, KeyboardInterrupt):
                confirm = "n"
            if confirm.lower() not in ("y", "yes"):
                print("   Cancelled.")
                return
            send_today = due
        else:
            send_today, hold = get_throttled_due_touches(due, throttle_cfg)
            if limit is not None:
                send_today = send_today[:limit]
            if hold:
                print(f"\n=== RENEWAL TOUCHES DUE {target} ===")
                print(f"Holding {len(hold)} for later (weekly spread).")
                print()

        pushed = 0
        for touch in send_today:
            lead = get_lead(touch["lead_id"])
            if not lead:
                continue
            seq = lead.get("sequence", {})
            seq_touch = next((st for st in seq.get("touches", []) if st["position"] == touch["position"]), None)
            if seq_touch is None:
                continue
            result = push_touch_to_zoho(lead, seq_touch, dry_run=False)
            icon = "✓" if result.get("status") == "sent" else "✗"
            print(f"  {icon} {touch['lead_name']} — Renewal Touch {touch['position']} ({result.get('status', 'failed')})")
            if result.get("status") == "sent":
                pushed += 1

        print(f"\n--- {pushed} renewal touch{'es' if pushed != 1 else ''} pushed ---")
    else:
        print(f"\n=== RENEWAL TOUCHES DUE {target} ===")
        print()

        for i, touch in enumerate(due, 1):
            print(f" #{i:2d}  {touch['lead_name']} — Touch {touch['position']} of {touch['total']} ({touch['purpose']})")
            print(f"     Subject: {touch['subject']}")
            print(f"     Status: approved ✓")
            print()

        print(f"--- {len(due)} renewal touch{'es' if len(due) != 1 else ''} due ---")
        print("Run with --push to send.")


# Import subcommands
import_app = typer.Typer(help="Bulk data import operations")
app.add_typer(import_app, name="import")


@import_app.command("leads")
def import_leads(
    csv_path: str = typer.Argument(..., help="Path to CSV file"),
    lead_type: str = typer.Option("cold", "--type", "-t", help="Lead type: cold or renewal"),
    dry_run: bool = typer.Option(False, "--dry-run", "-n", help="Preview without importing"),
    intel: Optional[str] = typer.Option(None, "--intel", "-i", help="Batch intel for all imported leads"),
    mapping: Optional[str] = typer.Option(None, "--mapping", "-m", help="JSON column mapping override"),
    skip_zoho_check: bool = typer.Option(False, "--skip-zoho-check", help="Skip Zoho dedup check (faster for fresh leads)"),
):
    """Import leads from a CSV file.

    Auto-detects column names from known aliases. Use --mapping to override.
    Checks suppression registry, deduplicates against backlog and within-file.

    Examples:
      absel import leads tradeshow.csv
      absel import leads tradeshow.csv --dry-run
      absel import leads renewals.csv --type renewal
      absel import leads contacts.csv --intel "Q3 trade show"
      absel import leads data.csv --mapping '{"Full Name":"contact_name","Org":"name"}'
    """
    from capabilities.import_leads import import_from_csv

    try:
        parsed_mapping = None
        if mapping:
            import json
            parsed_mapping = json.loads(mapping)

        result = import_from_csv(
            csv_path=csv_path,
            lead_type=lead_type,
            dry_run=dry_run,
            intel=intel,
            column_mapping=parsed_mapping,
            skip_zoho_check=skip_zoho_check,
        )
    except (FileNotFoundError, ValueError) as e:
        print(f"Error: {e}")
        raise typer.Exit(1)

    if dry_run:
        print(f"\nDRY RUN — CSV Import Preview:\n")
        print(f"  File: {csv_path} ({result['total_rows']} rows)")
        print(f"  Column mapping (auto-detected):")
        for csv_col, schema_field in result["column_mapping"].items():
            print(f"    {csv_col} \u2192 {schema_field}")
        print()
        print(f"  Would import: {result['imported']} leads ({lead_type})")
        print(f"  Would skip: {result['skipped_duplicate']} duplicates, {result['skipped_suppressed']} suppressed, {result['skipped_zoho']} in Zoho, {result['skipped_invalid']} invalid")
        for err in result["row_errors"]:
            print(f"  {err}")
        return

    print(f"\nImported {result['imported']} leads from {csv_path}")
    print(f"  Duplicates skipped: {result['skipped_duplicate']}")
    print(f"  Zoho exists:        {result['skipped_zoho']}")
    print(f"  Suppressed skipped: {result['skipped_suppressed']}")
    print(f"  Invalid skipped:    {result['skipped_invalid']}")
    print(f"  Lead type: {lead_type}")
    if intel:
        print(f"  Batch intel: {intel[:80]}{'...' if len(intel) > 80 else ''}")
    print()
    if result["imported"] > 0:
        print(f"  Next: absel research --batch or absel brief")
    if result["skipped_invalid"] > 0:
        print(f"  Errors:")
        for err in result["row_errors"]:
            print(f"    {err}")


# Signal harness subcommands (zohar)
try:
    from zohar.cli.main import app as signal_app
    app.add_typer(signal_app, name="signal")
except ImportError:
    pass


if __name__ == "__main__":
    app()