#!/usr/bin/env python3
"""print-agent — the installable runner of the Print Device (issue #99).

The Print Device's other half. The server (printpipe.py) queues jobs from
email; this runner turns a queue into paper. You install it on any computer
that owns a printer -- a desktop, an old laptop, a Raspberry Pi next to the
shelf -- and it polls its queue, downloads a waiting job, and hands the file
to the machine's own print system.

The print system is CUPS, which speaks IPP to any modern printer: that
CUPS+IPP pair is the *universal driver*. One runner drives nearly any printer
made in the last decade, no vendor driver needed. When the machine has no CUPS
(or the job cannot print), the runner says so loudly and keeps the file on
disk -- a failed print is a visible failure, never a dropped promise.

Usage:
  print-agent.py drain  --printer NAME --base URL      one pass over the queue
  print-agent.py wait   --printer NAME --base URL      poll forever (default)
  print-agent.py print  FILE [--copies N]              print one local file
  print-agent.py ledger                                show what this runner did
  print-agent.py --selftest                            no network, no printer

Environment:
  PRINT_DEST      CUPS queue name; defaults to the --printer name.
  PRINT_COMMAND   how to print; default "lp" (CUPS). "lp -d NAME" is implicit
                  via PRINT_DEST. For testing: "cp %(file)s to/here" or "true".
  PRINT_LEGACY    history file; defaults to ~/.print-agent/ledger.jsonl.

Honesty rule: the runner records what IT did in its own ledger. It cannot
write back to the static server, so the server keeps saying "waiting" -- the
paper, and this ledger, are the receipt. (A mail-back receipt is PR-C.)
"""

import argparse
import json
import os
import re
import shutil
import ssl
import subprocess
import sys
import time
import urllib.request

SAFE_NAME = re.compile(r"^[a-z0-9._-]{1,64}$")


def _home_dir(sub: str) -> str:
    return os.path.expanduser("~/.print-agent/" + sub)


def ledger_path() -> str:
    return os.environ.get("PRINT_LEGACY") or _home_dir("ledger.jsonl")


def log(line: str) -> None:
    ts = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
    try:
        os.makedirs(os.path.expanduser("~/.print-agent"), exist_ok=True)
        with open(os.path.join(os.path.expanduser("~/.print-agent"), "run.log"), "a") as f:
            f.write("%s %s\n" % (ts, line))
    except OSError:
        pass


def ledger_record(entry: dict) -> None:
    try:
        os.makedirs(os.path.dirname(ledger_path()), exist_ok=True)
        with open(ledger_path(), "a") as f:
            f.write(json.dumps(entry) + "\n")
    except OSError:
        log("ledger write failed for %r" % entry)


def read_ledger() -> list:
    path = ledger_path()
    if not os.path.isfile(path):
        return []
    rows = []
    try:
        with open(path) as f:
            for line in f:
                line = line.strip()
                if line:
                    rows.append(json.loads(line))
    except (OSError, ValueError):
        pass
    return rows


def _open(base: str, rel: str, timeout: int = 30):
    """Open a file in the queue, whether base is http(s) or a local path.

    Local paths make --selftest possible with no network at all.
    """
    if rel.startswith("/"):
        rel = rel[1:]
    if base.startswith("http://") or base.startswith("https://"):
        ctx = ssl.create_default_context()          # verify, never disable
        req = urllib.request.Request(base.rstrip("/") + "/" + rel)
        with urllib.request.urlopen(req, timeout=timeout, context=ctx) as r:
            return r.read()
    with open(os.path.join(base, rel), "rb") as f:
        return f.read()


def fetch_queue(base: str, printer: str, timeout: int = 30):
    """Return the waiting jobs for one printer; a list of dicts or []."""
    data = _open(base, "index.json", timeout)
    index = json.loads(data.decode("utf-8", "replace"))
    printers = index.get("printers", {})
    if printer not in printers:
        return []
    return printers[printer].get("jobs", [])


def print_file(path: str, dest: str, copies: int = 1) -> tuple:
    """Hand the file to CUPS; returns (ok:bool, note:str).

    The default command is `lp` (CUPS), which speaks IPP to the printer.
    Extra flags (-d, -n) are only meaningful there; other PRINT_COMMAND values
    are test/dev tools and just get the file path.
    """
    command = os.environ.get("PRINT_COMMAND", "lp")
    args = command.split()
    if args[0] == "lp":
        cmd = args + ["-d", dest, "-n", str(copies), path]
    else:
        cmd = args + [path]
    try:
        r = subprocess.run(cmd, capture_output=True, timeout=300, text=True)
    except OSError as e:
        return False, "cannot run %s: %s" % (args[0], e)
    if r.returncode == 0:
        return True, (r.stdout or r.stderr or "").strip() or "ok"
    return False, (r.stderr or r.stdout or "").strip() or "exit %d" % r.returncode


def drain(base: str, printer: str, dest: str,
          mark_seen: set, timeout: int = 30) -> list:
    """One pass over the queue: print every waiting job we have not seen."""
    done = []
    jobs = fetch_queue(base, printer, timeout)
    if not jobs:
        log("queue empty or printer '%s' not found in %s" % (printer, base))
        return done
    for job in jobs:
        jid = job.get("id", "")
        if not jid or jid in mark_seen or not SAFE_NAME.match(jid):
            continue
        if not SAFE_NAME.match(job.get("printer", "")):
            continue
        kind = job.get("kind", "pdf")
        if kind not in ("pdf", "png", "jpg", "gif", "bmp", "txt"):
            log("job %s: unsupported kind %r, leaving it" % (jid, kind))
            mark_seen.add(jid)
            continue
        file_path = os.path.expanduser(os.path.join(
            _home_dir("jobs"), printer, "%s.%s" % (jid, kind)))
        try:
            os.makedirs(os.path.dirname(file_path), exist_ok=True)
            blob = _open(base, "queue/%s/%s/source.%s" % (printer, jid, kind), timeout)
            with open(file_path, "wb") as f:
                f.write(blob)
        except Exception as e:
            log("job %s: could not fetch file: %s" % (jid, e))
            continue
        ok, note = print_file(file_path, dest)
        entry = {
            "id": jid, "printer": printer, "kind": kind,
            "bytes": len(blob), "title": job.get("title", ""),
            "at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
            "ok": ok, "dest": dest, "note": note,
        }
        ledger_record(entry)
        mark_seen.add(jid)
        done.append(entry)
        log("job %s: printed %s -> %s (%s)" % (jid, kind, dest, "ok" if ok else note))
    return done


def cmd_drain(args) -> int:
    dest = args.dest or args.printer
    seen = {e["id"] for e in read_ledger()}
    done = drain(args.base, args.printer, dest, seen)
    for e in done:
        print("%s  %-10s %10d B  %s" % (
            "printed" if e["ok"] else "FAILED ", e["kind"], e["bytes"],
            e["title"] or e["id"][:8]))
    if not done:
        print("nothing new to print.")
    return 0 if all(e["ok"] for e in done) else 1


def cmd_wait(args) -> int:
    while True:
        try:
            cmd_drain(args)
        except Exception as e:
            log("poll error: %s" % e)
            print("poll error: %s" % e)
        time.sleep(max(5, args.interval))


def cmd_print(args) -> int:
    dest = args.dest or os.environ.get("PRINT_DEST", "print-agent")
    ok, note = print_file(args.file, dest, args.copies)
    entry = {
        "id": "local", "printer": dest, "kind": "manual", "bytes": 0,
        "title": os.path.basename(args.file),
        "at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
        "ok": ok, "dest": dest, "note": note,
    }
    ledger_record(entry)
    print("print: %s (%s)" % ("ok" if ok else "FAILED", note))
    return 0 if ok else 1


def cmd_ledger(_args) -> int:
    for e in read_ledger():
        print("%s  %-4s  %-18s  %s" % (
            "printed" if e.get("ok") else "FAILED ", e.get("kind", "?"),
            e.get("at", "?"), e.get("title", "")))
    return 0


def _selftest() -> int:
    """Queue -> file -> fake printer -> ledger, with no hardware at all."""
    import tempfile

    root = tempfile.mkdtemp(prefix="print-agent-selftest-")
    jid = "c0ffee" * 8                          # 48 hex chars, fine for drain
    job = {
        "id": jid,
        "printer": "demo", "title": "selftest sheet", "kind": "png",
        "bytes": 4, "received": "2026-09-13T00:00:00Z",
        "status": "waiting",
        "url": "/print/queue/demo/%s/" % jid,
    }
    printers = {"demo": {"name": "demo", "jobs": [job]}}
    os.makedirs(os.path.join(root, "queue", "demo", jid))
    with open(os.path.join(root, "index.json"), "w") as f:
        json.dump({"generated": "2026-09-13T00:00:00Z", "printers": printers}, f)
    with open(os.path.join(root, "queue", "demo", jid, "source.png"), "wb") as f:
        f.write(b"ABCD")

    os.environ["PRINT_COMMAND"] = "touch"       # fake printer: nothing to break
    os.environ["PRINT_LEGACY"] = os.path.join(root, "ledger.jsonl")
    os.environ["HOME"] = root                   # job files land in the temp tree

    seen = set()
    done = drain(base=root, printer="demo", dest="selftest", mark_seen=seen)
    rows = read_ledger()
    got = rows[0] if rows else {}
    ok = (len(done) == 1 and done[0]["ok"] is True
          and got.get("id") == jid and got.get("ok") is True
          and os.path.isfile(os.path.join(_home_dir("jobs"), "demo", jid + ".png")))
    print("print-agent --selftest: %s (job %s -> ledger)" % (
        "OK" if ok else "FAIL", jid[:8]))
    return 0 if ok else 1


def main() -> int:
    ap = argparse.ArgumentParser(description="the Print Device runner")
    sub = ap.add_subparsers(dest="sub")
    d = sub.add_parser("drain")
    d.add_argument("--printer", required=True)
    d.add_argument("--base", required=True)
    d.add_argument("--dest", default=None)
    d.set_defaults(fn=cmd_drain)
    w = sub.add_parser("wait")
    w.add_argument("--printer", required=True)
    w.add_argument("--base", required=True)
    w.add_argument("--dest", default=None)
    w.add_argument("--interval", type=int, default=60)
    w.set_defaults(fn=cmd_wait)
    p = sub.add_parser("print")
    p.add_argument("file")
    p.add_argument("--copies", type=int, default=1)
    p.add_argument("--dest", default=None)
    p.set_defaults(fn=cmd_print)
    sub.add_parser("ledger").set_defaults(fn=cmd_ledger)
    ap.add_argument("--selftest", action="store_true", dest="selftest")

    args = ap.parse_args()
    if args.selftest:
        return _selftest()
    if not vars(args).get("sub"):
        ap.print_help()
        return 2
    return args.fn(args)


if __name__ == "__main__":
    sys.exit(main())