#!/usr/bin/env python3
"""papertype-coda.py: save a Coda (now Superhuman Docs) page, or a whole doc, as a PDF.

Coda's API exports a page's content as Markdown in three HTTP calls, and papertype.dev turns Markdown into a PDF
in one more. No dependencies beyond the Python 3.9+ standard library.
  1. POST https://coda.io/apis/v1/docs/<doc>/pages/<page>/export   {"outputFormat": "markdown"}   -> 202 {id, status, href}
  2. GET  https://coda.io/apis/v1/docs/<doc>/pages/<page>/export/<requestId>  (poll until status is complete -> downloadLink)
  3. GET  <downloadLink>                                              (the Markdown; the link expires after a short time)
  4. POST https://papertype.dev/v1/pdf                                (Markdown -> PDF)
Images in the export point at Coda-hosted URLs; the renderer fetches nothing from the network, so the script downloads
each image and inlines it as a data: URI within a size budget.

Usage
  export CODA_API_TOKEN=...            # Coda API token (Account settings -> API settings)
  export PAPERTYPE_API_KEY=dp_...      # https://papertype.dev/signup
  python3 papertype-coda.py <page browser link> [out.pdf]
  python3 papertype-coda.py --doc <doc id or doc link> [--separate --outdir pdfs]   # every visible page of the doc

Options
  --theme clean|report|minimal|letter   --page-size A4|Letter|Legal|A5   --toc   --no-images   --include-hidden
  --separate       with --doc: one PDF per page instead of one combined PDF
  --url            print a 24-hour download link instead of saving a file (output=url)

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

CODA_API = "https://coda.io/apis/v1"
PAPERTYPE_API = os.environ.get("PAPERTYPE_API", "https://papertype.dev/v1/pdf")
IMAGE_BUDGET = 1_400_000               # bytes of base64 per document; the API accepts 2 MB of source per request
IMAGE_MAX = 900_000                    # bytes per image before encoding; bigger images become links
EXPORT_TIMEOUT = 120                   # seconds to wait for one page export
POLL_SECONDS = 1.5


# ----------------------------------------------------------------------------- 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:            # Coda documents 429 for its per-user rate limits
                time.sleep(min(float(e.headers.get("Retry-After", "3") or 3), 60))
                continue
            return e.code, dict(e.headers), raw
    raise RuntimeError("unreachable")


def coda(path, method="GET", body=None, token=None):
    """Call the Coda API; path is relative to CODA_API (or a full https://coda.io/apis/v1/... href)."""
    token = token or os.environ.get("CODA_API_TOKEN") or sys.exit("CODA_API_TOKEN is not set")
    url = path if path.startswith("https://") else CODA_API + path
    status, headers, raw = _http(url, method, {"Authorization": "Bearer " + token}, body)
    try:
        payload = json.loads(raw.decode() or "{}")
    except ValueError:
        payload = {"message": raw[:200].decode(errors="replace")}
    if status >= 400:
        raise RuntimeError(f"Coda {status} on {method} {path}: {payload.get('statusMessage') or payload.get('message', '')}".strip())
    return payload


def fetch_blob(url):
    """Download an export file or an image from a Coda-hosted link -> (bytes, content-type)."""
    req = urllib.request.Request(url, headers={"User-Agent": "papertype-coda/1.0"})
    with urllib.request.urlopen(req, timeout=60) as r:
        return r.read(), (r.headers.get("Content-Type") or "").split(";")[0].strip()


# ----------------------------------------------------------------------------- locate the page
def resolve(target):
    """A browser link, a doc id, or 'docId/pageId' -> (doc_id, page_id or None)."""
    target = target.strip()
    if target.startswith("http"):
        if "/apis/v1/docs/" not in target:                     # browser link: ask Coda what it points at
            r = coda("/resolveBrowserLink?" + urllib.parse.urlencode({"url": target}))
            target = r["resource"]["href"]
        m = re.search(r"/docs/([^/?#]+)(?:/pages/([^/?#]+))?", target)
        if not m:
            raise ValueError(f"not a doc or page link: {target}")
        return m.group(1), m.group(2)
    if "/" in target:
        doc_id, page_id = target.split("/", 1)
        return doc_id, page_id
    return target, None


def list_pages(doc_id, include_hidden=False):
    """Every canvas page of a doc, in Coda's order, following nextPageToken."""
    pages, token = [], ""
    while True:
        q = {"limit": 100}
        if token:
            q["pageToken"] = token
        r = coda(f"/docs/{doc_id}/pages?" + urllib.parse.urlencode(q))
        pages += [p for p in r.get("items", []) if p.get("type") == "page"]
        token = r.get("nextPageToken")
        if not token:
            break
    if not include_hidden:
        pages = [p for p in pages if not p.get("isEffectivelyHidden")]
    return [p for p in pages if p.get("contentType", "canvas") == "canvas"]


# ----------------------------------------------------------------------------- export
def export_markdown(doc_id, page_id):
    """Begin a Markdown export of one page, poll until it completes, download the result -> str."""
    started = coda(f"/docs/{doc_id}/pages/{page_id}/export", "POST", {"outputFormat": "markdown"})
    status_url = started.get("href") or f"{CODA_API}/docs/{doc_id}/pages/{page_id}/export/{started['id']}"
    deadline = time.time() + EXPORT_TIMEOUT
    while True:
        st = coda(status_url)
        state = (st.get("status") or "").lower()
        if state in ("complete", "completed") and st.get("downloadLink"):
            break
        if state in ("failed", "error") or st.get("error"):
            raise RuntimeError(f"Coda export failed for page {page_id}: {st.get('error') or state}")
        if time.time() > deadline:
            raise RuntimeError(f"Coda export of page {page_id} did not complete within {EXPORT_TIMEOUT} s (status {state!r})")
        time.sleep(POLL_SECONDS)
    data, _ = fetch_blob(st["downloadLink"])
    return data.decode("utf-8", errors="replace")


# ----------------------------------------------------------------------------- markdown fixups
def image_loader(url):
    """Download a Coda-hosted image -> (bytes, mime). Returns (None, None) on failure or if too large."""
    try:
        data, mime = fetch_blob(url)
        if not mime.startswith("image/") or len(data) > IMAGE_MAX:
            return None, None
        return data, mime
    except (urllib.error.URLError, OSError, ValueError):
        return None, None


def inline_images(md, loader):
    """Replace remote image URLs with data: URIs within IMAGE_BUDGET. Images over budget, images that fail to
    download, and every remote image when loader is None (--no-images) become a link line: the API rejects a
    document that still references a remote URL (422 "External resources are disabled")."""
    spent = 0

    def repl(m):
        nonlocal spent
        alt, url = m.group(1), m.group(2)
        if loader is None:
            return f"[{alt or 'image'}]({url})"
        data, mime = loader(url)
        if data is None:
            return f"[{alt or 'image'}]({url})"
        b64 = base64.b64encode(data).decode()
        if spent + len(b64) > IMAGE_BUDGET:
            return f"[{alt or 'image'}]({url})"
        spent += len(b64)
        return f"![{alt}](data:{mime};base64,{b64})"
    return re.sub(r"!\[([^\]]*)\]\((https?://[^)\s]+)\)", repl, md)


def tidy(md):
    """Normalise line endings and whitespace; turn task-list items into ☐/☑ (the renderer prints `[ ]` literally)."""
    md = md.replace("\r\n", "\n").replace("\r", "\n")
    md = "\n".join(line.rstrip() for line in md.split("\n"))
    md = re.sub(r"^(\s*[-*+]\s+)\[[xX]\]\s+", r"\1☑ ", md, flags=re.M)
    md = re.sub(r"^(\s*[-*+]\s+)\[ \]\s+", r"\1☐ ", md, flags=re.M)
    md = re.sub(r"\n{3,}", "\n\n", md)
    return md.strip() + "\n"


def with_heading(md, name):
    """Make sure the page's name is its first heading (Coda keeps the page name out of the content)."""
    first = md.lstrip().split("\n", 1)[0].strip()
    if re.fullmatch(r"#\s+" + re.escape(name.strip()), first):
        return md
    return f"# {name}\n\n{md}"


def demote(md):
    """Push every ATX heading down one level (outside fenced code) so a page's headings sit under its chapter heading."""
    out, fenced = [], False
    for line in md.split("\n"):
        if line.lstrip().startswith(("```", "~~~")):
            fenced = not fenced
        elif not fenced and re.match(r"#{1,5}\s", line):
            line = "#" + line
        out.append(line)
    return "\n".join(out)


PAGE_BREAK = '<div style="page-break-before: always"></div>'


def combine(doc_name, pages_md):
    """One document: the doc name as title, each page a chapter (## name) starting on a new PDF page, so the
    contents page (which lists levels 2-3) names every page."""
    chapters = []
    for i, (name, md) in enumerate(pages_md):
        body = demote(with_heading(md, name)).strip()          # "# name" -> "## name", inner headings one level down
        chapters.append(("" if i == 0 else PAGE_BREAK + "\n\n") + body)
    return f"# {doc_name}\n\n" + "\n\n".join(chapters) + "\n"


# ----------------------------------------------------------------------------- PaperType
def render_pdf(markdown, *, title, api_key=None, theme="clean", page_size="A4", toc=False, filename="document.pdf", output="pdf"):
    api_key = api_key or os.environ.get("PAPERTYPE_API_KEY") or sys.exit("PAPERTYPE_API_KEY is not set")
    body = {"markdown": markdown, "title": title, "theme": theme, "page_size": page_size, "toc": toc,
            "page_numbers": True, "filename": filename, "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 page_markdown(doc_id, page, loader):
    md = tidy(export_markdown(doc_id, page["id"]))
    md = inline_images(md, loader)
    return with_heading(md, page.get("name") or "Untitled page")


def main(argv=None):
    ap = argparse.ArgumentParser(description="Coda page or doc -> PDF via papertype.dev")
    ap.add_argument("target", nargs="?", help="page browser link, or 'docId/pageId'")
    ap.add_argument("out", nargs="?", help="output file (default: <title>.pdf)")
    ap.add_argument("--doc", help="doc id or doc link: export every visible page")
    ap.add_argument("--separate", action="store_true", help="with --doc: one PDF per page")
    ap.add_argument("--include-hidden", action="store_true", help="with --doc: include hidden pages")
    ap.add_argument("--outdir", default=".", help="directory for --separate output")
    ap.add_argument("--theme", default="clean")
    ap.add_argument("--page-size", default="A4")
    ap.add_argument("--toc", action="store_true")
    ap.add_argument("--url", action="store_true", help="print a 24-hour download link instead of saving")
    ap.add_argument("--no-images", action="store_true")
    args = ap.parse_args(argv)
    if not args.target and not args.doc:
        ap.error("give a page link or --doc <doc id or link>")
    loader = None if args.no_images else image_loader
    output = "url" if args.url else "pdf"

    jobs = []                                                   # (title, markdown, filename)
    if args.doc:
        doc_id, _ = resolve(args.doc)
        doc = coda(f"/docs/{doc_id}")
        pages = list_pages(doc_id, args.include_hidden)
        print(f"{len(pages)} page(s) in {doc['name']}", file=sys.stderr)
        if args.separate:
            for p in pages:
                jobs.append((p["name"], page_markdown(doc_id, p, loader), safe_filename(p["name"])))
        else:
            parts = [(p["name"], page_markdown(doc_id, p, loader)) for p in pages]
            jobs.append((doc["name"], combine(doc["name"], parts), safe_filename(doc["name"])))
    else:
        doc_id, page_id = resolve(args.target)
        if not page_id:
            sys.exit("that link points at a doc, not a page; use --doc to export the whole doc")
        page = coda(f"/docs/{doc_id}/pages/{page_id}")
        jobs.append((page["name"], page_markdown(doc_id, page, loader), safe_filename(page["name"])))

    os.makedirs(args.outdir, exist_ok=True)
    for title, md, fname in jobs:
        result = render_pdf(md, title=title, theme=args.theme, page_size=args.page_size, toc=args.toc, filename=fname, output=output)
        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(jobs) == 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) as e:
        sys.exit(str(e))
