#!/usr/bin/env python3
"""papertype-clickup.py: save a ClickUp task as a PDF, every task in a List as one document or one file each, or a ClickUp Doc as a PDF.

ClickUp's API v2 returns a task's fields and, with `include_markdown_description=true`, its description as Markdown; the
Docs API (v3) returns a Doc's pages as Markdown too. The script assembles a Markdown document from those and papertype.dev
turns it into a PDF. No dependencies beyond the Python 3.9+ standard library.
  GET  https://api.clickup.com/api/v2/task/{task_id}?include_markdown_description=true&include_subtasks=true   the task
  GET  https://api.clickup.com/api/v2/task/{task_id}/comment[?start=…&start_id=…]                              comments, 25 a page
  GET  https://api.clickup.com/api/v2/list/{list_id}/task?page=N&include_markdown_description=true&…           tasks in a List, 100 a page
  GET  https://api.clickup.com/api/v3/workspaces/{workspace_id}/docs/{doc_id}                                  a Doc's name
  GET  https://api.clickup.com/api/v3/workspaces/{workspace_id}/docs/{doc_id}/pages?max_page_depth=-1&content_format=text/md
  GET  https://api.clickup.com/api/v2/team                                                                    your Workspaces (only to find the id for --doc)
  GET  <attachment url on *.clickup-attachments.com>                                                          images, inlined as data: URIs
  POST https://papertype.dev/v1/pdf                                                                            Markdown -> PDF
The script only reads from ClickUp.

Usage
  export CLICKUP_API_TOKEN=pk_...        # ClickUp -> avatar -> Settings -> Apps -> API Token
  export PAPERTYPE_API_KEY=dp_...        # https://papertype.dev/signup
  python3 papertype-clickup.py 86d2m1awy [out.pdf]                    # one task: fields, description, checklists, attachments, comments
  python3 papertype-clickup.py https://app.clickup.com/t/86d2m1awy    # the task URL works too
  python3 papertype-clickup.py --list 901234567 --title "Sprint 12" sprint.pdf
                                                                      # one PDF: a contents page, a task table, a section per task
  python3 papertype-clickup.py --list 901234567 --table-only --include-closed tasks.pdf   # just the table
  python3 papertype-clickup.py --list 901234567 --separate --outdir pdfs                  # one file per task
  python3 papertype-clickup.py --doc 8cht190-831 --workspace 9011234080 handbook.pdf      # a Doc: every page, nested, with a contents page
  python3 papertype-clickup.py --doc 8cht190-831 --page 8cht190-271 page.pdf              # one page of a Doc

Options
  --custom-id PROJ-123 --workspace 9011234080   read a task by its custom task id (ClickUp needs the Workspace id for that)
  --include-closed / --subtasks / --archived    for --list: include closed tasks (off by default), subtasks (off), archived tasks (off)
  --comments / --no-comments   comments are on for single tasks and --separate, off for the combined --list document
  --no-images                  do not fetch image attachments; images become links (so do images past 1.2 MB per document)
  --theme clean|report|minimal|letter   --page-size A4|Letter|Legal|A5   --footer "text"
  --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, datetime, html, json, os, re, sys, time, urllib.error, urllib.parse, urllib.request

PAPERTYPE_API = os.environ.get("PAPERTYPE_API", "https://papertype.dev/v1/pdf")
CLICKUP_API = os.environ.get("CLICKUP_API", "https://api.clickup.com/api")
MAX_IMAGE_BYTES = 1_200_000          # image bytes inlined per document (≈1.6 MB as base64, under the API's 2 MB request limit); beyond this, images become links
IMAGE_HOSTS = ("clickup-attachments.com", "clickup.com")   # hosts the token may be sent to; images elsewhere become links
PRIORITY_NAMES = {"1": "Urgent", "2": "High", "3": "Normal", "4": "Low"}   # developer.clickup.com/docs/tasks: 1 Urgent … 4 Low
CONF = {"token": None, "workspace": None}


# ----------------------------------------------------------------------------- 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:            # ClickUp: per-token limit per minute, 429 + X-RateLimit-Reset (Unix seconds)
                reset = e.headers.get("X-RateLimit-Reset")
                wait = 10.0
                if reset:
                    try:
                        wait = float(reset) - time.time()
                    except ValueError:
                        pass
                time.sleep(min(max(wait, 1.0), 65.0))
                continue
            return e.code, dict(e.headers), raw
    raise RuntimeError("unreachable")


class ClickUpError(RuntimeError):
    def __init__(self, status, path, message):
        super().__init__(f"ClickUp {status} on GET {path}: {message}".strip())
        self.status = status


def configure(token=None, workspace=None):
    CONF["token"] = token or os.environ.get("CLICKUP_API_TOKEN")
    CONF["workspace"] = workspace or os.environ.get("CLICKUP_WORKSPACE_ID")
    if not CONF["token"]:
        sys.exit("CLICKUP_API_TOKEN is not set (ClickUp → avatar → Settings → Apps → API Token; it starts with pk_)")


def auth_header():
    # developer.clickup.com/docs/authentication: "Add the token to the header: Authorization: {personal_token}" (no Bearer prefix)
    return {"Authorization": CONF["token"], "Accept": "application/json"}


def clickup(path, params=None):
    """GET {CLICKUP_API}/{path} -> parsed JSON; raises ClickUpError with ClickUp's message and code on a non-200."""
    url = f"{CLICKUP_API}/{path}"
    if params:
        url += "?" + urllib.parse.urlencode({k: v for k, v in params.items() if v is not None}, doseq=True)
    status, _, raw = _http(url, headers=auth_header())
    if status != 200:
        try:
            body = json.loads(raw.decode())
            message = body.get("err") or body.get("error") or body.get("message") or raw[:200].decode(errors="replace")
            if body.get("ECODE"):
                message = f"{message} ({body['ECODE']})"
        except (ValueError, AttributeError):
            message = raw[:200].decode(errors="replace")
        if status == 401 and not message.strip():
            message = "check CLICKUP_API_TOKEN"
        raise ClickUpError(status, "/api/" + path, message)
    return json.loads(raw.decode()) if raw else {}


def fetch_bytes(url):
    """GET an image; the token goes along only to ClickUp's hosts. Returns (content_type, bytes) or raises."""
    headers = {"Authorization": CONF["token"]} if same_site(url) else {}
    status, headers_out, raw = _http(url, headers=headers)
    if status != 200:
        raise ClickUpError(status, url, "attachment not fetched")
    return (headers_out.get("Content-Type") or headers_out.get("content-type") or "").split(";")[0].strip(), raw


def same_site(url):
    host = (urllib.parse.urlsplit(url).hostname or "").lower()
    return any(host == h or host.endswith("." + h) for h in IMAGE_HOSTS)


# ----------------------------------------------------------------------------- reading
def task_id_of(target):
    """'86d2m1awy' or https://app.clickup.com/t/86d2m1awy -> '86d2m1awy'. Returns (task_id, is_url)."""
    if "://" in target:
        parts = urllib.parse.urlsplit(target)
        m = re.search(r"/t/(?:\d+/)?([A-Za-z0-9_-]+)", parts.path)
        if not m:
            raise ValueError(f"not a ClickUp task URL (expected https://app.clickup.com/t/<task id>): {target}")
        return m.group(1), True
    return target.strip(), False


def read_task(task_id, custom=False, comments=True):
    params = {"include_markdown_description": "true", "include_subtasks": "true"}
    if custom:
        if not CONF["workspace"]:
            sys.exit("a custom task id needs the Workspace id: --workspace <id> or CLICKUP_WORKSPACE_ID (ClickUp's reference: custom_task_ids=true&team_id=…)")
        params.update({"custom_task_ids": "true", "team_id": CONF["workspace"]})
    task = clickup(f"v2/task/{urllib.parse.quote(task_id, safe='')}", params)
    task["_comments"] = read_comments(task["id"]) if comments else []
    return task


def read_comments(task_id):
    """All comments, oldest first. ClickUp returns the 25 newest; the next page is start=<date> & start_id=<id> of the last one."""
    out, seen, start, start_id = [], set(), None, None
    for _ in range(200):
        page = clickup(f"v2/task/{task_id}/comment", {"start": start, "start_id": start_id}).get("comments") or []
        fresh = [c for c in page if c.get("id") not in seen]
        if not fresh:
            break
        out.extend(fresh)
        seen.update(c.get("id") for c in fresh)
        last = fresh[-1]
        start, start_id = last.get("date"), last.get("id")
        if len(page) < 25 or not start or not start_id:
            break
    out.reverse()
    return out


def list_tasks(list_id, include_closed=False, subtasks=False, archived=False):
    """Every task whose home List is list_id, 100 a page, until last_page."""
    out = []
    for page in range(1000):
        params = {"page": page, "include_markdown_description": "true", "archived": "true" if archived else "false",
                  "include_closed": "true" if include_closed else None, "subtasks": "true" if subtasks else None}
        data = clickup(f"v2/list/{urllib.parse.quote(str(list_id), safe='')}/task", params)
        out.extend(data.get("tasks") or [])
        if data.get("last_page", True) or not data.get("tasks"):
            break
    return out


def workspace_id():
    """--workspace / CLICKUP_WORKSPACE_ID, else the one Workspace the token can see (GET /v2/team)."""
    if CONF["workspace"]:
        return str(CONF["workspace"])
    teams = clickup("v2/team").get("teams") or []
    if len(teams) == 1:
        CONF["workspace"] = str(teams[0]["id"])
        return CONF["workspace"]
    names = ", ".join(f"{t.get('name')} = {t.get('id')}" for t in teams) or "none visible to this token"
    sys.exit(f"pass --workspace <id> (or CLICKUP_WORKSPACE_ID); the token sees {len(teams)} Workspaces: {names}")


def doc_ref_of(target):
    """'8cht190-831' or https://app.clickup.com/9011234080/v/dc/8cht190-831/8cht190-271 -> (doc_id, workspace_id or None, page_id or None).
    The URL shape is the one ClickUp's app uses in the browser; it is not documented in the API reference, so ids always work too."""
    if "://" not in target:
        return target.strip(), None, None
    parts = urllib.parse.urlsplit(target)
    m = re.search(r"^/(\d+)/v/dc/([A-Za-z0-9_-]+)(?:/([A-Za-z0-9_-]+))?", parts.path)
    if not m:
        raise ValueError(f"not a ClickUp Doc URL I recognise (expected https://app.clickup.com/<workspace>/v/dc/<doc>[/<page>]); pass the ids instead: {target}")
    return m.group(2), m.group(1), m.group(3)


def read_doc(doc_id, page_id=None):
    ws = workspace_id()
    doc = clickup(f"v3/workspaces/{ws}/docs/{urllib.parse.quote(doc_id, safe='')}")
    if page_id:
        page = clickup(f"v3/workspaces/{ws}/docs/{urllib.parse.quote(doc_id, safe='')}/pages/{urllib.parse.quote(page_id, safe='')}", {"content_format": "text/md"})
        pages = [page]
    else:
        pages = clickup(f"v3/workspaces/{ws}/docs/{urllib.parse.quote(doc_id, safe='')}/pages", {"max_page_depth": -1, "content_format": "text/md"})
        if isinstance(pages, dict):                               # tolerate {"pages": [...]} as well as a bare array
            pages = pages.get("pages") or []
    return doc, pages


# ----------------------------------------------------------------------------- formatting
def e(s):
    return html.escape(str(s), quote=True)


def md_escape(s):
    """Text for a Markdown heading: HTML-escaped, Markdown punctuation backslash-escaped so a task named 'Fix *all* bugs' prints literally."""
    return re.sub(r"([\\`*_{}\[\]#+!|~])", r"\\\1", html.escape(str(s), quote=False))


def fmt_ms(v, with_time=True):
    """ClickUp dates are Unix milliseconds as strings ('1567780450202') -> '2019-09-06 14:34 UTC'."""
    if v in (None, "", 0, "0"):
        return ""
    try:
        d = datetime.datetime.fromtimestamp(int(float(v)) / 1000, tz=datetime.timezone.utc)
    except (ValueError, OverflowError, OSError):
        return str(v)
    return d.strftime("%Y-%m-%d %H:%M UTC") if with_time else d.strftime("%Y-%m-%d")


def fmt_duration(ms):
    try:
        total = int(float(ms)) // 60000
    except (TypeError, ValueError):
        return str(ms)
    h, m = divmod(total, 60)
    return f"{h}h {m:02d}m" if h else f"{m}m"


def fmt_number(v):
    try:
        f = float(v)
    except (TypeError, ValueError):
        return str(v)
    return str(int(f)) if f == int(f) else f"{f:g}"


def names(users):
    return ", ".join(str(u.get("username") or u.get("email") or u.get("id")) for u in users or [] if isinstance(u, dict))


def status_text(task):
    s = task.get("status")
    return (s.get("status") if isinstance(s, dict) else s) or ""


def priority_text(task):
    p = task.get("priority")
    if isinstance(p, dict):
        return (p.get("priority") or PRIORITY_NAMES.get(str(p.get("id")), "")).capitalize()
    return PRIORITY_NAMES.get(str(p), "") if p else ""


def custom_field_text(f):
    """One Custom Field's value as text, by type (developer.clickup.com/docs/customfields). '' when not set."""
    v = f.get("value")
    t = f.get("type") or ""
    cfg = f.get("type_config") or {}
    if v in (None, "", [], {}):
        return ""
    if t == "drop_down":
        for o in cfg.get("options") or []:
            if str(o.get("id")) == str(v) or (isinstance(v, (int, float)) or str(v).isdigit()) and str(o.get("orderindex")) == str(v):
                return str(o.get("name") or o.get("label") or v)
        return str(v)
    if t == "labels":
        ids = v if isinstance(v, list) else [v]
        by_id = {str(o.get("id")): str(o.get("label") or o.get("name") or o.get("id")) for o in cfg.get("options") or []}
        return ", ".join(by_id.get(str(i), str(i)) for i in ids)
    if t == "users":
        return names(v if isinstance(v, list) else [v])
    if t == "date":
        return fmt_ms(v, with_time=bool((f.get("value_options") or {}).get("time")))
    if t == "checkbox":
        return "Yes" if str(v).lower() in ("true", "1") else "No"
    if t == "currency":
        code = cfg.get("currency_type") or ""
        return f"{fmt_number(v)} {code}".strip()
    if t == "number":
        return fmt_number(v)
    if t == "emoji":
        return f"{fmt_number(v)} / {cfg.get('count', '?')}"
    if t in ("automatic_progress", "manual_progress") and isinstance(v, dict):
        pct = v.get("percent_complete")
        if pct is None and v.get("current") is not None and v.get("end"):
            try:
                pct = 100.0 * (float(v["current"]) - float(v.get("start") or 0)) / (float(v["end"]) - float(v.get("start") or 0))
            except (ValueError, ZeroDivisionError):
                pct = None
        return f"{fmt_number(round(pct, 1))}%" if pct is not None else json.dumps(v)[:120]
    if t == "tasks" and isinstance(v, list):
        return ", ".join(str(x.get("custom_id") or x.get("name") or x.get("id")) if isinstance(x, dict) else str(x) for x in v)
    if t == "location" and isinstance(v, dict):
        return str(v.get("formatted_address") or json.dumps(v)[:120])
    if isinstance(v, (str, int, float)):
        return str(v)
    if isinstance(v, list) and all(isinstance(x, (str, int, float)) for x in v):
        return ", ".join(str(x) for x in v)
    if f.get("value_markdown"):
        return str(f["value_markdown"])
    return json.dumps(v)[:120]


# ----------------------------------------------------------------------------- Markdown handling
class ImageBudget:
    """Inline images as data: URIs until MAX_IMAGE_BYTES per document; after that (or off-site, or on error) leave a link."""
    def __init__(self, enabled=True):
        self.enabled, self.used, self.inlined, self.linked = enabled, 0, 0, 0

    def data_uri(self, url):
        if not self.enabled or not same_site(url):
            self.linked += 1
            return None
        try:
            ctype, raw = fetch_bytes(url)
        except (ClickUpError, OSError, RuntimeError):
            self.linked += 1
            return None
        if not ctype.startswith("image/"):
            ext = os.path.splitext(urllib.parse.urlsplit(url).path)[1].lower()
            ctype = {".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp", ".svg": "image/svg+xml"}.get(ext, "")
            if not ctype:
                self.linked += 1
                return None
        if self.used + len(raw) > MAX_IMAGE_BYTES:
            self.linked += 1
            return None
        self.used += len(raw)
        self.inlined += 1
        return f"data:{ctype};base64,{base64.b64encode(raw).decode()}"


_MD_IMG = re.compile(r"!\[([^\]]*)\]\(\s*(https?://[^\s)]+)(\s+\"[^\"]*\")?\s*\)")
_HTML_IMG = re.compile(r"<img\b[^>]*?\bsrc\s*=\s*[\"'](https?://[^\"']+)[\"'][^>]*>", re.I)


def inline_images(md, images):
    """Markdown images at web URLs: ClickUp-hosted ones become data: URIs within the budget, everything else a link.
    The renderer never fetches from the web, so a left-over web image would fail the render with 422."""
    def md_sub(m):
        alt, url = m.group(1).strip(), m.group(2)
        uri = images.data_uri(url)
        return f"![{alt}]({uri})" if uri else f"[{alt or 'image'}]({url})"

    def html_sub(m):
        url = m.group(1)
        uri = images.data_uri(url)
        alt = re.search(r"\balt\s*=\s*[\"']([^\"']*)[\"']", m.group(0), re.I)
        text = (alt.group(1).strip() if alt else "") or "image"
        return f'<img src="{uri}" alt="{e(text)}">' if uri else f'<a href="{e(url)}">{e(text)}</a>'
    out = _MD_IMG.sub(md_sub, md or "")
    return _HTML_IMG.sub(html_sub, out)


def shift_headings(md, min_level):
    """Demote the Markdown headings in md so the shallowest one sits at min_level (code fences untouched, capped at 6)."""
    lines, fence, levels = (md or "").split("\n"), None, []
    for ln in lines:
        s = ln.lstrip()
        if fence:
            if s.startswith(fence):
                fence = None
            continue
        if s.startswith("```") or s.startswith("~~~"):
            fence = s[:3]
            continue
        m = re.match(r"^(#{1,6})\s", ln)
        if m:
            levels.append(len(m.group(1)))
    if not levels:
        return md or ""
    shift = max(0, min_level - min(levels))
    if shift == 0:
        return md
    out, fence = [], None
    for ln in lines:
        s = ln.lstrip()
        if fence:
            out.append(ln)
            if s.startswith(fence):
                fence = None
            continue
        if s.startswith("```") or s.startswith("~~~"):
            fence = s[:3]
            out.append(ln)
            continue
        m = re.match(r"^(#{1,6})(\s.*)$", ln)
        out.append("#" * min(6, len(m.group(1)) + shift) + m.group(2) if m else ln)
    return "\n".join(out)


def clean_markdown(md):
    """Drop script/style blocks and on*= handlers that a description could carry as raw HTML."""
    md = re.sub(r"<(script|style)\b.*?</\1\s*>", "", md or "", flags=re.I | re.S)
    return re.sub(r"\son\w+\s*=\s*(\"[^\"]*\"|'[^']*'|[^\s>]+)", "", md, flags=re.I)


def html_block(s):
    """A raw HTML block in Markdown needs blank lines around it."""
    return "\n\n" + s + "\n\n"


def meta_rows(task):
    rows = [("Status", status_text(task)), ("Priority", priority_text(task)), ("Assignees", names(task.get("assignees"))),
            ("Creator", (task.get("creator") or {}).get("username", "") if isinstance(task.get("creator"), dict) else ""),
            ("Created", fmt_ms(task.get("date_created"))), ("Updated", fmt_ms(task.get("date_updated"))), ("Closed", fmt_ms(task.get("date_closed"))),
            ("Start date", fmt_ms(task.get("start_date"))), ("Due date", fmt_ms(task.get("due_date"))),
            ("Time estimate", fmt_duration(task["time_estimate"]) if task.get("time_estimate") else ""),
            ("Time spent", fmt_duration(task["time_spent"]) if task.get("time_spent") else ""),
            ("Points", fmt_number(task["points"]) if task.get("points") is not None else ""),
            ("Tags", ", ".join(str(t.get("name")) for t in task.get("tags") or [] if isinstance(t, dict))),
            ("List", (task.get("list") or {}).get("name", "")), ("Folder", (task.get("folder") or {}).get("name", "") if not (task.get("folder") or {}).get("hidden") else ""),
            ("Custom ID", task.get("custom_id") or ""), ("Parent task", task.get("parent") or ""),
            ("Watchers", names(task.get("watchers")))]
    for f in task.get("custom_fields") or []:
        if isinstance(f, dict):
            rows.append((str(f.get("name") or f.get("id")), custom_field_text(f)))
    return [(k, v) for k, v in rows if v not in ("", None)]


def meta_table(task):
    return "<table class='meta-table'>" + "".join(f"<tr><th>{e(k)}</th><td>{e(v)}</td></tr>" for k, v in meta_rows(task)) + "</table>"


def lead_line(task):
    bits = [status_text(task), priority_text(task)]
    if task.get("assignees"):
        bits.append("Assigned to " + names(task["assignees"]))
    if task.get("due_date"):
        bits.append("Due " + fmt_ms(task["due_date"]))
    return " · ".join(b for b in bits if b)


def task_url(task_or_id):
    if isinstance(task_or_id, dict):
        return task_or_id.get("url") or f"https://app.clickup.com/t/{task_or_id.get('id')}"
    return f"https://app.clickup.com/t/{task_or_id}"


def task_sections(task, images, comments=True, level=1):
    """Markdown for one task. level=1: '# name' with h2 section headings; level=2 (combined document): '## name' with h3 sections.
    The task name is a Markdown heading (so the renderer's contents page can list it); the section headings are raw HTML so they stay out of it."""
    h = "#" * level
    name = task.get("name") or task.get("id")
    anchor = f"<a id='t-{e(task.get('id'))}'></a>"

    def hh(text):
        return html_block(f"<h{level + 1}>{text}</h{level + 1}>")
    parts = [f"{h} {md_escape(name)}", html_block(f"<p class='lead'><strong>{e(lead_line(task))}</strong></p>" + anchor if lead_line(task) else anchor)]
    parts.append(html_block(meta_table(task)))
    desc = task.get("markdown_description")
    if desc is None:
        desc = task.get("text_content") or task.get("description") or ""
    desc = clean_markdown(desc)
    parts.append(hh("Description") + (shift_headings(inline_images(desc, images), level + 2) if desc.strip() else "*No description.*"))
    sub = task.get("subtasks") or []
    if sub:
        parts.append(hh(f"Subtasks ({len(sub)})") + html_block("<ul>" + "".join(
            f"<li><a href='{e(task_url(s))}'>{e(s.get('name') or s.get('id'))}</a> ({e(status_text(s))}{', ' + e(names(s.get('assignees'))) if s.get('assignees') else ''})</li>" for s in sub) + "</ul>"))
    cls = [c for c in task.get("checklists") or [] if isinstance(c, dict)]
    if cls:
        out = []
        for c in cls:
            items = sorted([i for i in c.get("items") or [] if isinstance(i, dict)], key=lambda i: (i.get("orderindex") is None, i.get("orderindex") or 0))
            done = sum(1 for i in items if i.get("resolved"))
            out.append(f"<p><strong>{e(c.get('name') or 'Checklist')}</strong> ({done}/{len(items)})</p><ul class='checklist'>" + "".join(
                f"<li>{'☑' if i.get('resolved') else '☐'} {e(i.get('name') or '')}{' — ' + e(names([i['assignee']])) if isinstance(i.get('assignee'), dict) else ''}</li>" for i in items) + "</ul>")
        parts.append(hh("Checklists") + html_block("".join(out)))
    deps = [d for d in task.get("dependencies") or [] if isinstance(d, dict)]
    links = [l for l in task.get("linked_tasks") or [] if isinstance(l, dict)]
    if deps or links:
        li = []
        for d in deps:
            if str(d.get("task_id")) == str(task.get("id")):
                li.append(f"<li>waits on <a href='{e(task_url(d.get('depends_on')))}'>{e(d.get('depends_on'))}</a></li>")
            else:
                li.append(f"<li><a href='{e(task_url(d.get('task_id')))}'>{e(d.get('task_id'))}</a> waits on this task</li>")
        for l in links:
            other = l.get("link_id") if str(l.get("task_id")) == str(task.get("id")) else l.get("task_id")
            if other:
                li.append(f"<li>linked: <a href='{e(task_url(other))}'>{e(other)}</a></li>")
        parts.append(hh("Dependencies and links") + html_block("<ul>" + "".join(li) + "</ul>"))
    atts = [a for a in task.get("attachments") or [] if isinstance(a, dict) and not a.get("deleted")]
    if atts:
        rows = "".join(f"<tr><td>{e(a.get('title') or a.get('id'))}</td><td>{e(size_text(a.get('size')))}</td><td>{e((a.get('user') or {}).get('username', ''))}</td><td>{e(fmt_ms(a.get('date')))}</td></tr>" for a in atts)
        imgs = ""
        for a in atts:
            if str(a.get("mimetype") or "").startswith("image/") and a.get("url"):
                uri = images.data_uri(a["url"])
                imgs += f"<p><img src='{uri}' alt='{e(a.get('title') or '')}'></p>" if uri else f"<p><a href='{e(a['url'])}'>[image: {e(a.get('title') or a.get('id'))}]</a></p>"
        parts.append(hh(f"Attachments ({len(atts)})") + html_block("<table class='meta-table'><tr><th>File</th><th>Size</th><th>Added by</th><th>When</th></tr>" + rows + "</table>" + imgs))
    if comments and task.get("_comments"):
        cs = task["_comments"]
        out = []
        for c in cs:
            who = (c.get("user") or {}).get("username", "") if isinstance(c.get("user"), dict) else ""
            text = c.get("comment_text") or "".join(str(p.get("text", "")) for p in c.get("comment") or [] if isinstance(p, dict))
            flags = " · resolved" if c.get("resolved") else ""
            out.append(f"<div class='comment'><p class='meta'><strong>{e(who)}</strong> · {e(fmt_ms(c.get('date')))}{flags}</p><p>{e(text).replace(chr(10), '<br>')}</p></div>")
        parts.append(hh(f"Comments ({len(cs)})") + html_block("".join(out)))
    parts.append(html_block(f"<p class='foot'>Task <a href='{e(task_url(task))}'>{e(task.get('custom_id') or task.get('id'))}</a> · exported {datetime.datetime.now(datetime.timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}</p>"))
    return "\n\n".join(parts)


def size_text(n):
    try:
        n = int(n)
    except (TypeError, ValueError):
        return ""
    for unit in ("B", "KB", "MB", "GB"):
        if n < 1024 or unit == "GB":
            return f"{n:.1f} {unit}".replace(".0 ", " ") if unit != "B" else f"{n} B"
        n /= 1024


def task_markdown(task, images, comments=True):
    return task_sections(task, images, comments, level=1)


def list_markdown(tasks, title, images, comments=False, table_only=False, list_name=""):
    """Combined document: title, count, a task table (status, priority, assignees, due, page) and a section per task on a new page.
    The page column is real: each row links to the task's anchor and the CSS uses target-counter(attr(href), page), resolved at layout time."""
    parts = [f"# {md_escape(title)}", html_block(f"<p class='lead'>{len(tasks)} task{'s' if len(tasks) != 1 else ''}" + (f" in <strong>{e(list_name)}</strong>" if list_name else "") + "</p>")]
    pg = "<th>Page</th>" if not table_only else ""
    rows = "".join(f"<tr><td><a href='#t-{e(t.get('id'))}'>{e(t.get('custom_id') or t.get('id'))}</a></td><td>{e(t.get('name') or '')}</td><td>{e(status_text(t))}</td>"
                   f"<td>{e(priority_text(t))}</td><td>{e(names(t.get('assignees')))}</td><td>{e(fmt_ms(t.get('due_date'), with_time=False))}</td>"
                   + (f"<td class='pg'><a class='pg' href='#t-{e(t.get('id'))}'></a></td>" if not table_only else "") + "</tr>" for t in tasks)
    parts.append(html_block("<table class='tasks meta-table'><tr><th>ID</th><th>Task</th><th>Status</th><th>Priority</th><th>Assignees</th><th>Due</th>" + pg + "</tr>" + rows + "</table>"))
    if not table_only:
        for t in tasks:
            parts.append(html_block("<div style='page-break-before: always'></div>"))
            parts.append(task_sections(t, images, comments, level=2))
    return "\n\n".join(parts)


def doc_markdown(doc, pages, images):
    """A Doc: '# name', then every page as a heading at its depth (each further top-level page on a new sheet), content demoted below it.
    With more than one page the request asks for the renderer's contents page (toc: heading levels 2–3 with page numbers)."""
    title = doc.get("name") or doc.get("id") or "Doc"
    parts = [f"# {md_escape(title)}"]
    count = {"n": 0}

    def walk(items, depth):
        for p in items or []:
            if not isinstance(p, dict) or p.get("deleted") or p.get("archived"):
                continue
            count["n"] += 1
            level = min(6, depth + 2)
            if depth == 0 and count["n"] > 1:
                parts.append(html_block("<div style='page-break-before: always'></div>"))
            parts.append("#" * level + " " + md_escape(p.get("name") or "Untitled page"))
            if p.get("sub_title"):
                parts.append(f"*{md_escape(p['sub_title'])}*")
            body = clean_markdown(p.get("content") or "")
            parts.append(shift_headings(inline_images(body, images), level + 1) if body.strip() else "*Empty page.*")
            walk(p.get("pages"), depth + 1)
    walk(pages, 0)
    parts.append(html_block(f"<p class='foot'>Doc {e(doc.get('id') or '')} · {count['n']} page{'s' if count['n'] != 1 else ''} · exported {datetime.datetime.now(datetime.timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}</p>"))
    return "\n\n".join(parts), count["n"]


# ----------------------------------------------------------------------------- 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"


CSS = """
table.meta-table th { text-align: left; white-space: nowrap; padding-right: 1em; vertical-align: top; }
table.tasks td, table.tasks th { font-size: 9pt; }
table.tasks td.pg { text-align: right; }
a.pg::after { content: target-counter(attr(href), page); }
.lead { margin: 0 0 .6em 0; }
.meta { color: #666; font-size: 9pt; margin: 0 0 .3em 0; }
.comment { border-top: 1px solid #ddd; padding-top: .5em; margin-top: .8em; }
.checklist { list-style: none; padding-left: 0; }
.foot { color: #888; font-size: 8.5pt; margin-top: 2em; }
img { max-width: 100%; height: auto; }
"""


def request_for(markdown, title, args, fname, toc=False):
    body = {"markdown": markdown, "css": CSS, "title": title, "theme": args.theme, "page_size": args.page_size,
            "page_numbers": True, "toc": toc, "filename": fname}
    if args.footer:
        body["footer"] = args.footer
    return body


def save_or_print(result, path, title, args):
    if args.url:
        print(f"{title}\t{result['url']}\t{result['pages']} page(s), expires {result['expires_at']}")
        return
    with open(path, "wb") as f:
        f.write(result["pdf"])
    print(f"{path}\t{result['pages']} page(s)")


def main(argv=None):
    ap = argparse.ArgumentParser(description="ClickUp task(s) or Doc -> PDF via papertype.dev")
    ap.add_argument("target", nargs="?", help="task id (86d2m1awy) or task URL; omit with --list / --doc")
    ap.add_argument("out", nargs="?", help="output file (default: <id>-<name>.pdf, <title>.pdf for --list, <doc name>.pdf for --doc)")
    ap.add_argument("--custom-id", help="read a task by its custom task id (needs --workspace or CLICKUP_WORKSPACE_ID)")
    ap.add_argument("--list", dest="list_id", help="render every task in this List (List id from the List's URL or Settings)")
    ap.add_argument("--doc", help="render a Doc (doc id, or the Doc URL from your browser)")
    ap.add_argument("--page", help="with --doc: only this page id")
    ap.add_argument("--workspace", help="Workspace (team) id; needed for --doc and --custom-id unless CLICKUP_WORKSPACE_ID is set")
    ap.add_argument("--title", help="document title for --list (default: the List's name)")
    ap.add_argument("--separate", action="store_true", help="with --list: one PDF per task into --outdir")
    ap.add_argument("--table-only", action="store_true", help="with --list: only the task table, no per-task sections")
    ap.add_argument("--include-closed", action="store_true", help="with --list: include closed tasks (ClickUp excludes them by default)")
    ap.add_argument("--subtasks", action="store_true", help="with --list: include subtasks as rows (excluded by default)")
    ap.add_argument("--archived", action="store_true", help="with --list: archived tasks instead of active ones")
    ap.add_argument("--comments", dest="comments", action="store_true", default=None)
    ap.add_argument("--no-comments", dest="comments", action="store_false")
    ap.add_argument("--no-images", action="store_true", help="never fetch image attachments; images become links")
    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("--footer", default="", help="footer text on every page")
    ap.add_argument("--token", help="ClickUp personal API token (default CLICKUP_API_TOKEN; prefer the environment variable)")
    ap.add_argument("--url", action="store_true", help="print a 24-hour download link instead of saving")
    args = ap.parse_args(argv)
    modes = sum(1 for x in (args.target, args.custom_id, args.list_id, args.doc) if x)
    if args.list_id and args.target and not args.out:                   # `--list 123 out.pdf`: the one positional is the output file
        args.target, args.out = None, args.target
        modes -= 1
    if (args.doc or args.custom_id) and args.target and not args.out:
        args.target, args.out = None, args.target
        modes -= 1
    if modes != 1:
        ap.error("give exactly one of: a task id or URL, --custom-id, --list, --doc")
    output = "url" if args.url else "pdf"
    ws = None
    if args.doc:
        args.doc, ws, page_from_url = doc_ref_of(args.doc)
        args.page = args.page or page_from_url
    configure(args.token, args.workspace or ws)
    images = ImageBudget(enabled=not args.no_images)
    want_comments = args.comments is not False

    if args.target or args.custom_id:                                   # one task
        if args.custom_id:
            task = read_task(args.custom_id, custom=True, comments=want_comments)
        else:
            tid, _ = task_id_of(args.target)
            task = read_task(tid, comments=want_comments)
        title = task.get("name") or task["id"]
        fname = safe_filename(f"{task.get('custom_id') or task['id']} {title}")
        result = render_pdf(request_for(task_markdown(task, images, want_comments), title, args, fname), output=output)
        save_or_print(result, args.out or fname, title, args)
    elif args.doc:                                                       # a Doc
        doc, pages = read_doc(args.doc, args.page)
        md, n = doc_markdown(doc, pages, images)
        title = doc.get("name") or args.doc
        fname = safe_filename(title)
        result = render_pdf(request_for(md, title, args, fname, toc=not args.page and n > 1), output=output)
        save_or_print(result, args.out or fname, title, args)
    else:                                                                # a List
        tasks = list_tasks(args.list_id, args.include_closed, args.subtasks, args.archived)
        if not tasks:
            sys.exit(f"no tasks in List {args.list_id} (closed tasks need --include-closed, archived ones --archived)")
        list_name = (tasks[0].get("list") or {}).get("name", "")
        if args.separate:
            print(f"{len(tasks)} task(s)", file=sys.stderr)
            os.makedirs(args.outdir, exist_ok=True)
            for t in tasks:
                task = read_task(t["id"], comments=want_comments)        # full read: subtasks, checklists, attachments, comments
                title = task.get("name") or task["id"]
                fname = safe_filename(f"{task.get('custom_id') or task['id']} {title}")
                result = render_pdf(request_for(task_markdown(task, ImageBudget(enabled=not args.no_images), want_comments), title, args, fname), output=output)
                save_or_print(result, os.path.join(args.outdir, fname), title, args)
        else:
            if args.comments and not args.table_only:
                for t in tasks:
                    t["_comments"] = read_comments(t["id"])
            title = args.title or list_name or f"List {args.list_id}"
            fname = safe_filename(title)
            md = list_markdown(tasks, title, images, bool(args.comments), args.table_only, list_name)
            result = render_pdf(request_for(md, title, args, fname), output=output)
            save_or_print(result, args.out or fname, title, args)
    if images.linked:
        print(f"note: {images.linked} image(s) could not be fetched from ClickUp and were left as links", file=sys.stderr)


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