#!/usr/bin/env python3
"""Vote Receiver — Kai 盲选页票箱 (:8095)。

POST /report  body: {"page": "<批次id>", "votes": {...}, "notes": {...}, "client_ts": ...}
  -> 落盘 /home/kai/shared/votebox/<page>.json (原子写), 返回 {"ok": true}
GET  /read/<page> -> {"ok":true,"page":...,"votes":...,"notes":...,"ts":...,"updated_at":...}
GET  /health -> {"ok":true}

安全: 仅绑定 Tailscale IP (100.124.72.88) + 127.0.0.1; payload ≤ 64KB;
votes/notes 仅接受 str→(str|number) 扁平结构, 键 ≤64 字符, 值 ≤200 字符。
"""
import json, os, time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

BOX = "/home/kai/shared/votebox"
os.makedirs(BOX, exist_ok=True)
BIND = ("100.124.72.88", 8098)  # 8095/8096/8097 被既有 dashboard/probe 服务占用 (0917 盘点); 端口撞车由 systemd 拉起失败暴露


def sanitize(d):
    if not isinstance(d, dict):
        raise ValueError("not an object")
    out = {}
    for k, v in d.items():
        if not isinstance(k, str) or len(k) > 64:
            raise ValueError(f"bad key: {k!r}")
        if isinstance(v, (str, int, float)) and len(str(v)) <= 200:
            out[k] = v
        elif v is None:
            out[k] = ""
        else:
            raise ValueError(f"bad value for {k}")
    return out


class H(BaseHTTPRequestHandler):
    def _send(self, code, obj):
        body = json.dumps(obj, ensure_ascii=False).encode()
        self.send_response(code)
        self.send_header("Content-Type", "application/json; charset=utf-8")
        self.send_header("Content-Length", str(len(body)))
        self.send_header("Access-Control-Allow-Origin", self.headers.get("Origin") or "*")
        self.send_header("Access-Control-Allow-Headers", "Content-Type")
        self.end_headers()
        self.wfile.write(body)

    def log_message(self, fmt, *args):
        print(time.strftime("%H:%M:%S"), self.address_string(), fmt % args, flush=True)

    def do_OPTIONS(self):
        self._send(204, {})

    def do_GET(self):
        if self.path == "/health":
            return self._send(200, {"ok": True})
        if self.path.startswith("/read/"):
            page = self.path[len("/read/"):].strip("/")
            if not page or "/" in page or not all(c.isalnum() or c in "-_" for c in page):
                return self._send(400, {"ok": False, "error": "bad page id"})
            p = os.path.join(BOX, page + ".json")
            if not os.path.isfile(p):
                return self._send(200, {"ok": True, "page": page, "empty": True})
            try:
                return self._send(200, json.load(open(p)))
            except Exception as e:  # noqa: BLE001
                return self._send(500, {"ok": False, "error": str(e)})
        self._send(404, {"ok": False})

    def do_POST(self):
        if self.path != "/report":
            return self._send(404, {"ok": False})
        try:
            n = int(self.headers.get("Content-Length", "0"))
            if n <= 0 or n > 65536:
                raise ValueError(f"bad length {n}")
            body = json.loads(self.rfile.read(n))
            page = body.get("page")
            if not isinstance(page, str) or not page or "/" in page or len(page) > 64 \
               or not all(c.isalnum() or c in "-_" for c in page):
                raise ValueError("bad page id")
            votes = sanitize(body.get("votes", {}))
            notes = sanitize(body.get("notes", {}))
            rec = {"ok": True, "page": page, "votes": votes, "notes": notes,
                   "client_ts": body.get("client_ts"),
                   "updated_at": time.strftime("%Y-%m-%d %H:%M:%S")}
            tmp = os.path.join(BOX, f".{page}.tmp")
            with open(tmp, "w") as f:
                json.dump(rec, f, ensure_ascii=False)
            os.replace(tmp, os.path.join(BOX, f"{page}.json"))
            print("SAVED", page, len(votes), "votes", flush=True)
            self._send(200, {"ok": True})
        except Exception as e:  # noqa: BLE001
            self._send(400, {"ok": False, "error": str(e)[:200]})


if __name__ == "__main__":
    srv = ThreadingHTTPServer(BIND, H)
    print("vote-receiver on", BIND, flush=True)
    srv.serve_forever()
