#!/usr/bin/env python3
"""Submit a PDF to a PaperCut Mobility Print queue using only the printer JWT.

This is a focused test tool: no session token, no Microsoft access token, no
cookies.  The only credential sent is

    Authorization: Bearer <printer JWT>

The JWT is the one returned by GET /token on the Mobility Print server (the
"printer JWT" from the capture, valid until 2027-11-10 in that session).

Two IPP submission methods are supported:

  print-job   one Print-Job request carrying the PDF (default; the captured
              server advertises Print-Job in operations-supported)
  create-send create a job, then attach the PDF with Send-Document (the method
              the captured Print Deploy client used)

Both are spoken directly to the Mobility Print IPP endpoint:

    {server}/ipp/{host}_{port}/{hex(printer)}/printers/{printer}

and authenticate only with the JWT.  IPP responses are parsed and the job id,
job URI and job state are reported.

Usage:
    python3 -I print_pdf_jwt.py file.pdf --jwt-file printer.jwt
    python3 -I print_pdf_jwt.py file.pdf --jwt "$(cat printer.jwt)"
    PAPERCUT_PRINTER_JWT=$(cat printer.jwt) python3 -I print_pdf_jwt.py file.pdf

Options:
    --server URL        Mobility Print base (default https://172.25.1.14:9164)
    --printer NAME      queue name (default Follow-Me)
    --method MODE       print-job (default) or create-send
    --printer-uri URI   override the printer-uri IPP attribute
    --requesting-user-name NAME   default "anonymous"
    --job-name NAME     default: the PDF file name
    --verify / --cacert verify TLS (off by default; PaperCut's cert is used)
    --allow-expired     send even when the JWT's exp is in the past

Note: Follow-Me is a pull-printing queue, so a successfully submitted job is
usually held and must be released at the printer; the program reports the job
state returned by the server.
"""

from __future__ import annotations

import argparse
import base64
import getpass
import gzip
import http.client
import json
import os
import re
import socket
import ssl
import sys
import time
from datetime import datetime, timezone
from urllib.parse import quote, urlsplit

DEFAULT_SERVER = "https://172.25.1.14:9164"
DEFAULT_PRINTER = "Follow-Me"
DEFAULT_UA = "Print Deploy Client/1.10.3838/Debian"
DOCUMENT_FORMAT = "application/pdf"

# IPP operation codes
OP_PRINT_JOB = 0x0002
OP_CREATE_JOB = 0x0005
OP_SEND_DOCUMENT = 0x0006

IPP_STATUS = {
    0x0000: "successful-ok",
    0x0001: "successful-ok-ignored-or-substituted-attributes",
    0x0002: "successful-ok-conflicting-attributes",
    0x0400: "client-error-bad-request",
    0x0401: "client-error-forbidden",
    0x0402: "client-error-not-authenticated",
    0x0403: "client-error-not-authorized",
    0x0404: "client-error-not-possible",
    0x0405: "client-error-timeout",
    0x0406: "client-error-not-found",
    0x0407: "client-error-gone",
    0x0408: "client-error-request-entity-too-large",
    0x040A: "client-error-document-format-not-supported",
    0x040B: "client-error-attributes-or-values-not-supported",
    0x0500: "server-error-internal-error",
    0x0501: "server-error-operation-not-supported",
    0x0502: "server-error-service-unavailable",
}

JOB_STATE = {
    3: "pending",
    4: "pending-held",
    5: "processing",
    6: "processing-stopped",
    7: "canceled",
    8: "aborted",
    9: "completed",
}

SUCCESS_STATUS = {0x0000, 0x0001, 0x0002}


class PrintFailed(RuntimeError):
    pass


# ---------------------------------------------------------------------------
# Small helpers
# ---------------------------------------------------------------------------


def mask(value: str, keep: int = 10) -> str:
    if len(value) <= keep + 6:
        return value
    return f"{value[:keep]}...[redacted {len(value)} chars]...{value[-4:]}"


_LONG_RUN = re.compile(r"[A-Za-z0-9_\-\.=+/]{80,}")


def snippet(body: bytes, limit: int = 240) -> str:
    text = body[: limit * 4].decode("utf-8", "replace")
    text = _LONG_RUN.sub("<long-value>", text)
    text = " ".join(text.split())
    return text[:limit] + ("..." if len(text) > limit else "")


def iso_time(epoch) -> str:
    try:
        return datetime.fromtimestamp(int(epoch), tz=timezone.utc).isoformat()
    except (TypeError, ValueError, OverflowError, OSError):
        return str(epoch)


def decode_jwt_payload(token: str) -> dict:
    parts = token.split(".")
    if len(parts) != 3:
        raise PrintFailed("the supplied credential is not a JWT (expected 3 dot-separated parts)")
    part = parts[1] + "=" * (-len(parts[1]) % 4)
    try:
        return json.loads(base64.urlsafe_b64decode(part.encode("ascii")))
    except (ValueError, UnicodeDecodeError) as exc:
        raise PrintFailed(f"the JWT payload cannot be decoded: {exc}") from exc


def load_jwt(args) -> str:
    token = None
    if args.jwt:
        token = sys.stdin.read().strip() if args.jwt == "-" else args.jwt
    elif args.jwt_file:
        if args.jwt_file == "-":
            token = sys.stdin.read().strip()
        else:
            try:
                with open(args.jwt_file, encoding="utf-8") as fh:
                    token = fh.read().strip()
            except OSError as exc:
                raise PrintFailed(f"cannot read {args.jwt_file}: {exc}") from exc
    if not token:
        token = os.environ.get("PAPERCUT_PRINTER_JWT", "").strip()
    if not token:
        try:
            token = getpass.getpass("Printer JWT: ").strip()
        except (EOFError, KeyboardInterrupt):
            raise PrintFailed("no printer JWT supplied") from None
    if token.lower().startswith("bearer "):
        token = token[7:].strip()
    if not token:
        raise PrintFailed("no printer JWT supplied")
    return token


def read_pdf(path: str) -> tuple[bytes, str]:
    try:
        with open(path, "rb") as fh:
            data = fh.read()
    except OSError as exc:
        raise PrintFailed(f"cannot read {path}: {exc}") from exc
    if not data:
        raise PrintFailed(f"{path} is empty")
    if not data.startswith(b"%PDF-"):
        raise PrintFailed(f"{path} does not start with the %PDF- signature")
    return data, os.path.basename(path)


# ---------------------------------------------------------------------------
# IPP request construction
# ---------------------------------------------------------------------------


def _attr(tag: int, name: str, value: bytes) -> bytes:
    encoded = name.encode("utf-8")
    return (
        bytes([tag])
        + len(encoded).to_bytes(2, "big")
        + encoded
        + len(value).to_bytes(2, "big")
        + value
    )


def _text(name: str, value: str) -> bytes:
    return _attr(0x42, name, value.encode("utf-8"))


def _uri(name: str, value: str) -> bytes:
    return _attr(0x45, name, value.encode("utf-8"))


def _charset(value: str) -> bytes:
    return _attr(0x47, "attributes-charset", value.encode("utf-8"))


def _language(value: str) -> bytes:
    return _attr(0x48, "attributes-natural-language", value.encode("utf-8"))


def _format(value: str) -> bytes:
    return _attr(0x49, "document-format", value.encode("utf-8"))


def _integer(name: str, value: int) -> bytes:
    return _attr(0x21, name, int(value).to_bytes(4, "big"))


def _boolean(name: str, value: bool) -> bytes:
    return _attr(0x22, name, b"\x01" if value else b"\x00")


def _build(op: int, request_id: int, attributes: bytes, document: bytes = b"") -> bytes:
    """One operation-attributes group (as the captured client sends)."""
    return (
        b"\x02\x00"  # IPP/2.0
        + op.to_bytes(2, "big")
        + request_id.to_bytes(4, "big")
        + b"\x01"  # operation-attributes-tag
        + attributes
        + b"\x03"  # end-of-attributes-tag
        + document
    )


def build_print_job(printer_uri: str, user: str, job_name: str,
                    doc_format: str, document: bytes) -> bytes:
    attributes = (
        _charset("utf-8")
        + _language("en-us")
        + _uri("printer-uri", printer_uri)
        + _text("requesting-user-name", user)
        + _text("job-name", job_name)
        + _format(doc_format)
    )
    return _build(OP_PRINT_JOB, 1, attributes, document)


def build_create_job(printer_uri: str, user: str, job_name: str) -> bytes:
    attributes = (
        _charset("utf-8")
        + _language("en-us")
        + _uri("printer-uri", printer_uri)
        + _text("requesting-user-name", user)
        + _text("job-name", job_name)
    )
    return _build(OP_CREATE_JOB, 1, attributes)


def build_send_document(printer_uri: str, user: str, job_id: int,
                        doc_format: str, document: bytes) -> bytes:
    attributes = (
        _charset("utf-8")
        + _language("en-us")
        + _uri("printer-uri", printer_uri)
        + _integer("job-id", job_id)
        + _text("requesting-user-name", user)
        + _boolean("last-document", True)
        + _format(doc_format)
    )
    return _build(OP_SEND_DOCUMENT, 2, attributes, document)


# ---------------------------------------------------------------------------
# IPP response parsing
# ---------------------------------------------------------------------------


def parse_attributes(data: bytes) -> dict:
    """Parse top-level attributes after the 8-byte IPP header."""
    attributes: dict[str, list] = {}
    pos = 8
    last_name = None
    while pos < len(data):
        tag = data[pos]
        if tag == 0x03:  # end-of-attributes
            break
        if tag < 0x10:  # group delimiter
            pos += 1
            continue
        if pos + 3 > len(data):
            break
        name_length = int.from_bytes(data[pos + 1 : pos + 3], "big")
        name_bytes = data[pos + 3 : pos + 3 + name_length]
        pos += 3 + name_length
        name = name_bytes.decode("latin-1") if name_bytes else last_name
        if name_bytes:
            last_name = name
        if pos + 2 > len(data):
            break
        value_length = int.from_bytes(data[pos : pos + 2], "big")
        value = data[pos + 2 : pos + 2 + value_length]
        pos += 2 + value_length
        if name:
            attributes.setdefault(name, []).append((tag, value))
    return attributes


def _values(attributes: dict, name: str):
    return [value for _tag, value in attributes.get(name, [])]


def _first_text(attributes: dict, name: str):
    values = _values(attributes, name)
    return values[0].decode("utf-8", "replace") if values else None


def _first_int(attributes: dict, name: str):
    values = _values(attributes, name)
    if not values:
        return None
    return int.from_bytes(values[0], "big")


# ---------------------------------------------------------------------------
# HTTP transport
# ---------------------------------------------------------------------------


def make_ssl_context(verify: bool, cacert):
    if cacert:
        return ssl.create_default_context(cafile=cacert)
    if verify:
        return ssl.create_default_context()
    context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
    context.check_hostname = False
    context.verify_mode = ssl.CERT_NONE
    return context


def http_post(url: str, headers: dict, body: bytes, timeout: float,
              context: ssl.SSLContext):
    parts = urlsplit(url)
    if parts.scheme not in ("http", "https"):
        raise PrintFailed(f"unsupported URL scheme in {url!r}")
    port = parts.port or (443 if parts.scheme == "https" else 80)
    path = parts.path or "/"
    if parts.query:
        path += "?" + parts.query
    if parts.scheme == "https":
        conn = http.client.HTTPSConnection(
            parts.hostname, port, timeout=timeout, context=context
        )
    else:
        conn = http.client.HTTPConnection(parts.hostname, port, timeout=timeout)
    try:
        conn.putrequest("POST", path, skip_accept_encoding=True)
        for name, value in headers.items():
            conn.putheader(name, value)
        conn.putheader("Content-Length", str(len(body)))
        conn.endheaders(body)
        resp = conn.getresponse()
        data = resp.read()
        resp_headers = {k.lower(): v for k, v in resp.getheaders()}
        status, reason = resp.status, resp.reason
    except (OSError, http.client.HTTPException) as exc:
        raise PrintFailed(f"POST {url} failed: {exc}") from exc
    finally:
        conn.close()
    if resp_headers.get("content-encoding", "").lower() == "gzip" and data:
        try:
            data = gzip.decompress(data)
        except OSError:
            pass
    return status, reason, resp_headers, data


def check_ipp(data: bytes, context: str) -> dict:
    if len(data) < 8:
        raise PrintFailed(f"{context}: response shorter than an IPP header ({len(data)} bytes)")
    status = int.from_bytes(data[2:4], "big")
    request_id = int.from_bytes(data[4:8], "big")
    attributes = parse_attributes(data)
    name = IPP_STATUS.get(status, f"unknown-0x{status:04x}")
    if status not in SUCCESS_STATUS:
        detail = _first_text(attributes, "status-message")
        suffix = f" ({detail})" if detail else ""
        raise PrintFailed(
            f"{context}: IPP status 0x{status:04x} {name}{suffix}"
        )
    return {
        "status": status,
        "status_name": name,
        "request_id": request_id,
        "attributes": attributes,
    }


def print_job_details(result: dict):
    attrs = result["attributes"]
    job_id = _first_int(attrs, "job-id")
    job_uri = _first_text(attrs, "job-uri")
    job_state = _first_int(attrs, "job-state")
    if job_id is not None:
        print(f"   job-id: {job_id}")
    if job_uri:
        print(f"   job-uri: {job_uri}")
    if job_state is not None:
        label = JOB_STATE.get(job_state, "unknown")
        print(f"   job-state: {job_state} ({label})")
    return job_id, job_uri, job_state


# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------


def build_parser() -> argparse.ArgumentParser:
    p = argparse.ArgumentParser(
        description="Print a PDF through PaperCut Mobility Print using only the "
        "printer JWT.",
        formatter_class=argparse.ArgumentDefaultsHelpFormatter,
    )
    p.add_argument("pdf", help="PDF file to print")
    p.add_argument("--jwt", help="printer JWT ('-' reads stdin)")
    p.add_argument("--jwt-file", help="file containing the printer JWT ('-' reads stdin)")
    p.add_argument("--server", default=DEFAULT_SERVER,
                   help="Mobility Print base URL")
    p.add_argument("--printer", default=DEFAULT_PRINTER, help="queue name")
    p.add_argument("--method", choices=("print-job", "create-send"),
                   default="print-job", help="IPP submission method")
    p.add_argument("--printer-uri",
                   help="value of the printer-uri IPP attribute (default: derived)")
    p.add_argument("--requesting-user-name", default="anonymous")
    p.add_argument("--job-name", help="job name (default: PDF file name)")
    p.add_argument("--verify", action="store_true",
                   help="verify TLS certificates (off by default)")
    p.add_argument("--cacert", help="CA bundle to trust (implies verification)")
    p.add_argument("--timeout", type=float, default=120.0,
                   help="network timeout in seconds")
    p.add_argument("--allow-expired", action="store_true",
                   help="send even if the JWT's exp has passed")
    return p


def main(argv=None) -> int:
    args = build_parser().parse_args(argv)
    args.server = args.server.rstrip("/")
    try:
        sys.stdout.reconfigure(line_buffering=True)
    except (AttributeError, ValueError):
        pass

    try:
        document, filename = read_pdf(args.pdf)
        token = load_jwt(args)
        claims = decode_jwt_payload(token)
        exp = claims.get("exp")
        if exp is not None:
            if exp < time.time() and not args.allow_expired:
                raise PrintFailed(
                    f"the printer JWT expired at {iso_time(exp)}; use "
                    "--allow-expired to send anyway"
                )
    except PrintFailed as exc:
        print(f"[FAIL] {exc}", file=sys.stderr)
        return 1

    parts = urlsplit(args.server)
    host = parts.hostname
    port = parts.port or (443 if parts.scheme == "https" else 80)
    ipp_path = (
        f"/ipp/{host}_{port}/{args.printer.encode('utf-8').hex()}"
        f"/printers/{quote(args.printer, safe='')}"
    )
    url = args.server + ipp_path
    printer_uri = args.printer_uri or f"http://{host}:{port}{ipp_path}"
    job_name = args.job_name or filename

    print("PaperCut Mobility Print - PDF submission with printer JWT")
    print(f"  JWT:          {mask(token)}")
    claim_bits = []
    if exp is not None:
        claim_bits.append(f"exp={iso_time(exp)}")
    if claims.get("username"):
        claim_bits.append(f"username={claims['username']}")
    if claim_bits:
        print(f"  JWT claims:   {', '.join(claim_bits)}")
    print(f"  server:       {args.server}")
    print(f"  printer:      {args.printer}")
    print(f"  printer-uri:  {printer_uri}")
    print(f"  document:     {args.pdf} ({len(document)} bytes, {DOCUMENT_FORMAT})")
    print(f"  method:       {args.method}")
    print(f"  TLS verify:   {'on' if (args.verify or args.cacert) else 'OFF'}")
    print()

    headers = {
        "User-Agent": DEFAULT_UA,
        "Authorization": f"Bearer {token}",
        "Content-Type": "application/ipp",
        "Accept-Encoding": "gzip",
        "X-Forwarded-For": "127.0.0.1",
    }
    context = make_ssl_context(args.verify, args.cacert)

    try:
        if args.method == "print-job":
            body = build_print_job(
                printer_uri, args.requesting_user_name, job_name,
                DOCUMENT_FORMAT, document,
            )
            print(f"POST {url}  (Print-Job, {len(body)} bytes)")
            status, reason, _headers, data = http_post(
                url, headers, body, args.timeout, context
            )
            print(f"  <- HTTP {status} {reason} ({len(data)} bytes)")
            if status != 200:
                raise PrintFailed(
                    f"HTTP {status} {reason} instead of 200: {snippet(data)}"
                )
            result = check_ipp(data, "Print-Job")
            print(f"  <- IPP status 0x{result['status']:04x} "
                  f"({result['status_name']}), request-id {result['request_id']}")
            print_job_details(result)
            print("\nJob accepted. For Follow-Me queues, release it at the printer.")
        else:
            print(f"POST {url}  (Create-Job)")
            body = build_create_job(
                printer_uri, args.requesting_user_name, job_name
            )
            status, reason, _headers, data = http_post(
                url, headers, body, args.timeout, context
            )
            print(f"  <- HTTP {status} {reason} ({len(data)} bytes)")
            if status != 200:
                raise PrintFailed(
                    f"Create-Job returned HTTP {status} {reason}: {snippet(data)}"
                )
            created = check_ipp(data, "Create-Job")
            print(f"  <- IPP status 0x{created['status']:04x} "
                  f"({created['status_name']})")
            job_id, _job_uri, _state = print_job_details(created)
            if job_id is None:
                raise PrintFailed("Create-Job response did not contain a job-id")

            body = build_send_document(
                printer_uri, args.requesting_user_name, job_id,
                DOCUMENT_FORMAT, document,
            )
            print(f"\nPOST {url}  (Send-Document job-id={job_id}, "
                  f"{len(body)} bytes)")
            status, reason, _headers, data = http_post(
                url, headers, body, args.timeout, context
            )
            print(f"  <- HTTP {status} {reason} ({len(data)} bytes)")
            if status != 200:
                raise PrintFailed(
                    f"Send-Document returned HTTP {status} {reason}: {snippet(data)}"
                )
            sent = check_ipp(data, "Send-Document")
            print(f"  <- IPP status 0x{sent['status']:04x} "
                  f"({sent['status_name']})")
            print("\nDocument sent. For Follow-Me queues, release it at the printer.")
    except PrintFailed as exc:
        print(f"\n[FAIL] {exc}", file=sys.stderr)
        return 1

    print("Done.")
    return 0


if __name__ == "__main__":
    try:
        raise SystemExit(main())
    except KeyboardInterrupt:
        print("\nInterrupted.", file=sys.stderr)
        raise SystemExit(130)
