#!/usr/bin/env python3
"""papertype-hubspot.py: save a HubSpot deal as a PDF (a deal summary, or an invoice built from its line items).

HubSpot's CRM API returns a deal's properties and the ids of its associated records; a few more reads turn those ids
into names, and papertype.dev turns the result into a PDF. No dependencies beyond the Python 3.9+ standard library.
  GET  https://api.hubapi.com/crm/properties/2026-09/deals                  property labels and option labels
  GET  https://api.hubapi.com/crm/objects/2026-09/deals/{id}?properties=…&associations=companies,contacts,line_items
  GET  https://api.hubapi.com/crm/pipelines/2026-09/deals/{pipelineId}      stage id -> stage label
  GET  https://api.hubapi.com/crm/owners/2026-09/{ownerId}                 owner id -> name
  POST https://api.hubapi.com/crm/objects/2026-09/{line_items|companies|contacts}/batch/read
  POST https://papertype.dev/v1/pdf                                          Markdown (or template + data) -> PDF
The script only reads from HubSpot. Reads that your token's scopes do not allow (403) degrade to raw ids instead of
stopping the run; the deal read itself must succeed.

Usage
  export HUBSPOT_TOKEN=pat-...          # a service key or a private app's static access token (read scopes only)
  export PAPERTYPE_API_KEY=dp_...       # https://papertype.dev/signup
  python3 papertype-hubspot.py <deal id or deal URL> [out.pdf]            # deal summary
  python3 papertype-hubspot.py <deal> --invoice --issuer issuer.json      # invoice from the deal's line items
  python3 papertype-hubspot.py --stage <dealstage id> [--pipeline <id>] --outdir pdfs   # one PDF per deal in a stage

Options
  --properties a,b,c   extra deal properties to include (internal names; the default set is DEAL_PROPERTIES below)
  --theme clean|report|minimal|letter   --page-size A4|Letter|Legal|A5   --toc
  --invoice            render the hosted invoice template instead of a summary (needs --issuer)
  --issuer file.json   {"name", "address_lines": [], "email", "tax_id"} of the party issuing the invoice
  --number INV-1       invoice number (default INV-<deal id>)   --notes "…"   --tax-rate 20
  --url                print a 24-hour download link instead of saving a file (output=url)

MIT License. Copyright (c) 2026 PaperType (papertype.dev).
"""
import argparse, datetime, json, os, re, sys, time, urllib.error, urllib.parse, urllib.request

HUBSPOT_API = "https://api.hubapi.com"
VERSION = "2026-09"                       # the dated API version in every CRM path
PAPERTYPE_API = os.environ.get("PAPERTYPE_API", "https://papertype.dev/v1/pdf")
DEAL_PROPERTIES = ["dealname", "amount", "deal_currency_code", "closedate", "createdate", "dealstage", "pipeline",
                   "hubspot_owner_id", "dealtype", "description"]
LINE_ITEM_PROPERTIES = ["name", "quantity", "price", "amount", "hs_sku", "discount", "hs_discount_percentage", "description"]
COMPANY_PROPERTIES = ["name", "domain", "city", "state", "country"]
CONTACT_PROPERTIES = ["firstname", "lastname", "email", "jobtitle", "phone"]
HIDDEN = {"dealname", "description"}      # shown as title and section, not as table rows


# ----------------------------------------------------------------------------- HTTP
def _http(url, method="GET", headers=None, body=None, retries=3, timeout=60):
    data = json.dumps(body).encode() if body is not None else None
    req = urllib.request.Request(url, data=data, method=method, headers=headers or {})
    if data is not None:
        req.add_header("Content-Type", "application/json")
    for attempt in range(retries + 1):
        try:
            with urllib.request.urlopen(req, timeout=timeout) as r:
                return r.status, dict(r.headers), r.read()
        except urllib.error.HTTPError as e:
            raw = e.read()
            if e.code == 429 and attempt < retries:            # HubSpot: 100 requests per 10 s per app, 250,000 per day
                time.sleep(min(float(e.headers.get("Retry-After", "10") or 10), 60))
                continue
            return e.code, dict(e.headers), raw
    raise RuntimeError("unreachable")


class HubSpotError(RuntimeError):
    def __init__(self, status, method, path, message):
        super().__init__(f"HubSpot {status} on {method} {path}: {message}".strip())
        self.status = status


def hubspot(path, method="GET", body=None, token=None):
    """Call the HubSpot API; path is relative to https://api.hubapi.com. Raises HubSpotError on 4xx/5xx."""
    token = token or os.environ.get("HUBSPOT_TOKEN") or sys.exit("HUBSPOT_TOKEN is not set")
    status, headers, raw = _http(HUBSPOT_API + path, method, {"Authorization": "Bearer " + token}, body)
    try:
        payload = json.loads(raw.decode() or "{}")
    except ValueError:
        payload = {"message": raw[:200].decode(errors="replace")}
    if status >= 400:
        msg = payload.get("message", "")
        if payload.get("category") or payload.get("errorType"):
            msg = f"{payload.get('category') or payload.get('errorType')}: {msg}"
        raise HubSpotError(status, method, path.split("?")[0], msg)
    return payload


def optional(fn, default):
    """Run a lookup whose scope the token may lack; a 403 (or 404) falls back to default instead of stopping."""
    try:
        return fn()
    except HubSpotError as e:
        if e.status in (403, 404):
            print(f"note: {e} (continuing without it)", file=sys.stderr)
            return default
        raise


# ----------------------------------------------------------------------------- locate the deal
def deal_id_of(target):
    """A deal id, or a HubSpot record URL (…/record/0-3/<id> or …/deal/<id>) -> the id as a string."""
    target = target.strip()
    if target.isdigit():
        return target
    m = re.search(r"/(?:record/0-3|deal)/(\d+)", target)
    if not m:
        raise ValueError(f"not a deal id or deal URL: {target}")
    return m.group(1)


def search_deals(stage, pipeline=None, limit=200):
    """Every deal in a stage (optionally within one pipeline), following the search cursor."""
    filters = [{"propertyName": "dealstage", "operator": "EQ", "value": stage}]
    if pipeline:
        filters.append({"propertyName": "pipeline", "operator": "EQ", "value": pipeline})
    ids, after = [], None
    while True:
        body = {"filterGroups": [{"filters": filters}], "properties": ["dealname"], "limit": min(limit, 200),
                "sorts": [{"propertyName": "closedate", "direction": "DESCENDING"}]}
        if after:
            body["after"] = after
        r = hubspot(f"/crm/objects/{VERSION}/deals/search", "POST", body)
        ids += [d["id"] for d in r.get("results", [])]
        after = (r.get("paging") or {}).get("next", {}).get("after")
        if not after:
            return ids


# ----------------------------------------------------------------------------- read the deal and its records
def property_meta(object_type):
    """{internal name: property object} for an object type; labels and enumeration options come from here."""
    r = optional(lambda: hubspot(f"/crm/properties/{VERSION}/{object_type}"), {"results": []})
    return {p["name"]: p for p in r.get("results", [])}


def association_ids(deal, kind):
    """Ids of associated records; the response map keys are object names ('companies', 'line items' or 'line_items')."""
    want = kind.replace("_", "").replace(" ", "")
    for key, coll in (deal.get("associations") or {}).items():
        if key.lower().replace("_", "").replace(" ", "") == want:
            return [str(x["id"]) for x in coll.get("results", [])]
    return []


def batch_read(object_type, ids, properties):
    if not ids:
        return []
    out = []
    for i in range(0, len(ids), 100):                              # batch endpoints take up to 100 inputs
        body = {"inputs": [{"id": x} for x in ids[i:i + 100]], "properties": properties, "propertiesWithHistory": []}
        r = optional(lambda: hubspot(f"/crm/objects/{VERSION}/{object_type}/batch/read", "POST", body), {"results": []})
        out += r.get("results", [])
    return out


def read_deal(deal_id, extra_properties=()):
    props = list(dict.fromkeys(DEAL_PROPERTIES + list(extra_properties)))
    q = urllib.parse.urlencode({"properties": ",".join(props), "associations": "companies,contacts,line_items"})
    deal = hubspot(f"/crm/objects/{VERSION}/deals/{deal_id}?{q}")
    p = deal.get("properties") or {}
    meta = property_meta("deals")
    stage_label = pipeline_label = None
    if p.get("pipeline"):
        pl = optional(lambda: hubspot(f"/crm/pipelines/{VERSION}/deals/{p['pipeline']}"), None)
        if pl:
            pipeline_label = pl.get("label")
            stage_label = next((s.get("label") for s in pl.get("stages", []) if str(s.get("id")) == str(p.get("dealstage"))), None)
    owner = None
    if p.get("hubspot_owner_id"):
        owner = optional(lambda: hubspot(f"/crm/owners/{VERSION}/{p['hubspot_owner_id']}"), None)
    return {
        "id": str(deal.get("id", deal_id)), "properties": p, "order": props, "meta": meta,
        "stage_label": stage_label, "pipeline_label": pipeline_label, "owner": owner,
        "companies": batch_read("companies", association_ids(deal, "companies"), COMPANY_PROPERTIES),
        "contacts": batch_read("contacts", association_ids(deal, "contacts"), CONTACT_PROPERTIES),
        "line_items": batch_read("line_items", association_ids(deal, "line_items"), LINE_ITEM_PROPERTIES),
    }


# ----------------------------------------------------------------------------- formatting
def money(value, currency):
    try:
        v = float(value)
    except (TypeError, ValueError):
        return str(value or "")
    cur = (currency or "").upper()
    sym = {"USD": "$", "EUR": "€", "GBP": "£", "CAD": "CA$", "AUD": "A$", "CHF": "CHF ", "JPY": "¥", "INR": "₹"}.get(cur)
    body = f"{v:,.0f}" if cur == "JPY" else f"{v:,.2f}"
    return f"{sym}{body}" if sym else (f"{body} {cur}".strip())


def date_of(value):
    """HubSpot dates arrive as ISO 8601 strings or epoch milliseconds -> YYYY-MM-DD."""
    s = str(value or "").strip()
    if not s:
        return ""
    if s.isdigit():
        return datetime.datetime.fromtimestamp(int(s) / 1000, datetime.timezone.utc).strftime("%Y-%m-%d")
    return s[:10]


def cell(s):
    """Escape a value for a Markdown table cell."""
    return str(s if s is not None else "").replace("|", "\\|").replace("\n", " ").strip()


def display(name, value, deal):
    """Format one deal property using its metadata (type, options) and the lookups done in read_deal."""
    p, meta = deal["properties"], deal["meta"].get(name, {})
    if name == "dealstage":
        return deal["stage_label"] or str(value)
    if name == "pipeline":
        return deal["pipeline_label"] or str(value)
    if name == "hubspot_owner_id" and deal["owner"]:
        o = deal["owner"]
        nm = " ".join(x for x in (o.get("firstName"), o.get("lastName")) if x).strip()
        return f"{nm} ({o['email']})" if nm and o.get("email") else (nm or o.get("email") or str(value))
    if name == "amount" or meta.get("fieldType") == "number" and "currency" in (meta.get("label") or "").lower():
        return money(value, p.get("deal_currency_code"))
    t = meta.get("type")
    if t in ("date", "datetime") or name in ("closedate", "createdate"):
        return date_of(value)
    if t == "enumeration":
        labels = {str(o.get("value")): o.get("label") for o in meta.get("options", [])}
        return "; ".join(labels.get(v, v) for v in str(value).split(";") if v)
    if t == "bool":
        return "Yes" if str(value).lower() == "true" else "No"
    if t == "number":
        try:
            return f"{float(value):,.2f}".rstrip("0").rstrip(".")
        except ValueError:
            return str(value)
    return str(value)


def label(name, deal):
    return deal["meta"].get(name, {}).get("label") or name


def person(c):
    p = c.get("properties") or {}
    return " ".join(x for x in (p.get("firstname"), p.get("lastname")) if x).strip() or p.get("email") or c.get("id")


def line_items_table(items, currency):
    rows, total = [], 0.0
    for li in items:
        p = li.get("properties") or {}
        name = p.get("name") or "Line item"
        if p.get("hs_sku"):
            name += f" ({p['hs_sku']})"
        try:
            amount = float(p.get("amount")) if p.get("amount") not in (None, "") else float(p.get("quantity") or 0) * float(p.get("price") or 0)
        except ValueError:
            amount = 0.0
        total += amount
        rows.append(f"| {cell(name)} | {cell(p.get('quantity') or '')} | {cell(money(p.get('price'), currency) if p.get('price') not in (None, '') else '')} | {money(amount, currency)} |")
    return ("| Item | Qty | Unit price | Amount |\n|---|---:|---:|---:|\n" + "\n".join(rows)
            + f"\n| **Total** | | | **{money(total, currency)}** |\n"), total


def summary_markdown(deal):
    p, cur = deal["properties"], deal["properties"].get("deal_currency_code")
    title = p.get("dealname") or f"Deal {deal['id']}"
    lead = " · ".join(x for x in (money(p["amount"], cur) if p.get("amount") not in (None, "") else "",
                                  deal["stage_label"] or "", f"Close {date_of(p['closedate'])}" if p.get("closedate") else "") if x)
    out = [f"# {title}", ""]
    if lead:
        out += [f"**{lead}**", ""]
    out += ["## Deal", "", "| Property | Value |", "|---|---|"]
    for name in deal["order"]:
        if name in HIDDEN or p.get(name) in (None, ""):
            continue
        out.append(f"| {cell(label(name, deal))} | {cell(display(name, p[name], deal))} |")
    out.append("")
    if deal["companies"]:
        out += ["## Companies", "", "| Company | Domain | Location |", "|---|---|---|"]
        for c in deal["companies"]:
            cp = c.get("properties") or {}
            loc = ", ".join(x for x in (cp.get("city"), cp.get("state"), cp.get("country")) if x)
            out.append(f"| {cell(cp.get('name') or c.get('id'))} | {cell(cp.get('domain') or '')} | {cell(loc)} |")
        out.append("")
    if deal["contacts"]:
        out += ["## Contacts", "", "| Name | Title | Email | Phone |", "|---|---|---|---|"]
        for c in deal["contacts"]:
            cp = c.get("properties") or {}
            out.append(f"| {cell(person(c))} | {cell(cp.get('jobtitle') or '')} | {cell(cp.get('email') or '')} | {cell(cp.get('phone') or '')} |")
        out.append("")
    if deal["line_items"]:
        table, _ = line_items_table(deal["line_items"], cur)
        out += ["## Line items", "", table]
    if p.get("description"):
        out += ["## Description", "", p["description"].strip(), ""]
    out += [f"_Generated from HubSpot deal {deal['id']} on {datetime.date.today().isoformat()}._", ""]
    return "\n".join(out)


def invoice_data(deal, issuer, number=None, notes=None, tax_rate=None):
    """Data for the hosted `invoice` template (fields at https://papertype.dev/templates/invoice)."""
    p, cur = deal["properties"], (deal["properties"].get("deal_currency_code") or "USD").upper()
    if not deal["line_items"]:
        raise ValueError(f"deal {deal['id']} has no line items; an invoice needs at least one")
    company = (deal["companies"][0].get("properties") if deal["companies"] else None) or {}
    contact = (deal["contacts"][0].get("properties") if deal["contacts"] else None) or {}
    to = {"name": company.get("name") or " ".join(x for x in (contact.get("firstname"), contact.get("lastname")) if x) or p.get("dealname") or f"Deal {deal['id']}"}
    lines = [x for x in (company.get("city"), company.get("state"), company.get("country")) if x]
    if lines:
        to["address_lines"] = [", ".join(lines)]
    if contact.get("email"):
        to["email"] = contact["email"]
    items, discount = [], 0.0
    for li in deal["line_items"]:
        lp = li.get("properties") or {}
        desc = lp.get("name") or "Line item"
        if lp.get("description"):
            desc += f": {lp['description']}"
        items.append({"description": desc, "quantity": float(lp.get("quantity") or 1), "unit_price": float(lp.get("price") or 0)})
        try:
            discount += float(lp.get("discount") or 0)             # HubSpot's per-line flat discount
        except ValueError:
            pass
    data = {"number": number or f"INV-{deal['id']}", "date": datetime.date.today().isoformat(), "currency": cur,
            "from": issuer, "to": to, "items": items}
    if p.get("closedate"):
        data["due_date"] = date_of(p["closedate"])
    if discount:
        data["discount"] = round(discount, 2)
    if tax_rate is not None:
        data["tax_rate"] = tax_rate
    if notes:
        data["notes"] = notes
    return data


# ----------------------------------------------------------------------------- PaperType
def render_pdf(body, *, api_key=None, output="pdf"):
    """POST a /v1/pdf request body -> {"pdf": bytes, "pages": n} or, with output=url, the JSON link response."""
    api_key = api_key or os.environ.get("PAPERTYPE_API_KEY") or sys.exit("PAPERTYPE_API_KEY is not set")
    body = {**body, "output": output}
    status, headers, raw = _http(PAPERTYPE_API, "POST", {"Authorization": "Bearer " + api_key}, body)
    if status != 200:
        try:
            detail = json.loads(raw.decode()).get("detail", raw[:200].decode(errors="replace"))
        except ValueError:
            detail = raw[:200].decode(errors="replace")
        raise RuntimeError(f"PaperType {status}: {detail}")
    if output == "url":
        return json.loads(raw.decode())
    return {"pdf": raw, "pages": headers.get("X-Pages") or headers.get("x-pages")}


def safe_filename(title):
    s = re.sub(r"[^A-Za-z0-9._-]+", "-", title).strip("-.") or "document"
    return s[:80] + ".pdf"


def request_for(deal, args, issuer):
    """The /v1/pdf body for one deal, summary or invoice, plus the file name."""
    title = deal["properties"].get("dealname") or f"Deal {deal['id']}"
    if args.invoice:
        data = invoice_data(deal, issuer, args.number, args.notes, args.tax_rate)
        fname = safe_filename(data["number"])
        return {"template": "invoice", "data": data, "filename": fname}, fname
    fname = safe_filename(title)
    return {"markdown": summary_markdown(deal), "title": title, "theme": args.theme, "page_size": args.page_size,
            "toc": args.toc, "page_numbers": True, "filename": fname}, fname


def main(argv=None):
    ap = argparse.ArgumentParser(description="HubSpot deal -> PDF via papertype.dev")
    ap.add_argument("target", nargs="?", help="deal id or deal URL")
    ap.add_argument("out", nargs="?", help="output file (default: <deal name>.pdf)")
    ap.add_argument("--stage", help="dealstage id: render every deal in that stage (one PDF each)")
    ap.add_argument("--pipeline", help="with --stage: restrict to one pipeline id")
    ap.add_argument("--properties", default="", help="extra deal properties, comma-separated internal names")
    ap.add_argument("--outdir", default=".", help="directory for --stage output")
    ap.add_argument("--theme", default="clean")
    ap.add_argument("--page-size", default="A4")
    ap.add_argument("--toc", action="store_true")
    ap.add_argument("--invoice", action="store_true", help="render the hosted invoice template from the line items")
    ap.add_argument("--issuer", help="JSON file with the issuing party: {name, address_lines, email, tax_id}")
    ap.add_argument("--number", help="invoice number (default INV-<deal id>)")
    ap.add_argument("--notes", help="invoice notes")
    ap.add_argument("--tax-rate", type=float, help="invoice tax rate in percent")
    ap.add_argument("--url", action="store_true", help="print a 24-hour download link instead of saving")
    args = ap.parse_args(argv)
    if not args.target and not args.stage:
        ap.error("give a deal id or URL, or --stage <dealstage id>")
    issuer = None
    if args.invoice:
        if not args.issuer:
            ap.error("--invoice needs --issuer issuer.json ({name, address_lines, email, tax_id})")
        with open(args.issuer) as f:
            issuer = json.load(f)
    extra = [x.strip() for x in args.properties.split(",") if x.strip()]
    output = "url" if args.url else "pdf"

    ids = search_deals(args.stage, args.pipeline) if args.stage else [deal_id_of(args.target)]
    if args.stage:
        print(f"{len(ids)} deal(s) in stage {args.stage}", file=sys.stderr)
    os.makedirs(args.outdir, exist_ok=True)
    for did in ids:
        deal = read_deal(did, extra)
        body, fname = request_for(deal, args, issuer)
        result = render_pdf(body, output=output)
        title = deal["properties"].get("dealname") or f"Deal {did}"
        if args.url:
            print(f"{title}\t{result['url']}\t{result['pages']} page(s), expires {result['expires_at']}")
            continue
        path = args.out if (args.out and len(ids) == 1) else os.path.join(args.outdir, fname)
        with open(path, "wb") as f:
            f.write(result["pdf"])
        print(f"{path}\t{result['pages']} page(s)")


if __name__ == "__main__":
    try:
        main()
    except (RuntimeError, ValueError, OSError) as e:
        sys.exit(str(e))
