#!/usr/bin/env python3
"""
ad43 swarm printer  —  https://ad43.com/join

Run this on the computer your thermal printer is plugged into. When someone
types a message on ad43.com, it prints here too — in every house at once.

USAGE (after joining at ad43.com/join, which gives you an id + token):

  PRINTER_ID=p_xxxx PRINTER_TOKEN=tok_xxxx python3 swarm_printer.py

Optional environment variables:
  PRINTER_TYPE   escpos (default) | zpl
  PRINTER_DEV    device to write to, e.g. /dev/usb/lp0  (auto-detected if unset)
  PRINTER_HOST   for network printers, e.g. 192.168.1.50  (with PRINTER_PORT, default 9100)
  PRINTER_WIDTH  characters per line for ESC/POS (default 32; use 48 for 80mm)
  POLL           seconds between checks (default 20)

Pure Python 3 standard library. Nothing prints until a human approves you.
"""

import os
import socket
import sys
import time
import urllib.request
from datetime import datetime

BASE   = os.environ.get("AD43_BASE", "https://ad43.com").rstrip("/")
PID    = os.environ.get("PRINTER_ID", "").strip()
TOKEN  = os.environ.get("PRINTER_TOKEN", "").strip()
PTYPE  = os.environ.get("PRINTER_TYPE", "escpos").strip().lower()
PDEV   = os.environ.get("PRINTER_DEV", "").strip()
PHOST  = os.environ.get("PRINTER_HOST", "").strip()
PPORT  = int(os.environ.get("PRINTER_PORT", "9100"))
PWIDTH = int(os.environ.get("PRINTER_WIDTH", "32"))
POLL   = int(os.environ.get("POLL", "60"))

STATE_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)),
                          f".swarm_state_{PID or 'x'}.txt")
UA = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
      "AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15")

USB_CANDIDATES = ["/dev/usb/lp0", "/dev/usb/lp1", "/dev/usblp0", "/dev/lp0"]


def log(m):
    print(f"[{datetime.now():%H:%M:%S}] {m}", flush=True)


# ---------- talking to ad43 ----------

def pull(after):
    url = f"{BASE}/api/pull?id={PID}&key={TOKEN}&after={after}"
    req = urllib.request.Request(url, headers={"User-Agent": UA})
    with urllib.request.urlopen(req, timeout=20) as r:
        import json
        return json.loads(r.read().decode())


# ---------- finding the printer ----------

def find_device():
    if PDEV:
        return PDEV
    for d in USB_CANDIDATES:
        if os.path.exists(d):
            return d
    return None


def send_bytes(data):
    """Write raw bytes to the printer (network socket or device file)."""
    if PHOST:
        with socket.create_connection((PHOST, PPORT), timeout=8) as s:
            s.sendall(data)
        return
    dev = find_device()
    if not dev:
        raise RuntimeError(
            "no printer found. Plug in the printer, or set PRINTER_DEV "
            "(e.g. /dev/usb/lp0) or PRINTER_HOST for a network printer.")
    with open(dev, "wb") as f:
        f.write(data)


# ---------- formatting a message ----------

def wrap(text, width):
    out, line = [], ""
    for word in text.split():
        if len(line) + len(word) + (1 if line else 0) > width:
            if line:
                out.append(line)
            line = word[:width]
            while len(word) > width:
                out.append(word[:width]); word = word[width:]; line = word
        else:
            line = (line + " " + word) if line else word
    if line:
        out.append(line)
    return out or [""]


def clean(s):
    return "".join(c for c in (s or "") if c == "\n" or 32 <= ord(c) < 127 or ord(c) > 160)


def build_escpos(msg):
    ESC, GS = b"\x1b", b"\x1d"
    out = bytearray()
    out += ESC + b"@"                      # init
    out += ESC + b"a" + b"\x01"            # center
    out += ESC + b"!" + b"\x38"            # double w/h, bold
    out += b"ad43.com\n"
    out += ESC + b"!" + b"\x00"            # normal
    out += b"- - - - - - - - -\n"
    out += ESC + b"a" + b"\x00"            # left
    for ln in wrap(clean(msg.get("text", "")), PWIDTH):
        out += ln.encode("ascii", "replace") + b"\n"
    frm = clean(msg.get("from", "")) or "anonymous"
    where = clean(msg.get("country", ""))
    out += b"\n" + ESC + b"a" + b"\x01"
    out += (f"-- {frm}" + (f" ({where})" if where else "")).encode("ascii", "replace") + b"\n"
    out += (f"no.{msg.get('id','?')}  {datetime.now():%d %b %H:%M}").encode() + b"\n"
    out += b"\n\n\n"
    out += GS + b"V" + b"\x00"             # full cut
    return bytes(out)


def build_zpl(msg):
    w = 560 if PWIDTH >= 40 else 400
    margin = 16
    text = clean(msg.get("text", "")).replace("^", " ").replace("~", " ")
    frm = clean(msg.get("from", "")) or "anonymous"
    where = clean(msg.get("country", ""))
    out, y = [], 20
    out.append(f"^FO{margin},{y}^A0N,40,40^FDad43.com^FS"); y += 50
    out.append(f"^FO{margin},{y}^GB{w-margin*2},0,3^FS"); y += 16
    fb = w - margin * 2
    import math
    cpl = max(10, fb // 18)
    nlines = min(10, max(1, math.ceil(len(text) / cpl)))
    out.append(f"^FO{margin},{y}^FB{fb},{nlines},0,L^A0N,30,30^FD{text}^FS")
    y += nlines * 36 + 12
    out.append(f"^FO{margin},{y}^A0N,22,22^FD-- {frm}" + (f" ({where})" if where else "") + "^FS")
    y += 30
    out.append(f"^FO{margin},{y}^A0N,18,18^FDno.{msg.get('id','?')}  {datetime.now():%d %b %H:%M}^FS")
    y += 24 + 40
    return f"^XA^CI28^MNN^PW{w}^LL{y}^LH0,0{''.join(out)}^XZ\n".encode()


def render(msg):
    return build_zpl(msg) if PTYPE == "zpl" else build_escpos(msg)


def ticket(lines):
    """A local status ticket (connection / welcome), in the printer's language."""
    if PTYPE == "zpl":
        out, y = [], 24
        for size, txt in lines:
            out.append(f"^FO16,{y}^A0N,{size},{size}^FD{txt}^FS")
            y += size + 12
        return f"^XA^CI28^MNN^PW400^LL{y+40}^LH0,0{''.join(out)}^XZ\n".encode()
    ESC, GS = b"\x1b", b"\x1d"
    out = bytearray(ESC + b"@" + ESC + b"a" + b"\x01")
    for size, txt in lines:
        out += ESC + b"!" + (b"\x38" if size >= 34 else b"\x00")
        out += txt.encode("ascii", "replace") + b"\n"
    out += b"\n\n\n" + GS + b"V" + b"\x00"
    return bytes(out)


def marker(name):
    return os.path.join(os.path.dirname(os.path.abspath(__file__)),
                        f".swarm_{name}_{PID}")


def once(name, data):
    """Print a status ticket exactly once (marker file remembers)."""
    if os.path.exists(marker(name)):
        return
    try:
        send_bytes(data)
        open(marker(name), "w").write("1")
        log(f"printed {name} ticket")
    except Exception as e:
        log(f"({name} ticket skipped: {e})")


# ---------- state ----------

def load_state():
    try:
        return int(open(STATE_FILE).read().strip())
    except Exception:
        return None


def save_state(v):
    try:
        open(STATE_FILE, "w").write(str(v))
    except Exception as e:
        log(f"(could not save state: {e})")


# ---------- main loop ----------

def main():
    if not PID or not TOKEN:
        print("Missing PRINTER_ID / PRINTER_TOKEN. Join at https://ad43.com/join first.")
        return 2
    log(f"ad43 swarm printer '{PID}' ({PTYPE}) -> "
        f"{'net '+PHOST+':'+str(PPORT) if PHOST else (find_device() or 'auto-detect')}")
    log("waiting for approval / messages… (nothing prints until a human approves you)")

    loaded = load_state()
    fresh = loaded is None          # never printed before?
    last = loaded if loaded is not None else 0
    primed = not fresh              # resumed printers already have a real cursor
    announced = None

    while True:
        try:
            d = pull(last)
            status = d.get("status")
            if status != announced:
                if status == "pending":
                    log("status: PENDING — a human hasn't approved this printer yet.")
                    once("hello", ticket([
                        (34, "ad43.com"),
                        (0, ""),
                        (22, "connection OK"),
                        (22, "waiting for a human in"),
                        (22, "Australia to approve you..."),
                    ]))
                elif status == "active":
                    log("status: ACTIVE ✅ — you are in the swarm. Messages will now print.")
                    hn = d.get("house")
                    once("welcome", ticket([
                        (34, "WELCOME TO"),
                        (34, "THE SWARM"),
                        (0, ""),
                        (34, f"YOU ARE HOUSE #{hn}" if hn else "YOU ARE IN"),
                        (0, ""),
                        (22, "strangers' words will now"),
                        (22, "appear on this paper"),
                        (22, "ad43.com"),
                    ]))
                elif status == "rejected":
                    log("status: this printer was removed from the swarm.")
                announced = status

            if status == "active":
                # a brand-new printer starts from 'now' — don't dump the backlog
                if not primed:
                    primed = True
                    last = d.get("seq", last)
                    save_state(last)
                    log("primed to current — only new messages will print from here")
                    time.sleep(POLL)
                    continue
                for m in sorted(d.get("items", []), key=lambda x: x.get("id", 0)):
                    if m.get("id", 0) > last:
                        send_bytes(render(m))
                        log(f"printed message #{m['id']} "
                            f"({m.get('from') or 'anon'}, {m.get('country') or '?'})")
                        last = m["id"]
                        save_state(last)
                        time.sleep(1)
        except KeyboardInterrupt:
            return 0
        except Exception as e:
            log(f"error: {e}")
        time.sleep(POLL)


if __name__ == "__main__":
    try:
        sys.exit(main())
    except KeyboardInterrupt:
        pass
