#!/usr/bin/env python3
"""AgentOutreach Autopilot — reference agent.

Pulls pending opportunities from your AgentOutreach queue, asks an LLM
("brain") to decide send-as-is / edit / skip per opportunity, sends approved
ones from your own SMTP, and POSTs results back to AgentOutreach so the
daily digest can recap what happened.

  Docs:        https://www.agentoutreach.io/autopilot
  Manage keys: https://www.agentoutreach.io/dashboard/api-keys

Environment variables
---------------------
Required:
  AGENTOUTREACH_API_KEY     ao_live_... (from /dashboard/api-keys)

Sending (SMTP):
  SMTP_HOST                 e.g. smtp.gmail.com
  SMTP_PORT                 default 587
  SMTP_USERNAME             your SMTP login
  SMTP_PASSWORD             your SMTP password (app password for Gmail)
  SEND_FROM                 e.g. "Riley <riley@yourdomain.com>"
  SEND_LIVE                 "1" to actually send; default "0" (dry-run)

Brain (which LLM decides per-opportunity):
  BRAIN                     anthropic (default) | openai | codex | claude_code
  ANTHROPIC_API_KEY         required when BRAIN=anthropic
  OPENAI_API_KEY            required when BRAIN=openai
  # codex / claude_code: must have `codex` or `claude` on PATH respectively

Behaviour:
  MAX_PER_RUN               cap on how many to action this run (default 20)
  MIN_GRADE                 only consider B+ and above by default (set "" to disable)
  AGENT_AUTO_EDIT           "1" to let the brain rewrite subject/body when it
                            wants to send; "0" to always send the original draft
"""
from __future__ import annotations

import json
import os
import smtplib
import sys
import textwrap
import time
import urllib.error
import urllib.request
from email.message import EmailMessage


BASE = "https://www.agentoutreach.io/api/v1"
API_KEY = os.environ.get("AGENTOUTREACH_API_KEY") or ""
SEND_LIVE = os.environ.get("SEND_LIVE", "0") == "1"
BRAIN = (os.environ.get("BRAIN") or "anthropic").lower()
MAX_PER_RUN = int(os.environ.get("MAX_PER_RUN") or 20)
MIN_GRADE = os.environ.get("MIN_GRADE", "B+")
AGENT_AUTO_EDIT = os.environ.get("AGENT_AUTO_EDIT", "1") == "1"


# --- HTTP -------------------------------------------------------------------

def _req(method: str, path: str, body=None) -> dict:
    if not API_KEY:
        die("AGENTOUTREACH_API_KEY env var not set.")
    url = BASE + path
    data = None
    headers = {"Authorization": f"Bearer {API_KEY}", "Accept": "application/json"}
    if body is not None:
        data = json.dumps(body).encode("utf-8")
        headers["Content-Type"] = "application/json"
    req = urllib.request.Request(url, data=data, method=method, headers=headers)
    try:
        with urllib.request.urlopen(req, timeout=30) as r:
            return json.loads(r.read() or "null")
    except urllib.error.HTTPError as e:
        try:
            payload = json.loads(e.read())
        except Exception:
            payload = {"error": str(e)}
        die(f"HTTP {e.code} on {method} {path}: {payload}")


def die(msg: str):
    print(f"[autopilot] {msg}", file=sys.stderr)
    sys.exit(1)


# --- Brain -----------------------------------------------------------------

DECIDE_SYSTEM = """You are a partnerships manager reviewing an outreach
opportunity that an AI has already drafted on your behalf. Your job is one
decision per opportunity:

- "send"  the draft is good — let it go out as-is.
- "edit"  the draft is close but needs a small change — supply edited_subject
          and edited_body in your response.
- "skip"  not a fit — give a one-sentence reason for the learning loop.

You will be shown a SITE PROFILE: a structured brief about the user's product
(what it does, who it's for, headline features, pricing, differentiator,
likely objection). Use it as your source of truth about the product. Do NOT
invent product features, prices, or capabilities that contradict the profile.

Be strict. Most cold emails should be skipped if the host doesn't clearly
match the user's niche described in the site profile. If the page evidence
doesn't show a clear hook to the profile's WHO IT'S FOR or HEADLINE FEATURES,
prefer skip over send. Quality over quantity.

Reply with JSON ONLY:
  {"action": "send" | "edit" | "skip",
   "reason": "...",                   # required for skip
   "edited_subject": "...",           # required for edit
   "edited_body": "..."}              # required for edit (must still start with "Hello!")
"""


def decide(opp: dict, site: dict) -> dict:
    profile = (site.get("site_profile") or "").strip()
    profile_block = (
        f"SITE PROFILE (source of truth about the product — do not contradict):\n{profile}\n\n"
        if profile
        else "(No site profile available — fall back to the one-line pitch below.)\n\n"
    )
    prompt = (
        f"SITE: {site.get('label') or site.get('url')}\n"
        f"URL: {site.get('url')}\n"
        f"ONE-LINE PITCH: {site.get('about') or '(n/a)'}\n"
        f"SENDER ROLE: {site.get('sender_role') or '(team member, not founder)'}\n\n"
        f"{profile_block}"
        f"OPPORTUNITY:\n"
        f"  Title: {opp.get('title')}\n"
        f"  URL: {opp.get('url')}\n"
        f"  Domain: {opp.get('domain')}\n"
        f"  Category: {opp.get('category_label')}\n"
        f"  Fit grade: {opp.get('fit_grade')}   score: {opp.get('fit_score')}\n"
        f"  Evidence: {(opp.get('evidence') or '')[:800]}\n\n"
        f"DRAFT:\n"
        f"  Subject: {opp.get('draft_subject')}\n"
        f"  Body:    {opp.get('draft_body')}\n\n"
        f"Your decision (JSON only)."
    )
    if BRAIN == "anthropic":
        return _decide_anthropic(prompt)
    if BRAIN == "openai":
        return _decide_openai(prompt)
    if BRAIN == "codex":
        return _decide_shell("codex", ["exec"], prompt)
    if BRAIN == "claude_code":
        return _decide_shell("claude", ["-p"], prompt)
    die(f"unknown BRAIN={BRAIN}")


def _decide_anthropic(prompt: str) -> dict:
    key = os.environ.get("ANTHROPIC_API_KEY") or die("ANTHROPIC_API_KEY required when BRAIN=anthropic")
    body = json.dumps({
        "model": "claude-haiku-4-5-20251001",
        "max_tokens": 1000,
        "system": DECIDE_SYSTEM,
        "messages": [{"role": "user", "content": prompt}],
    }).encode("utf-8")
    req = urllib.request.Request(
        "https://api.anthropic.com/v1/messages",
        data=body,
        headers={"x-api-key": key, "anthropic-version": "2023-06-01", "content-type": "application/json"},
    )
    with urllib.request.urlopen(req, timeout=60) as r:
        data = json.loads(r.read())
    txt = "".join(p["text"] for p in data.get("content", []) if p.get("type") == "text")
    return _coerce_json(txt)


def _decide_openai(prompt: str) -> dict:
    key = os.environ.get("OPENAI_API_KEY") or die("OPENAI_API_KEY required when BRAIN=openai")
    body = json.dumps({
        "model": "gpt-5-mini",
        "messages": [
            {"role": "system", "content": DECIDE_SYSTEM},
            {"role": "user", "content": prompt},
        ],
        "response_format": {"type": "json_object"},
    }).encode("utf-8")
    req = urllib.request.Request(
        "https://api.openai.com/v1/chat/completions",
        data=body,
        headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"},
    )
    with urllib.request.urlopen(req, timeout=60) as r:
        data = json.loads(r.read())
    txt = data["choices"][0]["message"]["content"]
    return _coerce_json(txt)


def _decide_shell(cmd: str, args: list[str], prompt: str) -> dict:
    """Shell out to codex/claude CLI for the decision."""
    import subprocess
    full_prompt = f"{DECIDE_SYSTEM}\n\n{prompt}"
    try:
        proc = subprocess.run(
            [cmd, *args, full_prompt],
            capture_output=True, text=True, timeout=90,
        )
    except FileNotFoundError:
        die(f"`{cmd}` not on PATH — needed when BRAIN={BRAIN}")
    if proc.returncode != 0:
        die(f"{cmd} exited {proc.returncode}: {proc.stderr[:300]}")
    return _coerce_json(proc.stdout)


def _coerce_json(text: str) -> dict:
    import re
    t = text.strip()
    t = re.sub(r"^```(?:json)?\s*", "", t)
    t = re.sub(r"\s*```\s*$", "", t)
    if "{" in t and "}" in t:
        t = t[t.find("{"): t.rfind("}") + 1]
    try:
        return json.loads(t)
    except Exception as e:
        return {"action": "skip", "reason": f"unparseable brain output: {e}: {text[:200]}"}


# --- Sending ---------------------------------------------------------------

def send_email(opp: dict, subject: str, body: str, site: dict) -> tuple[bool, str]:
    """Returns (sent, error_or_log_line)."""
    to_email = opp.get("contact_email")
    if not to_email:
        return False, "no contact_email — agent should not have decided 'send'"
    if not SEND_LIVE:
        return True, f"(dry-run) would send to {to_email}, subject={subject!r}"

    host = os.environ.get("SMTP_HOST") or die("SMTP_HOST required")
    port = int(os.environ.get("SMTP_PORT") or 587)
    user = os.environ.get("SMTP_USERNAME") or die("SMTP_USERNAME required")
    pwd  = os.environ.get("SMTP_PASSWORD") or die("SMTP_PASSWORD required")
    from_ = os.environ.get("SEND_FROM") or user

    msg = EmailMessage()
    msg["From"] = from_
    msg["To"] = to_email
    msg["Subject"] = subject
    signature = (site.get("signature") or "").strip()
    full_body = body if not signature else body.rstrip() + "\n\n" + signature
    msg.set_content(full_body)
    with smtplib.SMTP(host, port, timeout=30) as s:
        s.starttls()
        s.login(user, pwd)
        s.send_message(msg)
    return True, f"sent to {to_email}"


# --- Main loop -------------------------------------------------------------

def main():
    me = _req("GET", "/me")
    print(f"[autopilot] authed as {me['email']} ({me['plan']}, quota {me['daily_quota']}/day)")

    sites = _req("GET", "/sites")["sites"]
    if not sites:
        die("no sites — go to https://www.agentoutreach.io/dashboard/setup first")

    actioned = 0
    # Per-run dedupe: a single contact email should only get one message per
    # run, even if it surfaces under multiple opportunities (e.g. one person
    # who runs three sites the scanner catalogued separately). The set is
    # scoped to this run only — across runs we rely on `status='contacted'`
    # to keep us from re-emailing the same opportunity.
    seen_emails: set[str] = set()

    for site in sites:
        if actioned >= MAX_PER_RUN:
            break
        if site["planner_status"] != "approved":
            continue

        params = f"?status=new&limit={MAX_PER_RUN - actioned}"
        if MIN_GRADE:
            params += f"&min_grade={MIN_GRADE}"
        opps = _req("GET", f"/sites/{site['id']}/opportunities{params}")["opportunities"]

        for opp in opps:
            if actioned >= MAX_PER_RUN:
                break
            try:
                detail = _req("GET", f"/opportunities/{opp['id']}")
            except SystemExit:
                continue
            full = detail["opportunity"]
            site_ctx = detail["site"]
            full["category_label"] = full.get("category_label") or opp.get("category_label", "")

            contact_email = (full.get("contact_email") or "").strip().lower()
            contact_form_url = (full.get("contact_form_url") or "").strip()
            form_fields = full.get("contact_form_fields") or []

            # --- guard 1: dedupe inside this run -------------------------
            if contact_email and contact_email in seen_emails:
                print(f"  · {full['fit_grade']:3s} {full.get('domain'):<32s} → dedupe skip ({contact_email})")
                _req("POST", f"/opportunities/{full['id']}/actions", {
                    "action": "skipped",
                    "reason": f"duplicate contact ({contact_email}) already emailed earlier this run",
                })
                actioned += 1
                continue

            # --- guard 2: form-only opps ---------------------------------
            # Autopilot doesn't submit web forms (would require a headless
            # browser). Default: skip with a structured reason so the user
            # sees it in tomorrow's digest with the form URL + field values
            # they can paste manually.
            if not contact_email and contact_form_url:
                if not SEND_LIVE:
                    print(f"  · {full['fit_grade']:3s} {full.get('domain'):<32s} → form-only (dry-run)")
                else:
                    print(f"  · {full['fit_grade']:3s} {full.get('domain'):<32s} → form-only, queued for manual fill")
                packet = _build_form_packet(form_fields, site_ctx, full.get("draft_subject") or "", full.get("draft_body") or "")
                _req("POST", f"/opportunities/{full['id']}/actions", {
                    "action": "skipped",
                    "reason": f"FORM_FILL_REQUIRED: contact form at {contact_form_url} — {packet}",
                })
                actioned += 1
                continue

            # --- normal decide path --------------------------------------
            try:
                decision = decide(full, site_ctx)
            except Exception as e:
                print(f"[autopilot] brain failed on {full['id']}: {e}; skipping")
                continue

            act = (decision.get("action") or "skip").lower()
            print(f"  · {full['fit_grade']:3s} {full.get('domain'):<32s} → {act}")

            if act == "skip":
                reason = (decision.get("reason") or "agent skipped")[:240]
                _req("POST", f"/opportunities/{full['id']}/actions",
                     {"action": "skipped", "reason": reason})
                actioned += 1
                continue

            if act in ("send", "edit"):
                if not contact_email:
                    # Brain said send but there's no email. Likely indicates a
                    # form-only opp slipped past guard 2 (e.g. malformed data).
                    print(f"     ! no contact_email, marking form-required")
                    _req("POST", f"/opportunities/{full['id']}/actions", {
                        "action": "skipped",
                        "reason": "brain decided 'send' but opportunity has no email — visit the page to submit manually",
                    })
                    actioned += 1
                    continue
                if act == "edit" and AGENT_AUTO_EDIT:
                    subject = (decision.get("edited_subject") or full.get("draft_subject") or "").strip()
                    body    = (decision.get("edited_body")    or full.get("draft_body")    or "").strip()
                else:
                    subject = full.get("draft_subject") or ""
                    body    = full.get("draft_body")    or ""
                if not body.startswith("Hello!"):
                    body = "Hello!\n\n" + body.lstrip()
                ok, log = send_email(full, subject, body, site_ctx)
                print(f"     {log}")
                if not ok:
                    continue
                _req("POST", f"/opportunities/{full['id']}/actions", {
                    "action": "contacted",
                    "sent_subject": subject,
                    "sent_body": body,
                })
                seen_emails.add(contact_email)
                actioned += 1
                time.sleep(0.5)
            else:
                print(f"     unknown action: {act!r}")

    print(f"[autopilot] done — {actioned} opportunities actioned this run.")


def _build_form_packet(fields: list, site: dict, draft_subject: str, draft_body: str) -> str:
    """Build a compact 'fill the form like this' manifest the user can
    paste later. Mirrors the swipe-UI's auto-fill suggestions."""
    if not fields:
        return "(no form fields detected on the page)"
    name = site.get("from_name") or ""
    email = site.get("from_email") or ""
    url = site.get("url") or ""
    parts = []
    for f in fields[:15]:
        label = (f.get("label") or f.get("name") or "field").strip()
        ftype = (f.get("type") or "").lower()
        ll = label.lower()
        if "subject" in ll:
            val = draft_subject
        elif "message" in ll or "body" in ll or "pitch" in ll or "comments" in ll or ftype == "textarea":
            val = draft_body
        elif "name" in ll:
            val = name
        elif "email" in ll:
            val = email
        elif "url" in ll or "website" in ll or "site" in ll:
            val = url
        else:
            val = "(your answer)"
        # Trim long values for the digest line — full draft already in DB
        if len(val) > 100:
            val = val[:97] + "..."
        parts.append(f"{label}={val}")
    return " | ".join(parts)


if __name__ == "__main__":
    main()
