#!/usr/bin/env python3
"""papertype-jira.py: save a Jira Cloud issue as a PDF, or every issue a JQL query matches as one document or one file each.

Jira's REST API v3 returns an issue's fields and, with `expand=renderedFields`, the description rendered as HTML;
the comments endpoint does the same with `expand=renderedBody`. The script assembles a document from those and
papertype.dev turns it into a PDF. No dependencies beyond the Python 3.9+ standard library.
  GET  {site}/rest/api/3/issue/{key}?expand=renderedFields,names&fields=…      the issue, HTML of the description
  GET  {site}/rest/api/3/issue/{key}/comment?expand=renderedBody&maxResults=100&startAt=…   comments, HTML bodies
  GET  {site}/rest/api/3/search/jql?jql=…&fields=…&expand=renderedFields,names&maxResults=100&nextPageToken=…
  GET  {site}/rest/api/3/attachment/content/{id}?redirect=false            image attachments, inlined as data: URIs
  POST https://papertype.dev/v1/pdf                                          HTML -> PDF
The script only reads from Jira.

Usage
  export JIRA_SITE=https://your-domain.atlassian.net   # or https://api.atlassian.com/ex/jira/<cloudId> for a scoped token
  export JIRA_EMAIL=me@example.com                     # the Atlassian account the token belongs to
  export JIRA_API_TOKEN=...                            # id.atlassian.com -> Security -> API tokens
  export PAPERTYPE_API_KEY=dp_...                      # https://papertype.dev/signup
  python3 papertype-jira.py PROJ-123 [out.pdf]                       # one issue: fields, description, attachments, comments
  python3 papertype-jira.py https://your-domain.atlassian.net/browse/PROJ-123   # the browser URL works too (site taken from it)
  python3 papertype-jira.py --jql 'fixVersion = 2.4.0 ORDER BY key' --title "Release 2.4.0" release.pdf
                                                                      # one PDF: an issue table with page numbers, then a section per issue
  python3 papertype-jira.py --jql 'sprint in openSprints()' --table-only sprint.pdf      # just the table
  python3 papertype-jira.py --jql 'project = PROJ AND resolved >= -7d' --separate --outdir pdfs   # one file per issue

Options
  --fields customfield_10020,customfield_10016   extra fields to show in the table (ids as in the REST API; labels come from Jira)
  --comments / --no-comments   comments are on for single issues and --separate, off for the combined --jql 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")
FIELDS = ["summary", "issuetype", "status", "priority", "resolution", "assignee", "reporter", "created", "updated",
          "resolutiondate", "duedate", "labels", "components", "fixVersions", "versions", "parent", "subtasks",
          "issuelinks", "attachment", "description", "environment"]
TABLE_ROWS = ["issuetype", "status", "priority", "resolution", "assignee", "reporter", "created", "updated",
              "resolutiondate", "duedate", "labels", "components", "fixVersions", "versions", "parent"]
LIST_COLUMNS = ["issuetype", "status", "priority", "assignee", "resolutiondate"]   # the --jql table, after Key and Summary
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
CONF = {"site": None, "email": None, "token": None}
CSS = """
table.issues td, table.issues th { font-size: 9pt; }
table.issues td.pg { text-align: right; }
a.pg::after { content: target-counter(attr(href), page); }
section.issue { page-break-before: always; }
.meta { color: #666; font-size: 9pt; margin: 0 0 .4em 0; }
.comment { border-top: 1px solid #ddd; padding-top: .5em; margin-top: .8em; }
.foot { color: #888; font-size: 8.5pt; margin-top: 2em; }
img { max-width: 100%; height: auto; }
.panel { border-left: 3px solid #999; padding-left: .8em; }
"""


# ----------------------------------------------------------------------------- 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:            # Jira: points-based limits, answers 429 + Retry-After
                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 JiraError(RuntimeError):
    def __init__(self, status, path, message):
        super().__init__(f"Jira {status} on GET {path}: {message}".strip())
        self.status = status


def configure(site=None, email=None, token=None):
    """Where and as whom to talk to Jira; falls back to JIRA_SITE / JIRA_EMAIL / JIRA_API_TOKEN."""
    CONF["site"] = (site or os.environ.get("JIRA_SITE") or "").rstrip("/")
    CONF["email"] = email or os.environ.get("JIRA_EMAIL")
    CONF["token"] = token or os.environ.get("JIRA_API_TOKEN")


def auth_header():
    if not CONF["email"] or not CONF["token"]:
        sys.exit("JIRA_EMAIL and JIRA_API_TOKEN are not set")
    return {"Authorization": "Basic " + base64.b64encode(f"{CONF['email']}:{CONF['token']}".encode()).decode(),
            "Accept": "application/json"}


def jira(path, params=None):
    """GET {site}/rest/api/3/{path} -> parsed JSON. Raises JiraError on 4xx/5xx with Jira's errorMessages."""
    if not CONF["site"]:
        sys.exit("JIRA_SITE is not set (https://your-domain.atlassian.net) and the target is not a Jira URL")
    url = f"{CONF['site']}/rest/api/3/{path}" + (("?" + urllib.parse.urlencode(params)) if params else "")
    status, headers, raw = _http(url, "GET", auth_header())
    try:
        payload = json.loads(raw.decode() or "{}")
    except ValueError:
        payload = {"errorMessages": [raw[:200].decode(errors="replace")]}
    if status >= 400:
        msgs = list(payload.get("errorMessages") or []) + [f"{k}: {v}" for k, v in (payload.get("errors") or {}).items()]
        raise JiraError(status, "/rest/api/3/" + path.split("?")[0], "; ".join(msgs) or {401: "unauthorized (check JIRA_EMAIL and the token)", 403: "forbidden"}.get(status, ""))
    return payload


def fetch_bytes(url):
    """Authenticated GET of a binary (an attachment) -> (content_type, bytes); None if it cannot be fetched."""
    status, headers, raw = _http(url, "GET", auth_header(), retries=1)
    if status != 200:
        return None
    return (headers.get("Content-Type") or headers.get("content-type") or "").split(";")[0].strip(), raw


# ----------------------------------------------------------------------------- locate issues
KEY_RE = re.compile(r"\b([A-Z][A-Z0-9_]*-\d+)\b")


def issue_key_of(target):
    """'PROJ-123' or any Jira URL that contains an issue key -> (key, site or None)."""
    target = target.strip()
    if KEY_RE.fullmatch(target):
        return target, None
    if target.startswith("http"):
        m = KEY_RE.search(urllib.parse.unquote(target))
        if m:
            u = urllib.parse.urlsplit(target)
            return m.group(1), f"{u.scheme}://{u.netloc}"
    raise ValueError(f"not an issue key or issue URL: {target}")


def search(jql, fields):
    """Every issue matching the JQL (bounded queries only, Jira's rule), following nextPageToken."""
    issues, token = [], None
    while True:
        params = {"jql": jql, "fields": ",".join(fields), "expand": "renderedFields,names", "maxResults": 100}
        if token:
            params["nextPageToken"] = token
        r = jira("search/jql", params)
        for it in r.get("issues", []):
            it.setdefault("names", r.get("names") or {})
            issues.append(it)
        token = r.get("nextPageToken")
        if r.get("isLast") or not token:
            return issues


# ----------------------------------------------------------------------------- read
def read_issue(key, extra_fields=(), comments=True):
    fields = list(dict.fromkeys(FIELDS + list(extra_fields)))
    issue = jira(f"issue/{key}", {"expand": "renderedFields,names", "fields": ",".join(fields)})
    issue["_comments"] = read_comments(issue.get("key", key)) if comments else []
    return issue


def read_comments(key):
    out, start = [], 0
    while True:
        r = jira(f"issue/{key}/comment", {"expand": "renderedBody", "maxResults": 100, "startAt": start, "orderBy": "created"})
        page = r.get("comments") or []
        out += page
        start += len(page)
        if not page or start >= int(r.get("total") or 0):
            return out


# ----------------------------------------------------------------------------- values
def fmt_date(s):
    """Jira's '2022-10-06T07:32:47.000+0000' -> '2022-10-06 07:32 +0000'; a plain date stays as it is."""
    m = re.match(r"^(\d{4}-\d\d-\d\d)(?:T(\d\d:\d\d)(?::\d\d(?:\.\d+)?)?\s*([+-]\d{2}:?\d{2}|Z)?)?$", str(s or "").strip())
    if not m:
        return str(s or "")
    return " ".join(x for x in (m.group(1), m.group(2), m.group(3)) if x)


def value_text(v):
    """A field value of any Jira shape -> display text (users by display name, options by name/value, lists joined)."""
    if v is None or v == "":
        return ""
    if isinstance(v, bool):
        return "Yes" if v else "No"
    if isinstance(v, (int, float)):
        return f"{v:,.2f}".rstrip("0").rstrip(".") if isinstance(v, float) else str(v)
    if isinstance(v, str):
        return fmt_date(v) if re.match(r"^\d{4}-\d\d-\d\dT", v) else v
    if isinstance(v, list):
        return ", ".join(x for x in (value_text(i) for i in v) if x)
    if isinstance(v, dict):
        if v.get("type") == "doc":                              # an ADF document in a custom field
            return re.sub(r"<[^>]+>", " ", adf_html(v)).strip()
        if "displayName" in v:
            return v["displayName"]
        if "key" in v and isinstance(v.get("fields"), dict):     # parent / linked issue
            return f"{v['key']} {value_text(v['fields'].get('summary'))}".strip()
        for k in ("name", "value", "key", "text"):
            if v.get(k) not in (None, ""):
                return str(v[k])
        return json.dumps(v)[:120]
    return str(v)


def label_of(issue, name):
    return (issue.get("names") or {}).get(name) or name


# ----------------------------------------------------------------------------- ADF fallback (when Jira gives no rendered HTML)
def adf_html(node):
    """Atlassian Document Format -> HTML, for the node types Jira issues commonly contain."""
    e = html.escape
    if node is None:
        return ""
    if isinstance(node, list):
        return "".join(adf_html(n) for n in node)
    t, a, kids = node.get("type"), node.get("attrs") or {}, node.get("content") or []
    inner = adf_html(kids)
    if t == "text":
        s = e(node.get("text", ""))
        for m in node.get("marks") or []:
            mt, ma = m.get("type"), m.get("attrs") or {}
            if mt == "strong": s = f"<strong>{s}</strong>"
            elif mt == "em": s = f"<em>{s}</em>"
            elif mt == "code": s = f"<code>{s}</code>"
            elif mt == "strike": s = f"<s>{s}</s>"
            elif mt == "underline": s = f"<u>{s}</u>"
            elif mt == "link": s = f'<a href="{e(ma.get("href", ""))}">{s}</a>'
            elif mt == "subsup": s = f"<{ma.get('type', 'sub')}>{s}</{ma.get('type', 'sub')}>"
        return s
    if t == "paragraph": return f"<p>{inner}</p>"
    if t == "heading":
        lvl = min(max(int(a.get("level", 1)), 1), 6)
        return f"<h{lvl}>{inner}</h{lvl}>"
    if t == "hardBreak": return "<br>"
    if t == "bulletList": return f"<ul>{inner}</ul>"
    if t == "orderedList": return f"<ol>{inner}</ol>"
    if t in ("listItem", "decisionItem"): return f"<li>{inner}</li>"
    if t == "taskList": return f"<ul class='tasks'>{inner}</ul>"
    if t == "taskItem": return f"<li>{'☑' if a.get('state') == 'DONE' else '☐'} {inner}</li>"
    if t == "decisionList": return f"<ul>{inner}</ul>"
    if t == "codeBlock": return f"<pre><code>{e(''.join(k.get('text', '') for k in kids))}</code></pre>"
    if t == "blockquote": return f"<blockquote>{inner}</blockquote>"
    if t == "panel": return f"<blockquote class='panel panel-{e(str(a.get('panelType', 'info')))}'>{inner}</blockquote>"
    if t == "rule": return "<hr>"
    if t == "table": return f"<table>{inner}</table>"
    if t == "tableRow": return f"<tr>{inner}</tr>"
    if t == "tableHeader": return f"<th>{inner}</th>"
    if t == "tableCell": return f"<td>{inner}</td>"
    if t in ("expand", "nestedExpand"): return f"<div class='expand'><p><strong>{e(str(a.get('title', '')))}</strong></p>{inner}</div>"
    if t == "mention": return f"<span class='mention'>{e(str(a.get('text') or '@' + str(a.get('id', ''))))}</span>"
    if t == "emoji": return e(str(a.get("shortName") or a.get("text") or ""))       # the renderer has no colour-emoji font
    if t == "status": return f"<span class='status'>[{e(str(a.get('text', '')))}]</span>"
    if t == "date":
        try:
            return datetime.datetime.fromtimestamp(int(a.get("timestamp")) / 1000, datetime.timezone.utc).strftime("%Y-%m-%d")
        except (TypeError, ValueError):
            return ""
    if t in ("inlineCard", "blockCard", "embedCard"):
        u = str(a.get("url", ""))
        return f'<a href="{e(u)}">{e(u)}</a>'
    if t in ("mediaSingle", "mediaGroup"): return f"<p>{inner}</p>"
    if t in ("media", "mediaInline"):                                                 # ADF media ids are not attachment ids
        return f"<em>[attachment{(' ' + e(str(a['alt']))) if a.get('alt') else ''}]</em>"
    return inner                                                                     # doc, layoutSection, layoutColumn, unknown


# ----------------------------------------------------------------------------- rendered HTML cleanup
IMG_RE = re.compile(r"<img\b[^>]*?\bsrc\s*=\s*([\"'])(.*?)\1[^>]*>", re.I | re.S)
ATTACHMENT_RE = re.compile(r"/rest/api/\d/attachment/content/(\d+)|/secure/attachment/(\d+)/")


class ImageBudget:
    def __init__(self, enabled=True, limit=MAX_IMAGE_BYTES):
        self.enabled, self.left, self.inlined, self.linked = enabled, limit, 0, 0

    def data_uri(self, url):
        """Fetch an image from the Jira site and return a data: URI, or None (then the caller links it)."""
        if not self.enabled or self.left <= 0:
            return None
        if ATTACHMENT_RE.search(url) and "redirect=" not in url:
            url += ("&" if "?" in url else "?") + "redirect=false"            # no 303 to the media store; content comes directly
        got = fetch_bytes(url)
        if not got or not got[0].startswith("image/") or len(got[1]) > self.left:
            return None
        self.left -= len(got[1])
        self.inlined += 1
        return f"data:{got[0]};base64," + base64.b64encode(got[1]).decode()


def absolute(url):
    return urllib.parse.urljoin(CONF["site"] + "/", url) if url and not re.match(r"^(https?:|data:|mailto:|#)", url) else url


def same_site(url):
    return bool(CONF["site"]) and urllib.parse.urlsplit(url).netloc == urllib.parse.urlsplit(CONF["site"]).netloc


def clean_html(fragment, images):
    """Jira's rendered HTML -> safe to send: scripts/styles/handlers removed, links absolute, images inlined or linked."""
    s = re.sub(r"<(script|style)\b.*?</\1>", "", fragment or "", flags=re.I | re.S)
    s = re.sub(r"\son\w+\s*=\s*([\"']).*?\1", "", s, flags=re.I | re.S)
    s = re.sub(r"\b(href|src)=([\"'])/(?!/)", lambda m: f"{m.group(1)}={m.group(2)}{CONF['site']}/", s)

    def img(m):
        url = html.unescape(m.group(2))
        alt = re.search(r"\balt\s*=\s*([\"'])(.*?)\1", m.group(0), re.I | re.S)
        name = html.unescape(alt.group(2)) if alt and alt.group(2) else (urllib.parse.urlsplit(url).path.rsplit("/", 1)[-1] or "image")
        if url.startswith("data:"):
            return m.group(0)
        uri = images.data_uri(url) if same_site(url) else None
        if uri:
            return f'<img src="{uri}" alt="{html.escape(name)}">'
        images.linked += 1
        return f'<a href="{html.escape(url)}">[image: {html.escape(name)}]</a>'
    return IMG_RE.sub(img, s)


def body_html(issue, name, images):
    """Description/environment/comment body: Jira's rendered HTML when present, else converted from ADF."""
    rendered = (issue.get("renderedFields") or {}).get(name)
    if rendered:
        return clean_html(rendered, images)
    raw = (issue.get("fields") or {}).get(name)
    if isinstance(raw, dict) and raw.get("type") == "doc":
        return adf_html(raw)
    return f"<p>{html.escape(str(raw))}</p>" if raw else ""


# ----------------------------------------------------------------------------- document assembly
def e(s):
    return html.escape(str(s if s is not None else ""))


def size_text(n):
    try:
        n = int(n)
    except (TypeError, ValueError):
        return ""
    return f"{n / 1_000_000:.1f} MB" if n >= 1_000_000 else f"{n / 1000:.0f} kB" if n >= 1000 else f"{n} B"


def meta_table(issue, extra_fields=(), rows=TABLE_ROWS):
    f = issue.get("fields") or {}
    out = ["<table class='meta-table'>"]
    for name in list(rows) + [x for x in extra_fields if x not in rows]:
        text = value_text(f.get(name))
        if text:
            out.append(f"<tr><th>{e(label_of(issue, name))}</th><td>{e(text)}</td></tr>")
    out.append("</table>")
    return "\n".join(out)


def lead_line(issue):
    f = issue.get("fields") or {}
    return " · ".join(x for x in (value_text(f.get("issuetype")), value_text(f.get("status")), value_text(f.get("priority")),
                                  ("Assignee " + value_text(f.get("assignee"))) if f.get("assignee") else "") if x)


def issue_sections(issue, images, extra_fields=(), comments=True, heading="h2"):
    """Everything below the title for one issue: lead, table, description, environment, sub-tasks, links, attachments, comments."""
    f, key = issue.get("fields") or {}, issue.get("key", "")
    h = heading
    out = [f"<p class='lead'><strong>{e(lead_line(issue))}</strong></p>", meta_table(issue, extra_fields)]
    desc = body_html(issue, "description", images)
    out += [f"<{h}>Description</{h}>", desc or "<p><em>No description.</em></p>"]
    env = body_html(issue, "environment", images)
    if env:
        out += [f"<{h}>Environment</{h}>", env]
    if f.get("subtasks"):
        out += [f"<{h}>Sub-tasks</{h}>", "<ul>" + "".join(
            f"<li>{e(s.get('key'))} {e(value_text((s.get('fields') or {}).get('summary')))} ({e(value_text((s.get('fields') or {}).get('status')))})</li>"
            for s in f["subtasks"]) + "</ul>"]
    if f.get("issuelinks"):
        items = []
        for l in f["issuelinks"]:
            t = l.get("type") or {}
            if l.get("outwardIssue"):
                items.append(f"{t.get('outward', 'links to')} {value_text(l['outwardIssue'])} ({value_text((l['outwardIssue'].get('fields') or {}).get('status'))})")
            if l.get("inwardIssue"):
                items.append(f"{t.get('inward', 'linked from')} {value_text(l['inwardIssue'])} ({value_text((l['inwardIssue'].get('fields') or {}).get('status'))})")
        out += [f"<{h}>Linked issues</{h}>", "<ul>" + "".join(f"<li>{e(i)}</li>" for i in items) + "</ul>"]
    if f.get("attachment"):
        out += [f"<{h}>Attachments</{h}>", "<table><tr><th>File</th><th>Size</th><th>Added by</th><th>Date</th></tr>" + "".join(
            f"<tr><td>{e(a.get('filename'))}</td><td>{e(size_text(a.get('size')))}</td><td>{e(value_text(a.get('author')))}</td><td>{e(fmt_date(a.get('created')))}</td></tr>"
            for a in f["attachment"]) + "</table>"]
        for a in f["attachment"]:                                        # image attachments are shown, others listed only
            if str(a.get("mimeType", "")).startswith("image/") and a.get("content"):
                uri = images.data_uri(a["content"]) if same_site(a["content"]) else None
                if uri:
                    out.append(f"<p><img src='{uri}' alt='{e(a.get('filename'))}'><br><span class='meta'>{e(a.get('filename'))}</span></p>")
    cs = issue.get("_comments") or []
    if comments and cs:
        out.append(f"<{h}>Comments ({len(cs)})</{h}>")
        for c in cs:
            body = clean_html(c.get("renderedBody"), images) if c.get("renderedBody") else adf_html(c.get("body")) if isinstance(c.get("body"), dict) else e(c.get("body"))
            out.append(f"<div class='comment'><p class='meta'><strong>{e(value_text(c.get('author')))}</strong> · {e(fmt_date(c.get('created')))}"
                       + (f" · edited {e(fmt_date(c.get('updated')))}" if c.get("updated") and c.get("updated") != c.get("created") else "") + f"</p>{body}</div>")
    return "\n".join(out)


def issue_html(issue, images, extra_fields=(), comments=True):
    key, summary = issue.get("key", ""), value_text((issue.get("fields") or {}).get("summary"))
    return "\n".join([f"<h1>{e(key)}: {e(summary)}</h1>", issue_sections(issue, images, extra_fields, comments, "h2"),
                      f"<p class='foot'>Generated from {e(key)} at {e(CONF['site'])} on {datetime.date.today().isoformat()}.</p>"])


def list_html(issues, jql, title, images, extra_fields=(), comments=False, table_only=False):
    cols = [c for c in LIST_COLUMNS + list(extra_fields) if c not in ("summary",)]
    names = issues[0].get("names") if issues else {}
    head = "".join(f"<th>{e((names or {}).get(c) or c)}</th>" for c in cols)
    rows = []
    for it in issues:
        f = it.get("fields") or {}
        cells = "".join(f"<td>{e(value_text(f.get(c)))}</td>" for c in cols)
        link = f"<a href='#{e(it['key'])}'>{e(it['key'])}</a>" if not table_only else e(it["key"])
        pg = f"<td class='pg'><a class='pg' href='#{e(it['key'])}'></a></td>" if not table_only else ""
        rows.append(f"<tr><td>{link}</td><td>{e(value_text(f.get('summary')))}</td>{cells}{pg}</tr>")
    out = [f"<h1>{e(title)}</h1>",
           f"<p class='meta'>{len(issues)} issue{'s' if len(issues) != 1 else ''} · JQL: <code>{e(jql)}</code> · {e(CONF['site'])} · {datetime.date.today().isoformat()}</p>",
           f"<table class='issues'><tr><th>Key</th><th>Summary</th>{head}{'' if table_only else '<th>Page</th>'}</tr>{''.join(rows)}</table>"]
    if not table_only:
        for it in issues:
            out.append(f"<section class='issue' id='{e(it['key'])}'><h2>{e(it['key'])}: {e(value_text((it.get('fields') or {}).get('summary')))}</h2>"
                       + issue_sections(it, images, extra_fields, comments, "h3") + "</section>")
    return "\n".join(out)


# ----------------------------------------------------------------------------- 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(html_body, title, args, fname):
    body = {"html": html_body, "css": CSS, "title": title, "theme": args.theme, "page_size": args.page_size,
            "page_numbers": True, "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="Jira Cloud issue(s) -> PDF via papertype.dev")
    ap.add_argument("target", nargs="?", help="issue key (PROJ-123) or issue URL; omit with --jql")
    ap.add_argument("out", nargs="?", help="output file (default: <key>-<summary>.pdf, or <title>.pdf for --jql)")
    ap.add_argument("--jql", help="render every issue the JQL matches (bounded query, e.g. project = PROJ AND resolved >= -7d)")
    ap.add_argument("--title", help="document title for --jql (default: the JQL)")
    ap.add_argument("--separate", action="store_true", help="with --jql: one PDF per issue into --outdir")
    ap.add_argument("--table-only", action="store_true", help="with --jql: only the issue table, no per-issue sections")
    ap.add_argument("--fields", default="", help="extra field ids to show, comma-separated (customfield_10020,…)")
    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("--site", help="https://your-domain.atlassian.net (default JIRA_SITE, or the host of an issue URL)")
    ap.add_argument("--email", help="Atlassian account email (default JIRA_EMAIL)")
    ap.add_argument("--token", help="API token (default JIRA_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)
    if not args.target and not args.jql:
        ap.error("give an issue key or URL, or --jql '…'")
    if args.jql and args.target and not args.out:                        # `--jql '…' out.pdf`: the one positional is the output file
        args.target, args.out = None, args.target
    extra = [x.strip() for x in args.fields.split(",") if x.strip()]
    output = "url" if args.url else "pdf"
    key = site = None
    if args.target:
        key, site = issue_key_of(args.target)
    configure(args.site or site, args.email, args.token)
    if not CONF["site"]:
        sys.exit("JIRA_SITE is not set (https://your-domain.atlassian.net) and the target is not a Jira URL")
    images = ImageBudget(enabled=not args.no_images)

    if not args.jql:                                                     # one issue
        issue = read_issue(key, extra, comments=args.comments is not False)
        title = f"{issue['key']}: {value_text(issue['fields'].get('summary'))}"
        fname = safe_filename(f"{issue['key']} {value_text(issue['fields'].get('summary'))}")
        result = render_pdf(request_for(issue_html(issue, images, extra, args.comments is not False), title, args, fname), output=output)
        save_or_print(result, args.out or fname, title, args)
    elif args.separate:                                                  # one file per matching issue
        found = search(args.jql, ["key"])
        print(f"{len(found)} issue(s) match", file=sys.stderr)
        os.makedirs(args.outdir, exist_ok=True)
        for it in found:
            issue = read_issue(it["key"], extra, comments=args.comments is not False)
            title = f"{issue['key']}: {value_text(issue['fields'].get('summary'))}"
            fname = safe_filename(f"{issue['key']} {value_text(issue['fields'].get('summary'))}")
            result = render_pdf(request_for(issue_html(issue, ImageBudget(enabled=not args.no_images), extra, args.comments is not False), title, args, fname), output=output)
            save_or_print(result, os.path.join(args.outdir, fname), title, args)
    else:                                                                # one combined document
        fields = list(dict.fromkeys(FIELDS + extra))
        issues = search(args.jql, fields)
        if not issues:
            sys.exit(f"no issues match: {args.jql}")
        if args.comments and not args.table_only:
            for it in issues:
                it["_comments"] = read_comments(it["key"])
        title = args.title or args.jql
        fname = safe_filename(title)
        result = render_pdf(request_for(list_html(issues, args.jql, title, images, extra, bool(args.comments), args.table_only), 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 the Jira site and were left as links", file=sys.stderr)


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