#!/usr/bin/python3
"""mood-present — "Present to class": share this screen with any browser on the same network.

  mood-present [--port 7070] [--pin 1234] [--quality 60] [--fps 6] [--scale 0.6] [--output NAME]

Serves a PIN-protected page with a live MJPEG stream (grim screenshots, only taken while someone
watches). State for Settings/top bar: $XDG_RUNTIME_DIR/mood-present.json. SIGTERM (or locking the
screen) stops it. stdlib only + grim.
"""
import argparse
import html
import json
import os
import random
import re
import signal
import socket
import subprocess
import sys
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs, urlparse

sys.path.insert(0, "/usr/lib/mood/runtime")
try:
    import moodactivity  # "Presenting" pill on the Dynamic Island
except Exception:  # pragma: no cover
    moodactivity = None

RUN = os.environ.get("XDG_RUNTIME_DIR") or f"/tmp/mood-{os.getuid()}"
STATE = os.path.join(RUN, "mood-present.json")
BOUNDARY = "moodframe"

ap = argparse.ArgumentParser()
ap.add_argument("--port", type=int, default=7070)
ap.add_argument("--pin", default="")
ap.add_argument("--quality", type=int, default=60)
ap.add_argument("--fps", type=float, default=6)
ap.add_argument("--scale", type=float, default=0.6)
ap.add_argument("--output", default="")
ap.add_argument("--name", default="")
ap.add_argument("--grim", default=os.environ.get("MOOD_PRESENT_GRIM", "grim"))
ap.add_argument("--no-lock-watch", action="store_true")
A = ap.parse_args()
A.quality = max(20, min(95, A.quality))
A.fps = max(1.0, min(15.0, A.fps))
A.scale = max(0.25, min(1.0, A.scale))
if not re.fullmatch(r"\d{4}", A.pin or ""):
    A.pin = f"{random.SystemRandom().randrange(10000):04d}"
if A.output and not re.fullmatch(r"[\w.-]{1,40}", A.output):
    sys.exit("bad output name")
NAME = A.name or (os.environ.get("USER") or "Moodtop").capitalize()

frame = {"seq": 0, "jpg": b"", "t": 0.0, "err": ""}
cond = threading.Condition()
viewers = {}          # id -> client ip
fails = {}            # ip -> [count, until]
lock = threading.Lock()
stopping = threading.Event()
started = time.time()


def lan_ips():
    ips = []
    try:
        s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
        s.connect(("10.254.254.254", 1))  # no packet is sent; picks the default-route address
        ips.append(s.getsockname()[0])
        s.close()
    except OSError:
        pass
    try:
        out = subprocess.run(["ip", "-4", "-o", "addr", "show", "scope", "global"], capture_output=True, text=True, timeout=3).stdout
        for line in out.splitlines():
            m = re.match(r"\d+:\s+(\S+)\s+inet\s+([\d.]+)/", line)
            if m and not re.match(r"(moodvpn|wg|tun|tap|docker|podman|veth|virbr|cni)", m.group(1)) and m.group(2) not in ips:
                ips.append(m.group(2))
    except (OSError, subprocess.SubprocessError):
        pass
    return [ip for ip in ips if not ip.startswith("127.")]


def write_state():
    if stopping.is_set():
        return
    ips = lan_ips()
    vs = list(viewers.values())
    st = {"pid": os.getpid(), "port": A.port, "pin": A.pin, "started": int(started), "viewers": len(set(vs)), "streams": len(vs), "ips": ips, "urls": [f"http://{ip}:{A.port}" for ip in ips], "quality": A.quality,
          "fps": A.fps, "scale": A.scale, "output": A.output, "error": frame["err"]}
    tmp = STATE + ".tmp"
    with open(tmp, "w") as f:
        json.dump(st, f)
    os.replace(tmp, STATE)
    if moodactivity:
        n = st["viewers"]
        try:
            moodactivity.set("present", kind="present", title="Presenting to class",
                             sub=(f"{n} watching" if n else "Waiting for viewers") + (f" \u00b7 PIN {A.pin}" if A.pin else "") + (f" \u00b7 {st['urls'][0]}" if st["urls"] else ""),
                             color="#ff3b30", priority=70, pid=os.getpid(), app="settings",
                             actions=[{"label": "Stop presenting", "icon": "square", "exec": ["/bin/kill", "-TERM", str(os.getpid())]}])
        except Exception:
            pass


def grabber():
    """Screenshots only while at least one viewer is connected."""
    cmd = [A.grim, "-t", "jpeg", "-q", str(A.quality), "-s", f"{A.scale:g}"] + (["-o", A.output] if A.output else []) + ["-"]
    errs = 0
    while not stopping.is_set():
        if not viewers:
            time.sleep(0.25)
            continue
        t0 = time.monotonic()
        try:
            r = subprocess.run(cmd, capture_output=True, timeout=5)
            ok = r.returncode == 0 and r.stdout[:2] == b"\xff\xd8"
            err = "" if ok else (r.stderr.decode(errors="replace").strip()[:200] or "screen capture failed")
        except (OSError, subprocess.SubprocessError) as e:
            ok, err = False, str(e)[:200]
        with cond:
            if ok:
                frame.update(seq=frame["seq"] + 1, jpg=r.stdout, t=time.time(), err="")
                errs = 0
            else:
                errs += 1
                if frame["err"] != err:
                    frame["err"] = err
                    write_state()
            cond.notify_all()
        time.sleep(max(0.0, 1.0 / A.fps - (time.monotonic() - t0)) if ok else min(2.0, 0.2 * errs))


def lock_watch():
    """Stop when the screen locks (gtklock) — nobody should watch the lock screen or what follows it."""
    uid = str(os.getuid())
    while not stopping.wait(2):
        for p in os.listdir("/proc"):
            if not p.isdigit():
                continue
            try:
                with open(f"/proc/{p}/comm") as f:
                    if f.read().strip() != "gtklock":
                        continue
                if str(os.stat(f"/proc/{p}").st_uid) == uid:
                    notify("Presenting stopped", "Your screen locked, so sharing was switched off.")
                    stop()
                    return
            except OSError:
                continue


def notify(title, body):
    try:
        subprocess.Popen(["notify-send", "-a", "Present to class", "-i", "video-display", title, body], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
    except OSError:
        pass


PAGE_CSS = """*{box-sizing:border-box}html,body{margin:0;height:100%;background:#0b0b12;color:#e8e8f0;font:16px/1.4 system-ui,-apple-system,Segoe UI,Roboto,sans-serif}
.pin{min-height:100%;display:grid;place-items:center;padding:24px}.box{background:#161624;border:1px solid #2a2a3d;border-radius:22px;padding:34px 30px;width:min(380px,92vw);text-align:center;box-shadow:0 20px 60px #0008}
h1{font-size:22px;margin:0 0 6px}p{color:#a0a0b8;margin:0 0 20px}input{width:100%;font:700 34px/1 ui-monospace,monospace;letter-spacing:.4em;text-align:center;padding:14px 0 14px .4em;border-radius:14px;border:1px solid #33334a;background:#0e0e18;color:#fff;outline:none}
input:focus{border-color:#8b5cf6;box-shadow:0 0 0 3px #8b5cf655}button{margin-top:16px;width:100%;padding:13px;border:0;border-radius:14px;font-weight:700;font-size:16px;color:#fff;background:linear-gradient(135deg,#8b5cf6,#22d3ee);cursor:pointer}
.err{color:#fb7185;margin-top:12px;min-height:1.4em}.dot{display:inline-block;width:10px;height:10px;border-radius:50%;background:#ef4444;box-shadow:0 0 10px #ef4444;margin-right:8px;animation:p 1.6s infinite}@keyframes p{50%{opacity:.35}}
.view{height:100%;display:flex;flex-direction:column}.bar{display:flex;align-items:center;gap:10px;padding:8px 14px;background:#12121c;font-weight:600;font-size:14px}.bar span{color:#a0a0b8;font-weight:500}.bar .sp{flex:1}
.bar button{margin:0;width:auto;padding:7px 14px;font-size:13px}.stage{flex:1;min-height:0;display:grid;place-items:center;background:#000}.stage img{max-width:100%;max-height:100%;object-fit:contain}
.wait{position:absolute;inset:0;display:grid;place-items:center;color:#a0a0b8;pointer-events:none}:fullscreen .bar{display:none}"""


def pin_page(err=""):
    return f"""<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>{html.escape(NAME)}'s screen</title><style>{PAGE_CSS}</style></head>
<body><div class="pin"><form class="box" method="get" action="/"><h1><span class="dot"></span>{html.escape(NAME)} is presenting</h1><p>Enter the 4-digit PIN shown on their screen</p>
<input name="pin" inputmode="numeric" pattern="[0-9]{{4}}" maxlength="4" autocomplete="off" autofocus required><button>Watch</button><div class="err">{html.escape(err)}</div></form></div></body></html>"""


def view_page(pin):
    return f"""<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>{html.escape(NAME)}'s screen</title><style>{PAGE_CSS}</style></head>
<body><div class="view"><div class="bar"><span class="dot"></span>{html.escape(NAME)}'s screen <span>· Moodtop Present</span><div class="sp"></div><button id="fs">Full screen</button></div>
<div class="stage" id="stage"><img id="img" alt=""><div class="wait" id="wait">Connecting…</div></div></div>
<script>
const img=document.getElementById("img"),wait=document.getElementById("wait");let n=0;
function go(){{img.src="/stream.mjpg?pin={pin}&n="+(n++);}}
img.onload=()=>{{wait.hidden=true;}};
img.onerror=()=>{{wait.hidden=false;wait.textContent="Reconnecting…";fetch("/ping?pin={pin}").then(r=>{{if(r.status===410||r.status===403){{wait.textContent="The presentation has ended.";return;}}setTimeout(go,1500);}}).catch(()=>{{wait.textContent="The presentation has ended.";setTimeout(go,4000);}});}};
document.getElementById("fs").onclick=()=>document.getElementById("stage").requestFullscreen&&document.getElementById("stage").requestFullscreen();
document.getElementById("stage").ondblclick=()=>document.fullscreenElement?document.exitFullscreen():document.getElementById("stage").requestFullscreen();
go();
</script></body></html>"""


class H(BaseHTTPRequestHandler):
    server_version = "MoodPresent/1"
    protocol_version = "HTTP/1.1"

    def log_message(self, *a):
        pass

    def send(self, code, body, ctype="text/html; charset=utf-8", extra=None):
        b = body.encode() if isinstance(body, str) else body
        self.send_response(code)
        self.send_header("Content-Type", ctype)
        self.send_header("Content-Length", str(len(b)))
        self.send_header("Cache-Control", "no-store")
        self.send_header("X-Frame-Options", "DENY")
        for k, v in (extra or {}).items():
            self.send_header(k, v)
        self.end_headers()
        self.wfile.write(b)

    def pin_ok(self, q):
        ip = self.client_address[0]
        now = time.time()
        with lock:
            n, until = fails.get(ip, (0, 0))
            if until > now:
                return "wait"
        given = (q.get("pin") or [""])[0]
        if not given:
            c = re.search(r"(?:^|;\s*)mpin=(\d{4})", self.headers.get("Cookie") or "")
            given = c.group(1) if c else ""
        if given == A.pin:
            with lock:
                fails.pop(ip, None)
            return "ok"
        if given:
            with lock:
                n += 1
                fails[ip] = (0, now + 60) if n >= 5 else (n, 0)
            return "bad"
        return "none"

    def do_GET(self):
        u = urlparse(self.path)
        q = parse_qs(u.query)
        if stopping.is_set():
            return self.send(410, "ended", "text/plain")
        if u.path == "/favicon.ico":
            return self.send(404, "", "text/plain")
        ok = self.pin_ok(q)
        if u.path == "/":
            if ok == "ok":
                return self.send(200, view_page(A.pin), extra={"Set-Cookie": f"mpin={A.pin}; Path=/; SameSite=Strict; HttpOnly"})
            msg = {"bad": "That PIN isn't right. Check the presenter's screen.", "wait": "Too many tries. Wait a minute and try again."}.get(ok, "")
            return self.send(403 if ok in ("bad", "wait") else 200, pin_page(msg))
        if ok != "ok":
            return self.send(403, "PIN required", "text/plain")
        if u.path == "/ping":
            return self.send(200, "ok", "text/plain")
        if u.path == "/frame.jpg":
            return self.one_frame()
        if u.path == "/stream.mjpg":
            return self.stream()
        return self.send(404, "not found", "text/plain")

    def one_frame(self):
        vid = object()
        viewers[vid] = self.client_address[0]
        try:
            with cond:
                cond.wait_for(lambda: frame["jpg"] and time.time() - frame["t"] < 3 or stopping.is_set(), timeout=6)
                jpg = frame["jpg"]
        finally:
            viewers.pop(vid, None)
        return self.send(200, jpg, "image/jpeg") if jpg else self.send(503, "no frame", "text/plain")

    def stream(self):
        vid = object()
        viewers[vid] = self.client_address[0]
        write_state()
        self.close_connection = True
        try:
            self.send_response(200)
            self.send_header("Content-Type", f"multipart/x-mixed-replace; boundary={BOUNDARY}")
            self.send_header("Cache-Control", "no-store")
            self.send_header("Connection", "close")
            self.end_headers()
            seq = -1
            while not stopping.is_set():
                with cond:
                    cond.wait_for(lambda: frame["seq"] != seq or stopping.is_set(), timeout=10)
                    if stopping.is_set():
                        break
                    if frame["seq"] == seq:
                        continue
                    seq, jpg = frame["seq"], frame["jpg"]
                self.wfile.write(f"--{BOUNDARY}\r\nContent-Type: image/jpeg\r\nContent-Length: {len(jpg)}\r\n\r\n".encode() + jpg + b"\r\n")
                self.wfile.flush()
        except (BrokenPipeError, ConnectionResetError, OSError):
            pass
        finally:
            viewers.pop(vid, None)
            if not stopping.is_set():
                write_state()


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


def stop(*_):
    if stopping.is_set():
        return
    stopping.set()
    with cond:
        cond.notify_all()
    try:
        os.unlink(STATE)
    except OSError:
        pass
    if moodactivity:
        moodactivity.clear("present")
    threading.Thread(target=httpd.shutdown, daemon=True).start()


try:
    httpd = Server(("0.0.0.0", A.port), H)
except OSError as e:
    sys.exit(f"Port {A.port} is busy ({e.strerror}). Pick another port.")
os.makedirs(RUN, exist_ok=True)
signal.signal(signal.SIGTERM, stop)
signal.signal(signal.SIGINT, stop)
signal.signal(signal.SIGHUP, stop)
write_state()
threading.Thread(target=grabber, daemon=True).start()
if not A.no_lock_watch:
    threading.Thread(target=lock_watch, daemon=True).start()


def refresh():  # Wi-Fi may change while presenting
    while not stopping.wait(15):
        write_state()


threading.Thread(target=refresh, daemon=True).start()
print(json.dumps({"port": A.port, "pin": A.pin}), flush=True)
httpd.serve_forever(poll_interval=0.5)
stop()
