#!/usr/bin/env python3
"""Acquire the Printer JWT and the full per-user queue URL, and save them as YAML.

This is the minimal credential-acquisition path from PROTOCOL1 and PROTOCOL2.
It performs only the steps that produce the two values a Mobility Print client
needs to print:

  POST /deploy/oauth/url                       start Microsoft sign-in
  GET  localhost:9176/oauth2/microsoft/callback  browser -> authorization code
  POST /deploy/oauth/user                      code -> session + Microsoft tokens
  POST /deploy/printers                        printer list -> Mobility base URL
  GET  {mobility}/token?printerName=...        Basic upn:access_token -> JWT
  GET  {mobility}/printer-url?...              Bearer JWT -> full queue URL

The observational steps of the real client (branding, translations, the
repeated config fetch, the legacy /printer-urls probes, the token refresh and
the driver downloads) are deliberately not performed.  /deploy/config is only
fetched as a fallback when /deploy/printers does not advertise a
`connection.name`.

The output file is a flat YAML document intended for a loopback IPP relay
started by systemd (PROTOCOL3 option C).  It carries everything the relay
needs so that the relay never has to derive the upstream target from the
incoming CUPS request URL:

  printer_jwt       Authorization: Bearer value sent upstream
  ipp_endpoint      full upstream URL the relay POSTs to
  printer_uri       canonical IPP printer-uri attribute value
  queue_url         per-user queue URL returned by /printer-url
  mobility_base     Mobility Print base URL (from connection.name)
  printer, username, jwt_expires_*: for diagnostics and policy

The file contains a bearer credential and is written atomically with mode
0600.  Keep it that way; do not put it in a world-readable directory.

Usage:
    python3 -I acquire_queue_info.py --out queue.yaml
    python3 -I acquire_queue_info.py --out /etc/papercut/queue.yaml \
        --server https://printdeploy.example:9174 --printer Follow-Me
    python3 -I acquire_queue_info.py --out queue.yaml --os-name darwin

The sign-in needs a browser on the same host as the callback listener.  The
captured client reported os.name "darwin" while running on Debian; pass
--os-name/--os-version/--arch to reproduce a specific printer selection.

TLS certificates are not verified by default (the captured client works against
a self-signed PaperCut certificate); pass --verify or --cacert to enable checks.
"""

from __future__ import annotations

import argparse
import base64
import getpass
import gzip
import http.client
import json
import os
import platform
import re
import secrets
import socket
import ssl
import sys
import tempfile
import threading
import time
import webbrowser
from dataclasses import dataclass, field
from datetime import datetime, timezone
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs, quote, urlencode, urlsplit

CLIENT_VERSION = "1.10.3838"
DEFAULT_SERVER = "https://printdeploy.asg.nl:9174"
DEFAULT_PRINTER = "Follow-Me"
DEFAULT_USER_AGENT = f"Print Deploy Client/{CLIENT_VERSION}/Debian"
CALLBACK_PATH = "/oauth2/microsoft/callback"
CONFIG_VERSION = 1


class AcquireFailed(RuntimeError):
    """A request or response did not meet the protocol expectations."""


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


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


def mask_url(url: str) -> str:
    """Show the queue URL shape without its per-user secret parts."""
    parts = urlsplit(url)
    segments = parts.path.split("/")
    if "users" in segments:
        first = segments.index("users") + 1
        segments = segments[:first] + ["<redacted>"] * (len(segments) - first)
    elif len(segments) > 1 and segments[-1]:
        segments[-1] = "<redacted>"
    return parts._replace(path="/".join(segments)).geturl()


_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 human_time(epoch) -> str:
    try:
        return datetime.fromtimestamp(int(epoch), tz=timezone.utc).isoformat()
    except (TypeError, ValueError, OverflowError, OSError):
        return str(epoch)


def b64url_json(part: str):
    part += "=" * (-len(part) % 4)
    return json.loads(base64.urlsafe_b64decode(part.encode("ascii")))


def describe_jwt(token: str) -> dict:
    """Decode a JWT payload without verifying it."""
    parts = token.split(".")
    if len(parts) != 3:
        raise AcquireFailed("the server returned a value that is not a JWT")
    try:
        header = b64url_json(parts[0])
        payload = b64url_json(parts[1])
    except (ValueError, UnicodeDecodeError) as exc:
        raise AcquireFailed(f"the printer JWT cannot be decoded: {exc}") from exc
    if not isinstance(header, dict) or not isinstance(payload, dict):
        raise AcquireFailed("the printer JWT header/payload is not a JSON object")
    exp = payload.get("exp")
    if not isinstance(exp, (int, float)):
        raise AcquireFailed("the printer JWT has no numeric 'exp' claim")
    if exp < time.time():
        raise AcquireFailed(
            f"the server returned an already expired printer JWT "
            f"(exp {human_time(exp)})"
        )
    return {
        "header": header,
        "payload": payload,
        "exp": int(exp),
        "username": payload.get("username"),
    }


# ---------------------------------------------------------------------------
# Machine fingerprint (same shape as the observed client)
# ---------------------------------------------------------------------------


def build_machine_info(args) -> dict:
    try:
        default_user = getpass.getuser()
    except (KeyError, OSError):
        default_user = "unknown"
    username = args.machine_user or default_user
    hostname = args.hostname or socket.gethostname()
    os_name = args.os_name or platform.system().lower()
    os_version = args.os_version or _distro_version() or platform.release()
    arch = args.arch or _normalize_arch(platform.machine())
    return {
        "version": CLIENT_VERSION,
        "printingSystem": "CUPS",
        "loggedInUser": {"username": username, "type": "LocalMachine"},
        "machine": {
            "hostname": hostname,
            "os": {
                "name": os_name,
                "osName": _distro_name() or platform.system(),
                "version": os_version,
                "arch": arch,
            },
            "ipAddresses": _local_ip_addresses(),
            "activeDirectoryDomainName": "",
        },
    }


def _distro_field(field_name: str):
    try:
        with open("/etc/os-release", encoding="utf-8") as fh:
            for line in fh:
                key, _, value = line.strip().partition("=")
                if key == field_name:
                    return value.strip().strip('"')
    except OSError:
        pass
    return None


def _distro_name():
    name = _distro_field("NAME")
    return re.sub(r"\s+GNU/Linux$", "", name) if name else None


def _distro_version():
    return _distro_field("VERSION_ID")


def _normalize_arch(machine: str) -> str:
    return {
        "x86_64": "amd64",
        "aarch64": "arm64",
        "arm64": "arm64",
        "i686": "386",
        "i386": "386",
    }.get(machine, machine)


def _local_ip_addresses() -> list:
    addresses = set()
    try:
        for info in socket.getaddrinfo(socket.gethostname(), None):
            ip = info[4][0]
            if ":" not in ip and not ip.startswith("127."):
                addresses.add(ip)
    except OSError:
        pass
    if not addresses:
        # UDP connect only selects a route; it sends no packets.
        sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
        try:
            sock.connect(("192.0.2.1", 9))
            ip = sock.getsockname()[0]
            if ip and not ip.startswith("127."):
                addresses.add(ip)
        except OSError:
            pass
        finally:
            sock.close()
    return sorted(addresses)


# ---------------------------------------------------------------------------
# HTTP
# ---------------------------------------------------------------------------


@dataclass
class Response:
    url: str
    status: int
    reason: str
    headers: dict
    body: bytes

    def text(self) -> str:
        return self.body.decode("utf-8", "replace")

    def json(self):
        return json.loads(self.body)


class HttpClient:
    def __init__(self, timeout: float, verify: bool, cacert, verbose: bool,
                 user_agent: str):
        if cacert:
            self.context = ssl.create_default_context(cafile=cacert)
        elif verify:
            self.context = ssl.create_default_context()
        else:
            self.context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
            self.context.check_hostname = False
            self.context.verify_mode = ssl.CERT_NONE
        self.timeout = timeout
        self.verbose = verbose
        self.user_agent = user_agent

    def request(self, method: str, url: str, headers=None, body=None):
        parts = urlsplit(url)
        if parts.scheme not in ("http", "https"):
            raise AcquireFailed(f"unsupported URL scheme in {url!r}")
        port = parts.port or (443 if parts.scheme == "https" else 80)
        if parts.scheme == "https":
            conn = http.client.HTTPSConnection(
                parts.hostname, port, timeout=self.timeout, context=self.context
            )
        else:
            conn = http.client.HTTPConnection(
                parts.hostname, port, timeout=self.timeout
            )
        path = parts.path or "/"
        if parts.query:
            path += "?" + parts.query

        sent = {"User-Agent": self.user_agent, "Accept-Encoding": "gzip"}
        sent.update(headers or {})
        print(f"    -> {method} {url}")
        if self.verbose:
            for name, value in sent.items():
                shown = mask(value) if name.lower() in (
                    "authorization",
                    "proxy-authorization",
                    "cookie",
                ) else value
                print(f"       {name}: {shown}")
            if body is not None:
                print(f"       body: {len(body)} bytes {snippet(body, 160)}")

        try:
            conn.putrequest(method, path, skip_accept_encoding=True)
            for name, value in sent.items():
                conn.putheader(name, value)
            if body is not None:
                conn.putheader("Content-Length", str(len(body)))
            conn.endheaders(body if body else None)
            raw = conn.getresponse()
            data = raw.read()
            response_headers = {k.lower(): v for k, v in raw.getheaders()}
            status, reason = raw.status, raw.reason
        except (OSError, http.client.HTTPException) as exc:
            raise AcquireFailed(f"{method} {url} failed: {exc}") from exc
        finally:
            conn.close()

        if response_headers.get("content-encoding", "").lower() == "gzip" and data:
            try:
                data = gzip.decompress(data)
            except OSError:
                pass
        print(f"    <- HTTP {status} {reason} ({len(data)} bytes)")
        return Response(url, status, reason, response_headers, data)


def expect_json(resp: Response, status: int = 200):
    if resp.status != status:
        raise AcquireFailed(
            f"expected HTTP {status}, got {resp.status} {resp.reason}: "
            f"{snippet(resp.body)}"
        )
    try:
        return resp.json()
    except ValueError as exc:
        raise AcquireFailed(
            f"response is not JSON: {snippet(resp.body)}"
        ) from exc


# ---------------------------------------------------------------------------
# OAuth loopback callback
# ---------------------------------------------------------------------------


class _CallbackHandler(BaseHTTPRequestHandler):
    server_version = "AcquireQueueInfo/1.0"

    def log_message(self, *args):  # keep the output readable
        pass

    def do_GET(self):
        parsed = urlsplit(self.path)
        if parsed.path != CALLBACK_PATH:
            self.send_error(404)
            return
        params = parse_qs(parsed.query)
        result = self.server.result
        for key in ("code", "state", "error", "error_description"):
            if key in params:
                result[key] = params[key][0]
        message = (
            "Sign-in complete. You can close this tab and return to the terminal."
            if "code" in result
            else "Sign-in failed. Check the terminal for details."
        )
        page = f"<html><body><h2>{message}</h2></body></html>".encode()
        self.send_response(200)
        self.send_header("Content-Type", "text/html; charset=utf-8")
        self.send_header("Content-Length", str(len(page)))
        self.end_headers()
        self.wfile.write(page)
        self.server.got_response.set()


class _CallbackServer(ThreadingHTTPServer):
    daemon_threads = True
    allow_reuse_address = True

    def server_bind(self):
        if self.address_family == socket.AF_INET6:
            try:
                self.socket.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 0)
            except OSError:
                pass
        super().server_bind()


def _make_callback_server(port: int) -> _CallbackServer:
    attempts = []
    if socket.has_ipv6:
        attempts.append((socket.AF_INET6, "::"))
    attempts.append((socket.AF_INET, "127.0.0.1"))
    last_error = None
    for family, host in attempts:
        class Server(_CallbackServer):
            address_family = family

        try:
            return Server((host, port), _CallbackHandler)
        except OSError as exc:
            last_error = exc
    raise AcquireFailed(
        f"cannot listen on port {port} for the OAuth callback ({last_error}). "
        "Close whatever is using it or pass --callback-port."
    )


def wait_for_oauth_callback(port: int, auth_url: str, expected_state: str,
                            timeout: float, open_browser: bool) -> str:
    httpd = _make_callback_server(port)
    httpd.result = {}
    httpd.got_response = threading.Event()
    httpd.timeout = 1

    print(f"    listening on {CALLBACK_PATH} at localhost:{port}")
    print("\n  Open this URL and sign in with the account that has printer access:\n")
    print(f"    {auth_url}\n")
    if open_browser:
        try:
            if webbrowser.open(auth_url):
                print("    (a browser window was opened)")
            else:
                print("    (could not open a browser automatically)")
        except Exception:
            print("    (could not open a browser automatically)")

    deadline = time.monotonic() + timeout
    print(f"    waiting up to {int(timeout)}s for the redirect ...")
    try:
        while not httpd.got_response.is_set() and time.monotonic() < deadline:
            httpd.handle_request()
    finally:
        httpd.server_close()

    if not httpd.got_response.is_set():
        raise AcquireFailed(
            f"no OAuth callback arrived within {int(timeout)}s; "
            "the sign-in page may have been closed or consent was declined"
        )

    result = httpd.result
    if result.get("error"):
        detail = result.get("error_description") or result.get("error")
        raise AcquireFailed(f"the identity provider returned an error: {detail}")
    code = result.get("code")
    if not code:
        raise AcquireFailed("the callback did not contain an authorization code")
    if result.get("state") != expected_state:
        raise AcquireFailed(
            "OAuth state mismatch: the callback state does not match the value "
            "sent to /deploy/oauth/url; aborting to avoid a forged redirect"
        )
    print(f"    <- authorization code received ({len(code)} chars), state matched")
    return code


# ---------------------------------------------------------------------------
# Session and protocol steps
# ---------------------------------------------------------------------------


@dataclass
class Session:
    args: argparse.Namespace
    http: HttpClient
    username: str = ""
    session_token: str = ""
    access_token: str = ""
    state: str = ""
    auth_url: str = ""
    auth_code: str = ""
    printer: dict = field(default_factory=dict)
    mobility: str = ""
    job_token: str = ""
    queue_url: str = ""

    @property
    def server(self) -> str:
        return self.args.server.rstrip("/")

    @property
    def provider(self) -> str:
        return self.args.provider

    def post_json(self, url: str, payload, headers=None):
        body = json.dumps(payload, separators=(",", ":")).encode()
        return self.http.request("POST", url, headers=headers, body=body)


def step_oauth_url(s: Session):
    print("\n[1/6] Start the Microsoft sign-in (POST /deploy/oauth/url)")
    s.state = secrets.token_hex(16)
    resp = s.post_json(
        f"{s.server}/deploy/oauth/url",
        {"providerId": s.provider, "state": s.state},
    )
    data = expect_json(resp, 200)
    auth_url = data.get("authURL")
    if not auth_url:
        raise AcquireFailed("response did not contain authURL")
    query = parse_qs(urlsplit(auth_url).query)
    returned_state = (query.get("state") or [""])[0]
    if returned_state != s.state:
        raise AcquireFailed(
            "the authURL state does not match the state sent to /deploy/oauth/url"
        )
    redirect = (query.get("redirect_uri") or [""])[0]
    expected_redirect = f"http://localhost:{s.args.callback_port}{CALLBACK_PATH}"
    if redirect != expected_redirect:
        raise AcquireFailed(
            f"authURL redirects to {redirect!r}, but this program listens on "
            f"{expected_redirect!r}; the instance's OAuth app is configured for "
            "a different callback port, so the code cannot be captured"
        )
    s.auth_url = auth_url


def step_login(s: Session):
    print("\n[2/6] Sign in with Microsoft in a browser")
    s.auth_code = wait_for_oauth_callback(
        s.args.callback_port,
        s.auth_url,
        s.state,
        s.args.login_timeout,
        not s.args.no_browser,
    )


def step_oauth_user(s: Session):
    print("\n[3/6] Exchange the authorization code (POST /deploy/oauth/user)")
    if not s.auth_code:
        raise AcquireFailed("no authorization code is available")
    resp = s.post_json(
        f"{s.server}/deploy/oauth/user",
        {"providerId": s.provider, "code": s.auth_code},
    )
    data = expect_json(resp, 200)
    for key in ("username", "sessionToken", "token"):
        if key not in data:
            raise AcquireFailed(f"response is missing {key!r}")
    token = data["token"]
    if not isinstance(token, dict) or not token.get("access_token"):
        raise AcquireFailed("response did not contain token.access_token")
    s.username = data["username"]
    s.session_token = data["sessionToken"]
    s.access_token = token["access_token"]
    s.auth_code = ""  # single use: do not keep it around
    print(f"    username = {s.username}")
    print(f"    session token = {mask(s.session_token)}")
    print(f"    access token = {mask(s.access_token)}")


def step_printers(s: Session):
    print("\n[4/6] List printers (POST /deploy/printers)")
    info = build_machine_info(s.args)
    body = json.dumps(info, separators=(",", ":")).encode()
    resp = s.http.request(
        "POST",
        f"{s.server}/deploy/printers",
        headers={
            "Authorization": f"Bearer {s.session_token}",
            "printdeployauthenticationtype": s.provider,
        },
        body=body,
    )
    data = expect_json(resp, 200)
    printers = data.get("printers")
    if not isinstance(printers, list) or not printers:
        raise AcquireFailed("the server returned no printers for this client")
    names = [p.get("name") for p in printers]
    target = next((p for p in printers if p.get("name") == s.args.printer), None)
    if target is None:
        raise AcquireFailed(
            f"printer {s.args.printer!r} is not offered; available printers: {names}"
        )
    s.printer = target

    connection = target.get("connection") or {}
    base = (connection.get("name") or "").strip()
    if not base:
        base = _config_mobility_base(s)
    if not base.startswith(("http://", "https://")):
        base = "https://" + base
    parts = urlsplit(base)
    if not parts.hostname:
        raise AcquireFailed(f"cannot derive a Mobility Print host from {base!r}")
    s.mobility = base.rstrip("/")
    print(f"    print server: {s.server}")
    print(f"    machine fingerprint: {json.dumps(info, separators=(',', ':'))}")
    print(f"    printers offered: {names}")
    print(f"    selected printer: {target.get('name')!r}")
    print(f"    Mobility Print base: {s.mobility}")


def _config_mobility_base(s: Session) -> str:
    """Fallback only: /deploy/printers did not advertise connection.name."""
    print("    [note] no connection.name; falling back to /deploy/config")
    resp = s.http.request("GET", f"{s.server}/deploy/config")
    cfg = expect_json(resp, 200)
    for key in ("accessibleHost", "AccessibleMobilityPrintTLSPort"):
        if key not in cfg:
            raise AcquireFailed(f"config response is missing {key!r}")
    return (
        f"https://{cfg['accessibleHost']}:"
        f"{cfg['AccessibleMobilityPrintTLSPort']}"
    )


def step_printer_token(s: Session):
    print("\n[5/6] Request the Printer JWT (GET {mobility}/token)")
    credentials = f"{s.username}:{s.access_token}".encode()
    basic = base64.b64encode(credentials).decode()
    query = urlencode({"printerName": s.args.printer})
    resp = s.http.request(
        "GET",
        f"{s.mobility}/token?{query}",
        headers={
            "Authentication-Type": f"openid/{s.provider}",
            "Authorization": "Basic " + basic,
        },
    )
    data = expect_json(resp, 200)
    token = data.get("token")
    if not token:
        raise AcquireFailed("response did not contain a printer token")
    s.job_token = token
    details = describe_jwt(token)
    if details["username"] and details["username"] != s.username:
        print(
            f"    [warn] JWT username {details['username']!r} differs from the "
            f"signed-in user {s.username!r}"
        )
    print(f"    printer JWT = {mask(token)}")
    print(
        f"    claims: exp={human_time(details['exp'])}, "
        f"username={details['username']}"
    )
    return details


def step_printer_url(s: Session):
    print("\n[6/6] Resolve the full queue URL (GET {mobility}/printer-url)")
    host = urlsplit(s.mobility).hostname
    query = urlencode(
        {"printerName": s.args.printer, "scheme": s.args.queue_scheme,
         "serverName": host}
    )
    resp = s.http.request(
        "GET",
        f"{s.mobility}/printer-url?{query}",
        headers={"Authorization": f"Bearer {s.job_token}"},
    )
    if resp.status != 200:
        raise AcquireFailed(
            f"expected HTTP 200, got {resp.status} {resp.reason}: "
            f"{snippet(resp.body)}"
        )
    url = resp.text().strip()
    if not url or any(ch.isspace() for ch in url):
        raise AcquireFailed(f"response is not a queue URL: {url[:200]!r}")
    parts = urlsplit(url)
    if parts.scheme not in ("http", "https") or not parts.netloc:
        raise AcquireFailed(f"response is not an absolute queue URL: {url[:200]!r}")
    s.queue_url = url
    print(f"    queue URL = {mask_url(url)}")


def ipp_path_for(mobility: str, printer: str) -> str:
    """Derive /ipp/{host}_{port}/{hex(printer)}/printers/{printer}."""
    parts = urlsplit(mobility)
    host = parts.hostname
    port = parts.port or (443 if parts.scheme == "https" else 80)
    return (
        f"/ipp/{host}_{port}/{printer.encode('utf-8').hex()}"
        f"/printers/{quote(printer, safe='')}"
    )


# ---------------------------------------------------------------------------
# YAML output
# ---------------------------------------------------------------------------


def _yaml_string(value: str) -> str:
    # A JSON string is also a valid YAML double-quoted scalar.
    return json.dumps(str(value), ensure_ascii=False)


def render_yaml(s: Session, details: dict, generated_at: str) -> str:
    ipp_path = ipp_path_for(s.mobility, s.args.printer)
    ipp_endpoint = s.mobility + ipp_path
    rows = [
        ("version", str(CONFIG_VERSION)),
        ("generated_at", _yaml_string(generated_at)),
        ("print_deploy_server", _yaml_string(s.server)),
        ("printer", _yaml_string(s.args.printer)),
        ("username", _yaml_string(s.username)),
        ("mobility_base", _yaml_string(s.mobility)),
        ("printer_jwt", _yaml_string(s.job_token)),
        ("jwt_expires_at", _yaml_string(human_time(details["exp"]))),
        ("jwt_expires_epoch", str(details["exp"])),
        ("queue_url", _yaml_string(s.queue_url)),
        ("ipp_path", _yaml_string(ipp_path)),
        ("ipp_endpoint", _yaml_string(ipp_endpoint)),
        ("printer_uri", _yaml_string(ipp_endpoint)),
    ]
    lines = [
        "# PaperCut Mobility Print queue configuration for a loopback IPP relay",
        "# (PROTOCOL3 option C).  Consumed by the relay started from systemd.",
        "#",
        "# The relay MUST use ipp_endpoint / printer_uri / printer_jwt from this",
        "# file and must not derive the upstream target from the CUPS request URL.",
        "#",
        "# SECURITY: printer_jwt and queue_url are bearer credentials.",
        "# Keep this file mode 0600 and readable only by the relay user.",
        f"# Generated by acquire_queue_info.py at {generated_at}",
    ]
    lines.extend(f"{key}: {value}" for key, value in rows)
    return "\n".join(lines) + "\n"


def write_private_file(path: str, text: str) -> str:
    target = os.path.abspath(path)
    directory = os.path.dirname(target) or "."
    os.makedirs(directory, exist_ok=True)
    try:
        st = os.stat(directory)
        if st.st_mode & 0o022:
            print(
                f"    [warn] {directory} is group/world-writable; the file is "
                "still written 0600, but prefer a private directory"
            )
    except OSError:
        pass
    fd, tmp = tempfile.mkstemp(prefix=".queue-info-", suffix=".tmp",
                               dir=directory)
    try:
        os.fchmod(fd, 0o600)
        with os.fdopen(fd, "w", encoding="utf-8") as fh:
            fh.write(text)
            fh.flush()
            os.fsync(fh.fileno())
        os.replace(tmp, target)
    except BaseException:
        try:
            os.unlink(tmp)
        except OSError:
            pass
        raise
    return target


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


def build_parser() -> argparse.ArgumentParser:
    p = argparse.ArgumentParser(
        description="Acquire the Printer JWT and full queue URL and save them as "
        "YAML for a CUPS loopback IPP relay.",
        formatter_class=argparse.ArgumentDefaultsHelpFormatter,
    )
    p.add_argument("--out", default="queue.yaml",
                   help="YAML output file (written atomically, mode 0600)")
    p.add_argument("--server", default=DEFAULT_SERVER,
                   help="Print Deploy server base URL")
    p.add_argument("--printer", default=DEFAULT_PRINTER,
                   help="printer name to select from /deploy/printers")
    p.add_argument("--provider", default="microsoft",
                   help="identity provider id for the OAuth flow")
    p.add_argument("--callback-port", type=int, default=9176,
                   help="local port for the OAuth loopback callback")
    p.add_argument("--login-timeout", type=float, default=300,
                   help="seconds to wait for the browser sign-in callback")
    p.add_argument("--no-browser", action="store_true",
                   help="do not open a browser automatically")
    p.add_argument("--queue-scheme", default="http",
                   choices=("http", "https"),
                   help="scheme requested for the queue URL from /printer-url")
    p.add_argument("--machine-user",
                   help="username sent in the machine fingerprint "
                        "(default: the local user, as the captured client did)")
    p.add_argument("--hostname", help="hostname sent in the machine fingerprint")
    p.add_argument("--os-name", dest="os_name",
                   help="os.name in the machine fingerprint (capture: darwin)")
    p.add_argument("--os-version", dest="os_version",
                   help="os.version in the machine fingerprint (capture: 12)")
    p.add_argument("--arch",
                   help="os.arch in the machine fingerprint (capture: amd64)")
    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=30.0,
                   help="per-request network timeout in seconds")
    p.add_argument("--user-agent", default=DEFAULT_USER_AGENT)
    p.add_argument("--verbose", action="store_true",
                   help="show request headers and bodies (secrets masked)")
    return p


def main(argv=None) -> int:
    args = build_parser().parse_args(argv)
    args.server = args.server.rstrip("/")
    if args.callback_port < 1 or args.callback_port > 65535:
        print("[FAIL] --callback-port must be between 1 and 65535", file=sys.stderr)
        return 1

    try:
        sys.stdout.reconfigure(line_buffering=True)
    except (AttributeError, ValueError):
        pass

    print("PaperCut Mobility Print - acquire Printer JWT and queue URL")
    print(f"  server:  {args.server}")
    print(f"  printer: {args.printer}")
    print(f"  output:  {os.path.abspath(args.out)}")
    print(f"  TLS verification: {'on' if (args.verify or args.cacert) else 'OFF'}")
    if not (args.verify or args.cacert):
        print("  (use --verify or --cacert if the server has a trusted certificate)")

    http = HttpClient(
        timeout=args.timeout,
        verify=args.verify,
        cacert=args.cacert,
        verbose=args.verbose,
        user_agent=args.user_agent,
    )
    session = Session(args=args, http=http)

    try:
        step_oauth_url(session)
        step_login(session)
        step_oauth_user(session)
        step_printers(session)
        details = step_printer_token(session)
        step_printer_url(session)

        generated_at = datetime.now(tz=timezone.utc).isoformat()
        text = render_yaml(session, details, generated_at)
        target = write_private_file(args.out, text)
    except AcquireFailed as exc:
        print(f"\n[FAIL] {exc}", file=sys.stderr)
        print("No queue config was written.", file=sys.stderr)
        return 1
    except OSError as exc:
        print(f"\n[FAIL] cannot write {args.out}: {exc}", file=sys.stderr)
        return 1

    print("\n[ok] Queue information acquired.")
    print(f"    saved: {target} (mode 0600)")
    print(f"    printer: {args.printer}")
    print(f"    username: {session.username}")
    print(f"    Mobility Print base: {session.mobility}")
    print(f"    queue URL: {mask_url(session.queue_url)}")
    print(f"    JWT expiry: {human_time(details['exp'])}")
    print(f"    upstream endpoint: {session.mobility}"
          f"{ipp_path_for(session.mobility, args.printer)}")
    print(
        "\nThe relay must read ipp_endpoint, printer_uri and printer_jwt from "
        "this file;\nit must not derive the upstream target from the CUPS "
        "request URL."
    )
    return 0


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