#!/usr/bin/env python3
"""
PhotoDrop Remote — E2E-encrypted relay flavor.

Sibling of photodrop_secure.py: same desktop UI, but the phone can be on ANY network.
Photos travel through a blind Cloudflare Worker relay as AES-256-GCM ciphertext:
  - The 256-bit key is generated HERE and rides only in the QR's URL fragment (#...),
    which browsers never transmit — the relay stores bytes it cannot read.
  - The QR carries upload-only credentials; the pickup token never leaves this machine.
  - A poller thread lists, fetches, decrypts, saves to dropped_photos/, then deletes.

Requires: pip install segno cryptography   (QR + AES-GCM; HTTP is stdlib urllib).
Default relay: RELAY_DEFAULT below — override with --relay to use a self-hosted Worker.
"""
import argparse
import base64
import http.server
import json
import os
import secrets
import socketserver
import threading
import time
import urllib.parse
import urllib.request
import webbrowser

import segno  # pip install segno
from cryptography.hazmat.primitives.ciphers.aead import AESGCM  # pip install cryptography

# --- CONFIG ---
RELAY_DEFAULT = "https://photodrop-relay.kevin-yoder.workers.dev"
PORT = 8000
UPLOAD_DIR = "dropped_photos"
# Local UI access token (gates the local gallery, exactly like photodrop_secure.py).
# Override with PHOTODROP_TOKEN for a fixed value.
TOKEN = os.environ.get("PHOTODROP_TOKEN") or secrets.token_urlsafe(6)
POLL_INTERVAL = 2   # seconds between relay polls (matches the LAN gallery cadence)
FAIL_THRESHOLD = 3  # consecutive poll failures before the UI badge shows

HTML_TEMPLATE = """<!DOCTYPE html>
<html lang="en"><head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PhotoDrop Remote</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Ctext y='.9em' font-size='90'%3E📶%3C/text%3E%3C/svg%3E">
<style>
  :root{--blue:#2563eb;--slate:#0f172a;--muted:#64748b;--bg:#f8fafc;--line:#e2e8f0}
  *{box-sizing:border-box;margin:0;padding:0}
  body{font-family:-apple-system,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;background:var(--bg);color:var(--slate);min-height:100vh}
  header{background:#fff;border-bottom:1px solid var(--line);padding:16px 24px;display:flex;align-items:center;justify-content:space-between;position:sticky;top:0;z-index:10}
  .brand{font-size:20px;font-weight:700}.brand b{color:var(--blue)}
  .badge{font:12px ui-monospace,monospace;background:#f1f5f9;color:var(--muted);padding:4px 12px;border-radius:999px}
  main{max-width:1100px;margin:0 auto;padding:24px;display:grid;grid-template-columns:1fr;gap:24px}
  @media(min-width:900px){main{grid-template-columns:320px 1fr}}
  .card{background:#fff;border:1px solid var(--line);border-radius:16px;padding:24px}
  .qr-card{text-align:center}.qr-card h2{font-size:18px;margin-bottom:6px}.qr-card p{color:var(--muted);font-size:14px;margin-bottom:16px}
  .qr-box{display:inline-block;padding:8px;border:1px solid var(--line);border-radius:12px}
  .qr-box img{width:200px;height:200px;display:block;image-rendering:pixelated}
  .host{margin-top:16px;display:inline-block;background:#eff6ff;color:var(--blue);font:12px ui-monospace,monospace;padding:6px 14px;border-radius:999px}
  .warn{display:none;margin-top:12px;background:#fef2f2;color:#b91c1c;border:1px solid #fecaca;font-size:13px;font-weight:600;padding:6px 14px;border-radius:999px}
  .warn.on{display:inline-block}
  .count-card{background:var(--slate);color:#fff;margin-top:16px}
  .count-card .n{font-size:36px;font-weight:700}.count-card p{color:#94a3b8;font-size:14px}
  .count-card .path{margin-top:12px;padding-top:12px;border-top:1px solid #334155;font:12px ui-monospace,monospace;color:#eab308}
  h2.g{font-size:22px;font-weight:700;margin-bottom:16px}
  .grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:16px}
  .photo{background:#fff;border:1px solid var(--line);border-radius:12px;overflow:hidden}
  .photo img{width:100%;height:120px;object-fit:cover;display:block;background:#f1f5f9}
  .photo .meta{padding:10px;display:flex;align-items:center;justify-content:space-between;gap:8px}
  .photo .meta span{font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
  .photo .meta a{color:var(--blue);text-decoration:none;font-size:16px}
  .empty{border:2px dashed var(--line);border-radius:16px;height:280px;display:flex;flex-direction:column;align-items:center;justify-content:center;color:var(--muted);gap:6px}
  .empty .ico{font-size:40px;opacity:.5}
</style></head>
<body><div id="app"></div>
<script>
const token = new URLSearchParams(location.search).get('token') || '';
const qs = 'token=' + encodeURIComponent(token);
const state = {files:[], relayOk:true};
function init(){ render(); poll(); setInterval(poll,2000); }
async function poll(){
  try{
    const rs=await fetch('/api/relay_status?'+qs);
    if(rs.ok){ const s=await rs.json(); if(s.ok!==state.relayOk){ state.relayOk=s.ok; render(); } }
    const r=await fetch('/api/list?'+qs); if(!r.ok) return; const d=await r.json();
    if(d.length!==state.files.length){ state.files=d.sort((a,b)=>b.timestamp-a.timestamp); render(); }
  }catch(e){}
}
function render(){
  const app=document.getElementById('app');
  const header='<header><div class="brand">&#128246; PhotoDrop <b>Remote</b></div><div class="badge">Relay: {{RELAY_HOST}}</div></header>';
  const warn='<div class="warn'+(state.relayOk?'':' on')+'">&#9888; relay unreachable - retrying</div>';
  const gallery = state.files.length===0
    ? '<div class="empty"><div class="ico">&#128247;</div><p style="font-size:18px;font-weight:600">No photos yet</p><p style="font-size:14px">Scan the code to drop photos here</p></div>'
    : '<div class="grid">'+state.files.map(f=>'<div class="photo"><img src="'+f.url+'?'+qs+'"><div class="meta"><span>'+f.name+'</span><a href="'+f.url+'?'+qs+'" download>&#11015;</a></div></div>').join('')+'</div>';
  app.innerHTML=header+'<main>'
    +'<div><div class="card qr-card"><h2>Connect Device</h2><p>Scan with a phone on any network. Photos arrive end-to-end encrypted.</p>'
    +'<div class="qr-box"><img src="{{QR_IMG}}" alt="QR"></div><div class="host">{{RELAY_HOST}}</div>'+warn+'</div>'
    +'<div class="card count-card"><div class="n">'+state.files.length+'</div><p>Photos Received</p>'
    +'<div class="path">Saving to: ./{{UPLOAD_DIR}}/</div></div></div>'
    +'<div><h2 class="g">Gallery</h2>'+gallery+'</div></main>';
}
init();
</script></body></html>"""

DENIED_HTML = """<!DOCTYPE html><html><head><meta charset="UTF-8"><title>PhotoDrop</title>
<style>body{font-family:-apple-system,"Segoe UI",sans-serif;background:#f1f5f9;color:#64748b}
.d{max-width:420px;margin:80px auto;text-align:center}</style></head>
<body><div class="d"><div style="font-size:48px">&#128274;</div>
<h2 style="color:#0f172a;margin:12px 0">Access denied</h2>
<p>Open PhotoDrop from the host screen, or scan the QR code shown there.</p></div></body></html>"""


# --- PURE PIECES (unit-tested in tests/test_desktop.py) ---

def b64url_nopad(raw):
    """base64url without padding — the alphabet the Worker uses for tokens and the QR key."""
    return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")


def build_fragment_url(relay, session_id, upload_token, key):
    """URL the QR encodes. The #fragment never reaches any server — key + upload token
    travel only inside the QR itself."""
    return (relay.rstrip("/") + "/u#s=" + session_id
            + "&u=" + upload_token + "&k=" + b64url_nopad(key))


def decrypt_blob(key, blob):
    """File blob = IV(12) || ciphertext||tag (AES-256-GCM). Raises on tamper/wrong key."""
    return AESGCM(key).decrypt(blob[:12], blob[12:], None)


def decrypt_meta(key, header_value):
    """X-PD-Meta = standard base64 (WITH padding) of IV2(12) || GCM(JSON {name, type})."""
    raw = base64.b64decode(header_value)
    return json.loads(AESGCM(key).decrypt(raw[:12], raw[12:], None))


def save_with_collision_suffix(upload_dir, name, data):
    """Mirror of the LAN flavor's do_POST save: basename-sanitize the (decrypted) name,
    suffix _<epoch> on collision. Returns the path written."""
    name = os.path.basename(name.replace("\\", "/")) or "unknown.bin"
    os.makedirs(upload_dir, exist_ok=True)
    fp = os.path.join(upload_dir, name)
    if os.path.exists(fp):
        base_, ext = os.path.splitext(name)
        fp = os.path.join(upload_dir, f"{base_}_{int(time.time())}{ext}")
    with open(fp, "wb") as f:
        f.write(data)
    return fp


class RelayStatus:
    """Consecutive poll-failure counter feeding the UI's 'relay unreachable' badge."""

    def __init__(self):
        self.fails = 0

    def failure(self):
        self.fails += 1

    def success(self):
        self.fails = 0

    @property
    def ok(self):
        return self.fails < FAIL_THRESHOLD


# --- RELAY CLIENT (stdlib urllib only) ---

# Cloudflare's default edge protections block requests carrying the unmodified
# default User-Agent of common scripting libraries (incl. "Python-urllib/x.y")
# on workers.dev — every relay call needs an honest, non-blocklisted UA or the
# desktop poller gets silently 403'd while the phone's browser fetches sail
# through untouched.
USER_AGENT = "PhotoDropRemote-Desktop/1.0"


def _relay_req(relay, path, token, method="GET", data=None):
    return urllib.request.Request(
        relay.rstrip("/") + path, data=data,
        headers={"Authorization": "Bearer " + token, "User-Agent": USER_AGENT},
        method=method)


def create_session(relay):
    """POST /api/session -> {"session_id","upload_token","pickup_token"}."""
    req = urllib.request.Request(
        relay.rstrip("/") + "/api/session",
        data=json.dumps({"demo": False}).encode("utf-8"),
        headers={"Content-Type": "application/json", "User-Agent": USER_AGENT},
        method="POST",
    )
    return json.loads(urllib.request.urlopen(req, timeout=10).read())


def relay_list(relay, sid, pickup_token):
    req = _relay_req(relay, "/api/list?s=" + sid, pickup_token)
    return json.loads(urllib.request.urlopen(req, timeout=10).read())["blobs"]


def relay_get_blob(relay, sid, pickup_token, blob_id):
    req = _relay_req(relay, "/api/blob/" + blob_id + "?s=" + sid, pickup_token)
    resp = urllib.request.urlopen(req, timeout=30)
    return resp.read(), resp.headers.get("X-PD-Meta") or ""


def relay_delete_blob(relay, sid, pickup_token, blob_id):
    req = _relay_req(relay, "/api/blob/" + blob_id + "?s=" + sid, pickup_token,
                     method="DELETE")
    urllib.request.urlopen(req, timeout=10).read()


# --- POLLER ---

def poll_once(relay, sid, pickup_token, key, upload_dir, status, warned):
    """One poll cycle: list -> per blob: fetch, decrypt meta+file, save, delete.

    - A failed /api/list call counts one consecutive failure toward the UI badge.
    - A transient fetch failure just skips the blob until the next cycle.
    - A DECRYPT failure warns once per blob id, skips, and NEVER deletes — the blob
      stays on the relay for its TTL in case the right key shows up in another session.
    """
    try:
        blobs = relay_list(relay, sid, pickup_token)
    except Exception:
        status.failure()
        return
    status.success()
    for b in blobs:
        blob_id = b["id"]
        try:
            body, meta_header = relay_get_blob(relay, sid, pickup_token, blob_id)
        except Exception:
            continue  # network hiccup — retry next cycle
        try:
            meta = decrypt_meta(key, meta_header)
            data = decrypt_blob(key, body)
        except Exception as e:
            if blob_id not in warned:
                warned.add(blob_id)
                print(f"[warn] blob {blob_id}: cannot decrypt "
                      f"({e.__class__.__name__}) — skipped, not deleted")
            continue
        fp = save_with_collision_suffix(upload_dir, meta.get("name") or "unknown.bin", data)
        print(f"Received: {os.path.basename(fp)}")
        try:
            relay_delete_blob(relay, sid, pickup_token, blob_id)
        except Exception:
            pass  # relay TTL will reap it; worst case a _epoch-suffixed duplicate next cycle


def poller_loop(relay, sid, pickup_token, key, upload_dir, status, warned):
    while True:
        poll_once(relay, sid, pickup_token, key, upload_dir, status, warned)
        time.sleep(POLL_INTERVAL)


# --- LOCAL UI SERVER (parity with photodrop_secure.py) ---

def _token_ok(path):
    q = urllib.parse.parse_qs(urllib.parse.urlparse(path).query)
    return secrets.compare_digest((q.get("token") or [""])[0], TOKEN)


# Runtime state: set in run(), read by Handler.
STATUS = RelayStatus()
FRAGMENT_URL = ""
RELAY_HOST = ""


class Handler(http.server.SimpleHTTPRequestHandler):
    def _send(self, code, body, ctype="text/html"):
        b = body if isinstance(body, bytes) else body.encode("utf-8")
        self.send_response(code)
        self.send_header("Content-type", ctype)
        self.send_header("Content-Length", str(len(b)))
        self.end_headers()
        self.wfile.write(b)

    def do_GET(self):
        p = urllib.parse.urlparse(self.path).path
        if p == "/favicon.ico":
            # Served ahead of the token gate: browsers request the favicon automatically
            # with no token, and a 403 here just litters the console. Inline emoji SVG.
            svg = ("<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'>"
                   "<text y='.9em' font-size='90'>\U0001F4F6</text></svg>")
            self._send(200, svg, "image/svg+xml")
            return
        if not _token_ok(self.path):
            self._send(403, DENIED_HTML)
            return
        if p == "/api/relay_status":
            # Feeds the "relay unreachable - retrying" badge from the poller's counter.
            self._send(200, json.dumps({"ok": STATUS.ok}), "application/json")
            return
        if p == "/api/list":
            files = []
            if os.path.isdir(UPLOAD_DIR):
                for f in os.listdir(UPLOAD_DIR):
                    if not f.startswith("."):
                        fp = os.path.join(UPLOAD_DIR, f)
                        files.append({"name": f, "url": f"/{UPLOAD_DIR}/{f}",
                                      "timestamp": os.path.getmtime(fp)})
            self._send(200, json.dumps(files), "application/json")
            return
        if p.startswith(f"/{UPLOAD_DIR}/"):
            self.path = p  # drop the ?token query so the file handler serves the file
            super().do_GET()
            return
        # Frontend page — the QR encodes the RELAY /u fragment URL (key + upload token
        # ride only inside the QR; the fragment is never transmitted to any server).
        qr_uri = segno.make(FRAGMENT_URL, error="m").png_data_uri(scale=6, border=2)
        html = (HTML_TEMPLATE.replace("{{QR_IMG}}", qr_uri)
                .replace("{{UPLOAD_DIR}}", UPLOAD_DIR)
                .replace("{{RELAY_HOST}}", RELAY_HOST))
        self._send(200, html)


def run():
    global PORT, STATUS, FRAGMENT_URL, RELAY_HOST
    ap = argparse.ArgumentParser(
        description="PhotoDrop Remote: E2E-encrypted photo drop via a blind relay.")
    ap.add_argument("--relay", default=RELAY_DEFAULT,
                    help=f"relay base URL (default {RELAY_DEFAULT})")
    ap.add_argument("--port", type=int, default=PORT, help=f"local UI port (default {PORT})")
    args = ap.parse_args()
    PORT = args.port
    RELAY_HOST = urllib.parse.urlparse(args.relay).netloc or args.relay

    os.makedirs(UPLOAD_DIR, exist_ok=True)
    session = create_session(args.relay)
    key = AESGCM.generate_key(bit_length=256)
    FRAGMENT_URL = build_fragment_url(args.relay, session["session_id"],
                                      session["upload_token"], key)
    STATUS = RelayStatus()
    threading.Thread(
        target=poller_loop,
        args=(args.relay, session["session_id"], session["pickup_token"],
              key, UPLOAD_DIR, STATUS, set()),
        daemon=True,
    ).start()

    # 127.0.0.1 on purpose: the phone talks to the RELAY, never to this machine, so
    # there is no reason to expose the gallery to the LAN (hardening vs the LAN flavor).
    httpd = socketserver.TCPServer(("127.0.0.1", PORT), Handler)
    url = f"http://127.0.0.1:{PORT}/?token={TOKEN}"
    print("\n" + "=" * 52)
    print(" PHOTODROP REMOTE (E2E-encrypted relay) STARTED")
    print("=" * 52)
    print(f" Desktop URL : {url}")
    print(" (open this — the token gates the local gallery)")
    print(f" Relay       : {args.relay}")
    print(f" Phone URL   : {FRAGMENT_URL}")
    print(" (the desktop page's QR encodes this — the key rides in the #fragment)")
    print(f" Photos      : {os.path.abspath(UPLOAD_DIR)}")
    print("=" * 52 + "\n")
    webbrowser.open(url)
    try:
        httpd.serve_forever()
    except KeyboardInterrupt:
        print("\nStopping server...")
        httpd.server_close()


if __name__ == "__main__":
    run()
