#!/usr/bin/env python3
"""amp — bộ công cụ làm game appmienphi ngay trên máy (chỉ cần Python 3.8+, không cài thêm gì).

  python3 amp.py create ten-game [--template basic|multiplayer]   tạo game mới từ mẫu
  python3 amp.py start [thu-muc] [--port 5173]                    chạy game + giả lập appmienphi
  python3 amp.py multiplayer [thu-muc] [--players 3]              mở N cửa sổ, mỗi cửa sổ 1 người chơi
  python3 amp.py leaderboard [show|seed|clear] [--board main]      xem / tạo điểm giả / xoá bảng xếp hạng
  python3 amp.py storage [list|get KEY|clear] [--player 1]         xem dữ liệu cloud giả lập của người chơi
  python3 amp.py check [thu-muc]                                  kiểm tra gói giống hệt lúc đăng lên
  python3 amp.py pack [thu-muc]                                   kiểm tra + nén ra dist/<slug>.zip để đăng
  python3 amp.py update                                           tải bản amp.py mới nhất

Tải về: curl -O https://be.appmienphi.com/sdk/amp.py   (mẫu game tự tải lần đầu chạy)

`start` phục vụ đúng SDK thật (https://be.appmienphi.com/sdk/appdata.js), chỉ đổi địa chỉ sang
server giả lập trên máy: AppData.get/set, uploadFile, submitScore/leaderboard, rooms... chạy y như
trên appmienphi.com nhưng dữ liệu nằm trong <thu-muc>/.appmienphi/ (xoá thư mục đó = làm lại từ đầu).
Mỗi cổng (5173, 5174, ...) là 1 người chơi riêng: trình duyệt coi mỗi cổng là 1 trang khác nhau nên
đăng nhập, dữ liệu máy (IndexedDB) không lẫn vào nhau.
"""
import argparse
import http.server
import io
import json
import mimetypes
import os
import posixpath
import random
import re
import secrets
import shutil
import socketserver
import string
import sys
import threading
import time
import urllib.parse
import urllib.request
import uuid
import webbrowser
import zipfile
from datetime import datetime, timedelta, timezone

HERE = os.path.dirname(os.path.abspath(__file__))
BE_BASE = os.environ.get("AMP_BE", "https://be.appmienphi.com").rstrip("/")
PROD_SDK_URL = "https://be.appmienphi.com/sdk/appdata.js"  # thẻ script game phải trỏ đúng link này khi đăng
CACHE_DIR = os.path.join(os.path.expanduser("~"), ".appmienphi")
ASSETS_CACHE = os.path.join(CACHE_DIR, "amp-files")
CONFIG_NAME = "appmienphi.json"
DATA_DIR = ".appmienphi"

# ---- Giới hạn copy từ be (helpers/plans.py, appdata.py, leaderboard.py, rooms.py, packages.py) ----
MB = 1024 * 1024
PLAN_FIELDS = ("id", "name", "price", "storage", "requests_per_day", "doc_max", "docs_max", "file_max", "rooms_max", "room_members")
PLANS = {p[0]: dict(zip(PLAN_FIELDS, p)) for p in [
    ("dung-thu", "Dùng thử", 0, 50 * MB, 1_000, 1 * MB, 200, 5 * MB, 1, 4),
    ("co-ban", "Cơ bản", 20_000, 50 * MB, 1_000, 1 * MB, 200, 5 * MB, 3, 8),
    ("nang-cao", "Nâng cao", 50_000, 200 * MB, 5_000, 2 * MB, 500, 10 * MB, 10, 20),
    ("trung-tam", "Trung tâm", 120_000, 1024 * MB, 20_000, 5 * MB, 2000, 20 * MB, 30, 50),
]}
READ_ONLY = dict(zip(PLAN_FIELDS, ("het-han", "Chưa có gói", 0, 0, 200, 0, 0, 0, 0, 0)))
TRIAL_DAYS = 30
KEY_RE = re.compile(r"^[A-Za-z0-9_-]{1,64}$")
SCORE_MAX = 10_000_000
STATE_MAX = 64 * 1024
SECRET_MAX = 2 * 1024
ONLINE_SECONDS = 40
ROOM_POLL_SECONDS = 25
CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
IMAGE_CT = {"image/png": "png", "image/jpeg": "jpg", "image/webp": "webp", "image/gif": "gif"}

PACKAGE_MAX_BYTES = 20 * MB
UNZIPPED_MAX_BYTES = 50 * MB
MAX_FILES = 200
IMAGE_MAX_BYTES = 2 * MB
TEXT_EXT = {".html", ".htm", ".css", ".js"}
IMAGE_EXT = {".png", ".jpg", ".jpeg", ".webp", ".gif", ".svg", ".ico"}
AUDIO_EXT = {".mp3"}
AUDIO_MAX_BYTES = 5 * MB
# File/thư mục của môi trường làm game -> không đưa vào gói, không báo lỗi.
DEV_ONLY = re.compile(r"(^|/)(\.[^/]*|node_modules|dist|__MACOSX|Thumbs\.db|desktop\.ini|appmienphi\.json|README(\.[a-z]+)?|LICENSE(\.[a-z]+)?)(/|$)", re.I)

FAKE_NAMES = ["Minh Anh", "Bảo Ngọc", "Gia Huy", "Khánh Linh", "Đức Trí", "Thảo Vy", "Hoàng Nam", "Phương Mai",
              "Quốc Bảo", "Tuệ Nhi", "Nhật Minh", "Hà My", "Tuấn Kiệt", "Diệp Chi", "Anh Khoa", "Thanh Trúc"]


def fetch(url, timeout=20):
    # Cloudflare chặn User-Agent mặc định của urllib (403)
    req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0 (amp dev; appmienphi SDK)"})
    with urllib.request.urlopen(req, timeout=timeout) as r:
        return r.read()


def assets_dir(refresh=False):
    """Mẫu game + trang dev (templates/, ui/): cạnh amp.py nếu chạy từ repo, không thì tải
    gói amp-files.zip từ be về ~/.appmienphi/amp-files (1 lần, `amp.py update` để làm mới)."""
    if os.path.isdir(os.path.join(HERE, "templates")) and os.path.isdir(os.path.join(HERE, "ui")):
        return HERE
    if refresh or not os.path.isdir(os.path.join(ASSETS_CACHE, "ui")):
        try:
            data = fetch(BE_BASE + "/sdk/amp-files.zip")
        except Exception as e:
            die(f"Không tải được mẫu game từ {BE_BASE}/sdk/amp-files.zip ({e}) — kiểm tra mạng rồi chạy lại")
        tmp = ASSETS_CACHE + ".tmp"
        shutil.rmtree(tmp, ignore_errors=True)
        with zipfile.ZipFile(io.BytesIO(data)) as z:
            for name in z.namelist():
                if name.startswith("/") or ".." in name.split("/"):
                    die("Gói amp-files.zip không hợp lệ")
            z.extractall(tmp)
        shutil.rmtree(ASSETS_CACHE, ignore_errors=True)
        os.replace(tmp, ASSETS_CACHE)
    return ASSETS_CACHE


def now_iso():
    return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S")


def vn_today():
    return (datetime.now(timezone.utc) + timedelta(hours=7)).strftime("%Y-%m-%d")


def die(msg):
    print("✗ " + msg, file=sys.stderr)
    sys.exit(1)


class ApiError(Exception):
    def __init__(self, status, detail):
        super().__init__(detail)
        self.status, self.detail = status, detail


# ======================================================================================
# Cấu hình game
# ======================================================================================

def slugify(text):
    import unicodedata
    s = unicodedata.normalize("NFD", text.replace("đ", "d").replace("Đ", "D"))
    s = "".join(c for c in s if unicodedata.category(c) != "Mn").lower()
    return re.sub(r"[^a-z0-9]+", "-", s).strip("-")[:60] or "game"


def load_project(path):
    root = os.path.abspath(path or ".")
    if not os.path.isdir(root):
        die(f"Không thấy thư mục {root}")
    cfg = {}
    cfg_path = os.path.join(root, CONFIG_NAME)
    if os.path.exists(cfg_path):
        try:
            with open(cfg_path, encoding="utf-8") as f:
                cfg = json.load(f)
        except ValueError as e:
            die(f"{CONFIG_NAME} không phải JSON hợp lệ: {e}")
    if not os.path.exists(os.path.join(root, "index.html")):
        die(f"{root} chưa có index.html — tạo game mới: python3 amp.py create ten-game")
    cfg.setdefault("slug", slugify(os.path.basename(root)))
    cfg.setdefault("name", cfg["slug"])
    return root, cfg


# ======================================================================================
# Kho dữ liệu giả lập (1 file JSON + thư mục ảnh trong <game>/.appmienphi/)
# ======================================================================================

class DevStore:
    def __init__(self, root, cfg):
        self.dir = os.path.join(root, DATA_DIR)
        self.files_dir = os.path.join(self.dir, "files")
        self.path = os.path.join(self.dir, "dev-data.json")
        self.cfg = cfg
        self.lock = threading.RLock()
        self.changed = threading.Condition(self.lock)
        self.data = {"users": {}, "docs": {}, "files": {}, "boards": {}, "rooms": {}}
        self.mtime = None
        self.sync()
        self.submit_hits = {}

    def sync(self):
        """Đọc lại file nếu bị sửa từ ngoài (vd `amp leaderboard seed` chạy lúc server đang bật)."""
        try:
            mtime = os.path.getmtime(self.path)
        except OSError:
            return
        if mtime == self.mtime:
            return
        try:
            with open(self.path, encoding="utf-8") as f:
                self.data = {"users": {}, "docs": {}, "files": {}, "boards": {}, "rooms": {}, **json.load(f)}
            self.mtime = mtime
        except ValueError:
            print("! .appmienphi/dev-data.json hỏng, bắt đầu dữ liệu mới")

    def save(self):
        os.makedirs(self.dir, exist_ok=True)
        with open(self.dir + "/.gitignore", "w") as f:
            f.write("*\n")
        tmp = self.path + ".tmp"
        with open(tmp, "w", encoding="utf-8") as f:
            json.dump(self.data, f, ensure_ascii=False, indent=1)
        os.replace(tmp, self.path)
        self.mtime = os.path.getmtime(self.path)

    # ---------- người chơi & gói ----------
    def user(self, n):
        uid = f"dev-user-{n}"
        users = self.data["users"]
        if uid not in users:
            users[uid] = {"id": uid, "name": f"Người chơi {n}", "plan": None, "expires_at": None, "trial_used": False, "usage": {}}
            self.save()
        return users[uid]

    def plan_of(self, u):
        if u.get("plan") and u.get("expires_at") and u["expires_at"] > now_iso():
            return PLANS.get(u["plan"])
        return None

    def storage_used(self, uid):
        docs = self.data["docs"].get(uid, {})
        files = self.data["files"].get(uid, {})
        return sum(d["size"] for d in docs.values()) + sum(f["size"] for f in files.values())

    def items_count(self, uid):
        return len(self.data["docs"].get(uid, {})) + len(self.data["files"].get(uid, {}))

    def plan_info(self, u, base):
        plan = self.plan_of(u)
        return {
            "plan": plan,
            "expires_at": u["expires_at"] if plan else None,
            "trial_available": not u["trial_used"] and not plan,
            "trial_days": TRIAL_DAYS,
            "storage_used": self.storage_used(u["id"]),
            "requests_today": u["usage"].get(vn_today(), 0),
            "upgrade_url": base + "/_amp/#" + u["id"],
        }

    def count_request(self, u, plan):
        day = vn_today()
        u["usage"] = {day: u["usage"].get(day, 0) + 1}
        limit = (plan or READ_ONLY)["requests_per_day"]
        if u["usage"][day] > limit:
            raise ApiError(429, f"Bạn đã dùng hết {limit} lượt lưu/đọc cloud hôm nay (gói {(plan or READ_ONLY)['name']}), thử lại sau 0h hoặc nâng cấp gói")

    @staticmethod
    def need_plan(plan):
        if not plan:
            raise ApiError(402, "Chưa có gói lưu trữ cloud hoặc gói đã hết hạn — mua gói để tiếp tục lưu (dữ liệu cũ vẫn giữ nguyên)")
        return plan

    def check_space(self, uid, plan, extra, replacing=0):
        if self.storage_used(uid) - replacing + extra > plan["storage"]:
            raise ApiError(507, f"Bạn đã dùng hết {plan['storage'] // MB}MB cloud (gói {plan['name']}), nâng cấp gói để lưu thêm")

    # ---------- phòng ----------
    def bump(self, room):
        room["version"] += 1
        room["updated_at"] = now_iso()
        self.changed.notify_all()

    def room_view(self, room, uid):
        now = time.time()
        owner = self.data["users"].get(room["owner_id"])
        plan = self.plan_of(owner) if owner else None
        return {
            "id": room["id"], "code": room["code"], "name": room["name"], "owner_id": room["owner_id"], "me": uid,
            "version": room["version"], "round": room["round"], "state": room["state"], "reveal": room["reveal"],
            "max_members": plan["room_members"] if plan else 0, "locked": plan is None,
            "members": [{
                "id": m["user_id"], "name": m["name"], "owner": m["user_id"] == room["owner_id"],
                "online": now - m["last_seen"] < ONLINE_SECONDS,
                "submitted": m["secret_round"] == room["round"] and m["secret"] is not None,
            } for m in room["members"]],
        }


def display_name(u, nickname):
    name = re.sub(r"\s+", " ", (nickname or u["name"] or "").strip())[:40]
    return name or "Người chơi"


def jsize(value):
    return len(json.dumps(value, ensure_ascii=False, separators=(",", ":")).encode())


def image_ok(ext, data):
    head = data[:16]
    return {
        "png": head[:8] == b"\x89PNG\r\n\x1a\n", "jpg": head[:3] == b"\xff\xd8\xff", "jpeg": head[:3] == b"\xff\xd8\xff",
        "webp": head[:4] == b"RIFF" and head[8:12] == b"WEBP", "gif": head[:6] in (b"GIF87a", b"GIF89a"),
        "ico": head[:4] == b"\x00\x00\x01\x00", "svg": b"\x00" not in data and b"<svg" in data[:4096].lower(),
    }[ext]


# ======================================================================================
# API giả lập /appdata/v1/* (cùng hợp đồng với be/routers/appdata_router.py)
# ======================================================================================

class Api:
    def __init__(self, store, server):
        self.s, self.srv = store, server

    def auth(self, headers):
        h = headers.get("Authorization") or ""
        uid = h[len("Bearer dev."):] if h.startswith("Bearer dev.") else None
        u = self.s.data["users"].get(uid) if uid else None
        if not u:
            raise ApiError(401, "Phiên đăng nhập hết hạn, hãy đăng nhập lại")
        return u

    def handle(self, method, path, query, headers, body, base):
        s = self.s
        with s.lock:
            if path == "/app" and method == "GET":
                return self.app_info(base)
            if path == "/leaderboard" and method == "GET":
                uid = None
                try:
                    uid = self.auth(headers)["id"]
                except ApiError:
                    pass
                return self.leaderboard(query.get("board", "main"), int(query.get("limit", "20") or 20), uid)

            u = self.auth(headers)
            plan = s.plan_of(u)
            uid = u["id"]
            if path == "/plan" and method == "GET":
                return s.plan_info(u, base)
            if path == "/trial" and method == "POST":
                if not plan and not u["trial_used"]:
                    u.update(plan="dung-thu", trial_used=True, expires_at=(datetime.now(timezone.utc) + timedelta(days=TRIAL_DAYS)).strftime("%Y-%m-%dT%H:%M:%S"))
                    s.save()
                return s.plan_info(u, base)
            if path == "/scores" and method == "POST":
                s.count_request(u, plan)
                return self.submit_score(u, body or {})
            if path.startswith("/rooms"):
                return self.rooms(method, path[len("/rooms"):], query, u, body or {})

            # Các API dữ liệu tính lượt / ngày
            s.count_request(u, plan)
            docs = s.data["docs"].setdefault(uid, {})
            files = s.data["files"].setdefault(uid, {})
            if path == "/me":
                return {"user": {"id": uid, "name": u["name"]}, "app": {"id": self.app_id(), "name": s.cfg["name"]}}
            if path == "/docs" and method == "GET":
                return {"items": [{"key": k, "size": d["size"], "updated_at": d["updated_at"]} for k, d in sorted(docs.items())],
                        "doc_max": plan["doc_max"] if plan else 0}
            m = re.match(r"^/docs/(.+)$", path)
            if m:
                key = m.group(1)
                if not KEY_RE.match(key):
                    raise ApiError(400, "Tên tài liệu chỉ gồm chữ, số, - và _ (tối đa 64 ký tự)")
                if method == "GET":
                    if key not in docs:
                        raise ApiError(404, "Chưa có dữ liệu")
                    return RawJson(docs[key]["raw"])
                if method == "PUT":
                    plan = s.need_plan(plan)
                    if len(body_raw := (self.srv.last_body or b"")) > plan["doc_max"]:
                        raise ApiError(413, f"Mỗi tài liệu tối đa {plan['doc_max'] // 1024}KB (gói {plan['name']})")
                    try:
                        value = json.loads(body_raw or b"null")
                    except ValueError:
                        raise ApiError(400, "Dữ liệu phải là JSON hợp lệ")
                    raw = json.dumps(value, ensure_ascii=False, separators=(",", ":"))
                    size = len(raw.encode())
                    if key not in docs and s.items_count(uid) >= plan["docs_max"]:
                        raise ApiError(413, f"Gói {plan['name']} lưu tối đa {plan['docs_max']} tài liệu + file trong app này")
                    s.check_space(uid, plan, size, docs.get(key, {}).get("size", 0))
                    docs[key] = {"raw": raw, "size": size, "updated_at": now_iso()}
                    s.save()
                    return {"key": key, "size": size, "updated_at": docs[key]["updated_at"]}
                if method == "DELETE":
                    docs.pop(key, None)
                    s.save()
                    return {"ok": True}
            if path == "/files" and method == "POST":
                plan = s.need_plan(plan)
                ct, size = (body or {}).get("content_type"), int((body or {}).get("size") or 0)
                if ct not in IMAGE_CT:
                    raise ApiError(400, "Chỉ nhận ảnh PNG, JPG, WebP hoặc GIF")
                if size <= 0 or size > plan["file_max"]:
                    raise ApiError(413, f"Mỗi ảnh tối đa {plan['file_max'] // MB}MB (gói {plan['name']})")
                if s.items_count(uid) >= plan["docs_max"]:
                    raise ApiError(413, f"Gói {plan['name']} lưu tối đa {plan['docs_max']} tài liệu + file trong app này")
                s.check_space(uid, plan, size)
                fid = uuid.uuid4().hex
                files[fid] = {"id": fid, "name": ((body or {}).get("name") or "")[:200], "content_type": ct, "size": size,
                              "created_at": now_iso(), "done": False}
                s.save()
                return {"id": fid, "put_url": f"{base}/_amp/upload/{uid}/{fid}", "content_type": ct}
            m = re.match(r"^/files/([0-9a-f]{32})(/done)?$", path)
            if m:
                fid = m.group(1)
                f = files.get(fid)
                if not f:
                    raise ApiError(404, "Không tìm thấy file")
                if m.group(2) and method == "POST":
                    blob = os.path.join(s.files_dir, fid)
                    if not os.path.exists(blob) or os.path.getsize(blob) != f["size"]:
                        raise ApiError(400, "Chưa tải xong ảnh, thử lại")
                    with open(blob, "rb") as fh:
                        if not image_ok(IMAGE_CT[f["content_type"]], fh.read(16)):
                            raise ApiError(400, "File không phải ảnh hợp lệ")
                    f["done"] = True
                    s.save()
                    return self.file_out(f, base)
                if method == "DELETE":
                    files.pop(fid, None)
                    try:
                        os.remove(os.path.join(s.files_dir, fid))
                    except OSError:
                        pass
                    s.save()
                    return {"ok": True}
            if path == "/files" and method == "GET":
                ids = [i for i in query.get("ids", "").split(",") if i][:100]
                items = [files[i] for i in ids if i in files] if ids else sorted(files.values(), key=lambda f: f["created_at"], reverse=True)
                return {"items": [self.file_out(f, base) for f in items if f.get("done")]}
        raise ApiError(404, "Không có API này")

    def app_id(self):
        return "dev-" + self.s.cfg["slug"]

    def app_info(self, base):
        # Link mời mở ở cổng của người chơi kế tiếp -> bấm link mời là thấy ngay người khác vào phòng.
        return {"id": self.app_id(), "name": self.s.cfg["name"], "slug": self.s.cfg["slug"],
                "invite_base": self.srv.next_player_base(base) + "/?phong="}

    @staticmethod
    def file_out(f, base):
        return {"id": f["id"], "name": f["name"], "content_type": f["content_type"], "size": f["size"],
                "created_at": f["created_at"], "url": f"{base}/_amp/files/{f['id']}"}

    # ---------- bảng xếp hạng ----------
    def submit_score(self, u, body):
        board, score, mode = body.get("board") or "main", body.get("score"), body.get("mode") or "best"
        if not KEY_RE.match(board):
            raise ApiError(400, "Tên bảng xếp hạng chỉ gồm chữ, số, - và _ (tối đa 64 ký tự)")
        if mode not in ("best", "latest"):
            raise ApiError(400, "mode chỉ nhận best hoặc latest")
        if not isinstance(score, int) or isinstance(score, bool) or score < 0 or score > SCORE_MAX:
            raise ApiError(400, f"Điểm phải là số nguyên từ 0 đến {SCORE_MAX:,}")
        key, now = (u["id"], board), time.time()
        if now - self.s.submit_hits.get(key, 0) < 1:
            raise ApiError(429, "Gửi điểm quá nhanh, thử lại sau 1 giây")
        self.s.submit_hits[key] = now
        rows = self.s.data["boards"].setdefault(board, {})
        e = rows.get(u["id"])
        if not e:
            e = rows[u["id"]] = {"score": score, "plays": 0}
        elif mode == "latest" or score > e["score"]:
            e["score"] = score
        e["plays"] += 1
        e["name"] = display_name(u, body.get("name"))
        e["updated_at"] = now_iso()
        self.s.save()
        return {"board": board, "score": e["score"], "rank": 1 + sum(1 for r in rows.values() if r["score"] > e["score"]), "total": len(rows)}

    def leaderboard(self, board, limit, uid):
        if not KEY_RE.match(board):
            raise ApiError(400, "Tên bảng xếp hạng chỉ gồm chữ, số, - và _ (tối đa 64 ký tự)")
        rows = self.s.data["boards"].get(board, {})
        ordered = sorted(rows.items(), key=lambda kv: (-kv[1]["score"], kv[1]["updated_at"]))[:max(1, min(limit, 100))]
        items, last, rank = [], None, 0
        for i, (k, e) in enumerate(ordered):
            if e["score"] != last:
                rank, last = i + 1, e["score"]
            items.append({"rank": rank, "name": e["name"], "score": e["score"], "me": k == uid})
        me = None
        if uid in rows:
            mine = rows[uid]
            me = {"rank": 1 + sum(1 for r in rows.values() if r["score"] > mine["score"]), "score": mine["score"], "name": mine["name"]}
        return {"board": board, "items": items, "me": me, "total": len(rows)}

    # ---------- phòng chơi cùng bạn bè ----------
    def rooms(self, method, sub, query, u, body):
        s, uid = self.s, u["id"]
        rooms = s.data["rooms"]
        if sub == "" and method == "POST":
            plan = s.plan_of(u)
            if not plan:
                raise ApiError(402, "Tạo phòng chơi cùng bạn bè cần gói lưu trữ cloud — dùng thử miễn phí hoặc mua gói")
            mine = [r for r in rooms.values() if r["owner_id"] == uid and not r["closed"]]
            if len(mine) >= plan["rooms_max"]:
                raise ApiError(403, f"Gói {plan['name']} tạo tối đa {plan['rooms_max']} phòng — đóng bớt phòng cũ hoặc nâng cấp gói")
            dn = display_name(u, body.get("nickname"))
            while True:
                code = "".join(secrets.choice(CODE_ALPHABET) for _ in range(6))
                if not any(r["code"] == code for r in rooms.values()):
                    break
            rid = uuid.uuid4().hex[:12]
            rooms[rid] = {"id": rid, "code": code, "name": (body.get("name") or "Phòng của " + dn)[:60], "owner_id": uid,
                          "state": None, "reveal": None, "version": 1, "round": 1, "closed": False, "updated_at": now_iso(),
                          "members": [self._member(uid, dn)]}
            s.save()
            return s.room_view(rooms[rid], uid)
        if sub == "" and method == "GET":
            mine = [r for r in rooms.values() if not r["closed"] and any(m["user_id"] == uid for m in r["members"])]
            mine.sort(key=lambda r: r["updated_at"], reverse=True)
            return {"items": [{"id": r["id"], "code": r["code"], "name": r["name"], "owner": r["owner_id"] == uid} for r in mine]}
        if sub == "/join" and method == "POST":
            code = str(body.get("code") or "").strip().upper()
            room = next((r for r in rooms.values() if r["code"] == code and not r["closed"]), None)
            if not room:
                raise ApiError(404, "Mã phòng không đúng hoặc phòng đã đóng")
            m = self._find_member(room, uid)
            if m:
                m["last_seen"] = time.time()
                return s.room_view(room, uid)
            plan = s.plan_of(s.data["users"][room["owner_id"]])
            if not plan:
                raise ApiError(402, "Chủ phòng đã hết gói lưu trữ cloud, phòng tạm khoá")
            if len(room["members"]) >= plan["room_members"]:
                raise ApiError(403, f"Phòng đã đủ {plan['room_members']} người")
            room["members"].append(self._member(uid, display_name(u, body.get("nickname"))))
            s.bump(room)
            s.save()
            return s.room_view(room, uid)

        m = re.match(r"^/([0-9a-f]{12})(/state|/secret|/leave)?$", sub)
        if not m:
            raise ApiError(404, "Không có API này")
        room = rooms.get(m.group(1))
        if not room or room["closed"]:
            raise ApiError(404, "Phòng không tồn tại hoặc đã đóng")
        action = m.group(2)
        me = self._find_member(room, uid)
        if not me:
            raise ApiError(403, "Bạn chưa vào phòng này")
        if action is None and method == "GET":
            return self._room_get(room, me, uid, int(query.get("since") or 0))
        if action == "/state" and method == "PUT":
            if not s.plan_of(s.data["users"][room["owner_id"]]):
                raise ApiError(402, "Chủ phòng đã hết gói lưu trữ cloud, phòng tạm khoá")
            if body.get("version") != room["version"]:
                raise ApiError(409, "Phòng vừa thay đổi, tải lại rồi thử lại")
            if jsize(body.get("state")) > STATE_MAX:
                raise ApiError(413, f"Trạng thái phòng tối đa {STATE_MAX // 1024}KB")
            room["state"] = body.get("state")
            s.bump(room)
            s.save()
            return s.room_view(room, uid)
        if action == "/secret" and method == "POST":
            value = body.get("value")
            if jsize(value) > SECRET_MAX:
                raise ApiError(413, "Lựa chọn quá lớn")
            now = time.time()
            me.update(secret=value, secret_round=room["round"], last_seen=now)
            ready = [x for x in room["members"] if x["secret_round"] == room["round"] and x["secret"] is not None]
            online = [x for x in room["members"] if now - x["last_seen"] < ONLINE_SECONDS]
            if len(ready) >= 2 and all(x["secret_round"] == room["round"] for x in online):
                room["reveal"] = {"round": room["round"], "values": {x["user_id"]: x["secret"] for x in ready}}
                room["round"] += 1
                for x in ready:
                    x["secret"], x["secret_round"] = None, None
            s.bump(room)
            s.save()
            return s.room_view(room, uid)
        if action == "/leave" and method == "POST":
            if room["owner_id"] == uid:
                room["closed"] = True
            else:
                room["members"].remove(me)
            s.bump(room)
            s.save()
            return {"ok": True}
        raise ApiError(404, "Không có API này")

    @staticmethod
    def _member(uid, name):
        return {"user_id": uid, "name": name, "last_seen": time.time(), "secret": None, "secret_round": None}

    @staticmethod
    def _find_member(room, uid):
        return next((m for m in room["members"] if m["user_id"] == uid), None)

    def _room_get(self, room, me, uid, since):
        s = self.s
        was_offline = time.time() - me["last_seen"] >= ONLINE_SECONDS
        me["last_seen"] = time.time()
        if was_offline:
            s.bump(room)
        if since and room["version"] <= since:
            deadline = time.time() + ROOM_POLL_SECONDS
            while room["version"] <= since and not room["closed"] and time.time() < deadline:
                s.changed.wait(deadline - time.time())  # nhả khoá trong lúc chờ
            me["last_seen"] = time.time()
        return s.room_view(room, uid)


class RawJson:
    def __init__(self, raw):
        self.raw = raw


# ======================================================================================
# HTTP server: file game + SDK + API giả lập + trang dev (/_amp/*)
# ======================================================================================

SDK_TAG_RE = re.compile(r"<script\b[^>]*\bsrc\s*=\s*[\"'][^\"']*/sdk/appdata\.js[^\"']*[\"'][^>]*>", re.I)


class DevServer:
    def __init__(self, root, cfg, store, ports, sdk_js):
        self.root, self.cfg, self.store, self.ports, self.sdk_js = root, cfg, store, ports, sdk_js
        self.assets = assets_dir()
        self.api = Api(store, self)
        self.last_body = None
        self.stamp = self.scan()
        threading.Thread(target=self.watch, daemon=True).start()

    def player_of(self, port):
        return self.ports.index(port) + 1 if port in self.ports else 1

    def next_player_base(self, base):
        p = urllib.parse.urlsplit(base)
        port = p.port or 80
        nxt = self.ports[(self.ports.index(port) + 1) % len(self.ports)] if port in self.ports else port
        return f"{p.scheme}://{p.hostname}:{nxt}"

    # ---- live reload: theo dõi thời gian sửa file của game ----
    def scan(self):
        latest = 0.0
        for dirpath, dirnames, filenames in os.walk(self.root):
            dirnames[:] = [d for d in dirnames if not d.startswith(".") and d not in ("dist", "node_modules")]
            for n in filenames:
                try:
                    latest = max(latest, os.path.getmtime(os.path.join(dirpath, n)))
                except OSError:
                    pass
        return latest

    def watch(self):
        while True:
            time.sleep(0.5)
            stamp = self.scan()
            if stamp != self.stamp:
                with self.store.lock:
                    self.stamp = stamp
                    self.store.changed.notify_all()

    def rewrite_html(self, html, port):
        n = self.player_of(port)

        def repl(m):
            tag = re.sub(r"\bsrc\s*=\s*[\"'][^\"']*[\"']", 'src="/sdk/appdata.js"', m.group(0), count=1)
            if "data-app" not in tag:
                tag = tag[:-1].rstrip("/") + f' data-app="{self.cfg["slug"]}">'
            return tag

        html, found = SDK_TAG_RE.subn(repl, html)
        dev = f'<script src="/_amp/dev.js" data-player="{n}" data-players="{len(self.ports)}" data-sdk="{int(bool(found))}"></script>'
        if re.search(r"</body>", html, re.I):
            return re.sub(r"</body>", lambda _: dev + "</body>", html, count=1, flags=re.I)
        return html + dev

    def make_handler(self):
        dev = self

        class Handler(http.server.BaseHTTPRequestHandler):
            protocol_version = "HTTP/1.1"
            server_version = "amp-dev"

            def log_message(self, fmt, *args):
                if os.environ.get("AMP_VERBOSE"):
                    super().log_message(fmt, *args)

            def base(self):
                host = self.headers.get("Host") or f"localhost:{self.server.server_address[1]}"
                return "http://" + host

            def send(self, status, body, ctype="application/json; charset=utf-8", extra=None):
                if isinstance(body, str):
                    body = body.encode("utf-8")
                self.send_response(status)
                self.send_header("Content-Type", ctype)
                self.send_header("Content-Length", str(len(body)))
                self.send_header("Cache-Control", "no-store")
                self.send_header("Access-Control-Allow-Origin", "*")
                for k, v in (extra or {}).items():
                    self.send_header(k, v)
                self.end_headers()
                if self.command != "HEAD":
                    self.wfile.write(body)

            def send_json(self, status, obj):
                self.send(status, json.dumps(obj, ensure_ascii=False))

            def read_body(self):
                n = int(self.headers.get("Content-Length") or 0)
                return self.rfile.read(n) if n else b""

            def do_OPTIONS(self):
                self.send_response(204)
                self.send_header("Access-Control-Allow-Origin", "*")
                self.send_header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
                self.send_header("Access-Control-Allow-Headers", "Authorization, Content-Type")
                self.send_header("Content-Length", "0")
                self.end_headers()

            def do_GET(self):
                self.route("GET")

            def do_HEAD(self):
                self.route("GET")

            def do_POST(self):
                self.route("POST")

            def do_PUT(self):
                self.route("PUT")

            def do_DELETE(self):
                self.route("DELETE")

            def route(self, method):
                url = urllib.parse.urlsplit(self.path)
                path = urllib.parse.unquote(url.path)
                query = dict(urllib.parse.parse_qsl(url.query))
                port = self.server.server_address[1]
                raw = self.read_body() if method in ("POST", "PUT") else b""
                with dev.store.lock:
                    dev.store.sync()
                try:
                    if path.startswith("/appdata/v1/"):
                        body = None
                        if raw and method == "POST" or (raw and method == "PUT" and "/rooms/" in path):
                            try:
                                body = json.loads(raw)
                            except ValueError:
                                raise ApiError(400, "Dữ liệu gửi lên không phải JSON")
                        with dev.store.lock:
                            dev.last_body = raw
                            out = dev.api.handle(method, path[len("/appdata/v1"):], query, self.headers, body, self.base())
                        if isinstance(out, RawJson):
                            return self.send(200, out.raw)
                        return self.send_json(200, out)
                    if path == "/sdk/appdata.js":
                        return self.send(200, dev.sdk_js.replace("__AMP_MB_BASE__", self.base()), "text/javascript; charset=utf-8")
                    if path.startswith("/_amp/"):
                        return self.dev_route(method, path[len("/_amp/"):], query, raw, port)
                    if path == "/ket-noi-app.html" and not os.path.exists(os.path.join(dev.root, "ket-noi-app.html")):
                        return self.ui("login.html")
                    if method != "GET":
                        return self.send_json(405, {"detail": "Method not allowed"})
                    return self.static(path, port)
                except ApiError as e:
                    return self.send_json(e.status, {"detail": e.detail})
                except (BrokenPipeError, ConnectionResetError):
                    pass

            def ui(self, name, ctype="text/html; charset=utf-8"):
                with open(os.path.join(dev.assets, "ui", name), "rb") as f:
                    self.send(200, f.read(), ctype)

            def dev_route(self, method, sub, query, raw, port):
                s = dev.store
                if sub in ("", "index.html"):
                    return self.ui("dashboard.html")
                if sub == "dev.js":
                    return self.ui("dev.js", "text/javascript; charset=utf-8")
                if sub == "login" and method == "POST":
                    body = json.loads(raw or b"{}")
                    with s.lock:
                        u = s.user(int(body.get("player") or dev.player_of(port)))
                        if body.get("name"):
                            u["name"] = str(body["name"]).strip()[:40] or u["name"]
                            s.save()
                    return self.send_json(200, {"token": "dev." + u["id"], "expires_in": 30 * 86400,
                                                "user": {"id": u["id"], "name": u["name"]}})
                if sub == "whoami":
                    return self.send_json(200, {"player": dev.player_of(port), "players": len(dev.ports), "ports": dev.ports,
                                                "slug": dev.cfg["slug"], "name": dev.cfg["name"]})
                if sub == "reload":  # long-poll: trả về khi file game đổi
                    since = float(query.get("since") or 0)
                    with s.lock:
                        if since:
                            deadline = time.time() + 25
                            while dev.stamp == since and time.time() < deadline:
                                s.changed.wait(deadline - time.time())
                        return self.send_json(200, {"stamp": dev.stamp})
                if sub == "state":
                    with s.lock:
                        return self.send_json(200, dashboard_state(dev))
                if sub == "action" and method == "POST":
                    with s.lock:
                        return self.send_json(200, dev_action(dev, json.loads(raw or b"{}")))
                m = re.match(r"^upload/(dev-user-\d+)/([0-9a-f]{32})$", sub)
                if m and method == "PUT":
                    with s.lock:
                        f = s.data["files"].get(m.group(1), {}).get(m.group(2))
                        if not f or len(raw) != f["size"]:
                            return self.send_json(400, {"detail": "Sai dung lượng so với lúc xin link tải lên"})
                        os.makedirs(s.files_dir, exist_ok=True)
                        with open(os.path.join(s.files_dir, m.group(2)), "wb") as fh:
                            fh.write(raw)
                    return self.send(200, b"")
                m = re.match(r"^files/([0-9a-f]{32})$", sub)
                if m:
                    ct = next((f["content_type"] for fs in s.data["files"].values() for f in fs.values() if f["id"] == m.group(1)), None)
                    p = os.path.join(s.files_dir, m.group(1))
                    if ct and os.path.exists(p):
                        with open(p, "rb") as fh:
                            return self.send(200, fh.read(), ct)
                return self.send_json(404, {"detail": "Không thấy"})

            def static(self, path, port):
                rel = posixpath.normpath(path).lstrip("/")
                if rel in ("", "."):
                    rel = "index.html"
                full = os.path.join(dev.root, *rel.split("/"))
                if rel.startswith("..") or rel.split("/")[0] == DATA_DIR:
                    return self.send(404, "Không thấy", "text/plain; charset=utf-8")
                if os.path.isdir(full):
                    full = os.path.join(full, "index.html")
                if not os.path.isfile(full):
                    return self.send(404, f"Không thấy {path}", "text/plain; charset=utf-8")
                ext = os.path.splitext(full)[1].lower()
                with open(full, "rb") as f:
                    data = f.read()
                if ext in (".html", ".htm"):
                    return self.send(200, dev.rewrite_html(data.decode("utf-8", "replace"), port), "text/html; charset=utf-8")
                ctypes = {".js": "text/javascript; charset=utf-8", ".css": "text/css; charset=utf-8", ".svg": "image/svg+xml",
                          ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp",
                          ".gif": "image/gif", ".ico": "image/x-icon", ".json": "application/json"}
                self.send(200, data, ctypes.get(ext) or mimetypes.guess_type(full)[0] or "application/octet-stream")

        return Handler


def dashboard_state(dev):
    s = dev.store
    users = []
    for u in sorted(s.data["users"].values(), key=lambda u: int(u["id"].rsplit("-", 1)[1])):
        plan = s.plan_of(u)
        docs = s.data["docs"].get(u["id"], {})
        users.append({
            "id": u["id"], "name": u["name"], "plan": plan["name"] if plan else None, "plan_id": plan["id"] if plan else None,
            "expires_at": u["expires_at"] if plan else None, "trial_used": u["trial_used"],
            "storage_used": s.storage_used(u["id"]), "requests_today": u["usage"].get(vn_today(), 0),
            "docs": [{"key": k, "size": d["size"], "updated_at": d["updated_at"], "value": json.loads(d["raw"])} for k, d in sorted(docs.items())],
            "files": [Api.file_out(f, "") for f in s.data["files"].get(u["id"], {}).values() if f.get("done")],
        })
    boards = {b: dev.api.leaderboard(b, 100, None) for b in sorted(s.data["boards"])}
    rooms = [s.room_view(r, None) for r in s.data["rooms"].values() if not r["closed"]]
    return {"app": {"slug": dev.cfg["slug"], "name": dev.cfg["name"]}, "ports": dev.ports, "users": users,
            "boards": boards, "rooms": rooms, "plans": [{"id": p["id"], "name": p["name"]} for p in PLANS.values()]}


def dev_action(dev, a):
    s, kind = dev.store, a.get("action")
    u = s.data["users"].get(a.get("user") or "")
    if kind == "plan" and u:
        if a.get("plan") in PLANS:
            u.update(plan=a["plan"], trial_used=True, expires_at=(datetime.now(timezone.utc) + timedelta(days=30)).strftime("%Y-%m-%dT%H:%M:%S"))
        else:  # hết hạn
            u["expires_at"] = "2000-01-01T00:00:00"
    elif kind == "reset-trial" and u:
        u.update(plan=None, expires_at=None, trial_used=False)
    elif kind == "clear-docs" and u:
        s.data["docs"].pop(u["id"], None)
        s.data["files"].pop(u["id"], None)
    elif kind == "rename" and u:
        u["name"] = str(a.get("name") or "").strip()[:40] or u["name"]
    elif kind == "seed":
        seed_board(s, a.get("board") or "main", int(a.get("count") or 15))
    elif kind == "clear-board":
        s.data["boards"].pop(a.get("board") or "main", None)
    elif kind == "close-room":
        r = s.data["rooms"].get(a.get("room") or "")
        if r:
            r["closed"] = True
            s.bump(r)
    elif kind == "reset-all":
        s.data = {"users": {}, "docs": {}, "files": {}, "boards": {}, "rooms": {}}
        shutil.rmtree(s.files_dir, ignore_errors=True)
    else:
        raise ApiError(400, "Không rõ thao tác")
    s.save()
    return {"ok": True}


def seed_board(store, board, count):
    rows = store.data["boards"].setdefault(board, {})
    top = max([r["score"] * 2 for r in rows.values()] + [100])  # điểm giả cùng cỡ với điểm thật của game
    start = sum(1 for k in rows if k.startswith("seed-"))
    for i in range(start, start + count):
        rows[f"seed-{board}-{i}"] = {"name": FAKE_NAMES[i % len(FAKE_NAMES)] + (f" {i // len(FAKE_NAMES) + 1}" if i >= len(FAKE_NAMES) else ""),
                                     "score": random.randint(0, top), "plays": random.randint(1, 30), "updated_at": now_iso()}
    store.save()


class ThreadingServer(socketserver.ThreadingMixIn, http.server.HTTPServer):
    daemon_threads = True
    allow_reuse_address = True


def load_sdk(opt_path, refresh):
    """SDK thật: bản production (lưu tạm ở ~/.appmienphi để không có mạng vẫn chạy) hoặc file tự chỉ định."""
    src = None
    if opt_path:
        with open(opt_path, encoding="utf-8") as f:
            src = f.read()
    else:
        cached = os.path.join(CACHE_DIR, "appdata.js")
        fresh = os.path.exists(cached) and time.time() - os.path.getmtime(cached) < 6 * 3600
        if refresh or not fresh:
            try:
                src = fetch(BE_BASE + "/sdk/appdata.js", 10).decode("utf-8")
                os.makedirs(CACHE_DIR, exist_ok=True)
                with open(cached, "w", encoding="utf-8") as f:
                    f.write(src)
            except Exception as e:
                if not os.path.exists(cached):
                    die(f"Không tải được SDK từ {BE_BASE}/sdk/appdata.js ({e}). Có mạng rồi chạy lại, hoặc dùng --sdk đường-dẫn/appdata.js")
                print(f"! Không tải được SDK mới ({e}), dùng bản đã lưu {cached}")
        if src is None:
            with open(cached, encoding="utf-8") as f:
                src = f.read()
    src, n = re.subn(r'var MB_BASE = "[^"]*";', 'var MB_BASE = "__AMP_MB_BASE__";', src, count=1)
    if not n:
        die("SDK không đúng định dạng (không thấy MB_BASE) — cập nhật amp.py")
    return src


def serve(args, open_players):
    root, cfg = load_project(args.dir)
    store = DevStore(root, cfg)
    ports = list(range(args.port, args.port + max(1, args.players)))
    dev = DevServer(root, cfg, store, ports, load_sdk(args.sdk, args.refresh_sdk))
    servers = []
    for p in ports:
        try:
            srv = ThreadingServer(("127.0.0.1", p), dev.make_handler())
        except OSError:
            die(f"Cổng {p} đang bận — chạy với --port khác, vd --port {args.port + 10}")
        threading.Thread(target=srv.serve_forever, daemon=True).start()
        servers.append(srv)
    print(f"\n  🎮 {cfg['name']}  (slug: {cfg['slug']})")
    for i, p in enumerate(ports):
        print(f"  ▸ Người chơi {i + 1}:  http://localhost:{p}/")
    print(f"  ▸ Bảng điều khiển: http://localhost:{ports[0]}/_amp/")
    print(f"  Dữ liệu giả lập: {os.path.join(root, DATA_DIR)}  ·  Ctrl+C để dừng\n")
    if not args.no_open:
        for p in ports[:open_players]:
            webbrowser.open(f"http://localhost:{p}/")
    try:
        while True:
            time.sleep(3600)
    except KeyboardInterrupt:
        print("\nĐã dừng.")
        for srv in servers:
            srv.shutdown()


# ======================================================================================
# Kiểm tra & đóng gói (luật giống be/helpers/packages.py)
# ======================================================================================

def collect(root):
    files = {}
    for dirpath, dirnames, filenames in os.walk(root):
        for n in filenames:
            full = os.path.join(dirpath, n)
            rel = os.path.relpath(full, root).replace(os.sep, "/")
            if DEV_ONLY.search(rel):
                continue
            files[rel] = full
    return files


def check(root, cfg):
    errors, warns = [], []
    files = collect(root)
    if "index.html" not in files:
        errors.append("Thiếu index.html ở thư mục gốc")
    if len(files) > MAX_FILES:
        errors.append(f"Gói có {len(files)} file, tối đa {MAX_FILES}")
    total, bad_ext, uses_rooms = 0, [], False
    for rel, full in sorted(files.items()):
        ext = posixpath.splitext(rel)[1].lower()
        size = os.path.getsize(full)
        total += size
        if ext not in TEXT_EXT and ext not in IMAGE_EXT and ext not in AUDIO_EXT:
            bad_ext.append(rel)
            continue
        with open(full, "rb") as f:
            data = f.read()
        if ext in IMAGE_EXT:
            if size > IMAGE_MAX_BYTES:
                errors.append(f"Ảnh {rel} nặng {size / MB:.1f}MB, tối đa 2MB — nén nhỏ lại (nên dùng .webp)")
            elif not image_ok(ext[1:], data):
                errors.append(f"{rel} không phải ảnh {ext[1:].upper()} hợp lệ (đuôi file sai?)")
            continue
        if ext in AUDIO_EXT:
            if size > AUDIO_MAX_BYTES:
                errors.append(f"Âm thanh {rel} nặng {size / MB:.1f}MB, tối đa 5MB — giảm bitrate (vd 96kbps) hoặc cắt ngắn")
            elif not (data[:3] == b"ID3" or (len(data) > 1 and data[0] == 0xFF and data[1] & 0xE0 == 0xE0)):
                errors.append(f"{rel} không phải file MP3 hợp lệ (đổi đuôi từ .wav/.ogg? hãy chuyển sang MP3 thật)")
            continue
        if b"\x00" in data:
            errors.append(f"{rel} không phải file text")
            continue
        text = data.decode("utf-8", "replace")
        if re.search(r"https?://(localhost|127\.0\.0\.1)[:/]", text):
            warns.append(f"{rel} còn link localhost — trên appmienphi sẽ không chạy")
        if re.search(r"""(src|href)\s*=\s*["']http://""", text, re.I):
            warns.append(f"{rel} tải tài nguyên qua http:// — trình duyệt sẽ chặn trên https, đổi sang https://")
        if "AppData.rooms" in text or "rooms.create" in text:
            uses_rooms = True
    if bad_ext:
        shown = ", ".join(bad_ext[:6]) + (f" và {len(bad_ext) - 6} file khác" if len(bad_ext) > 6 else "")
        errors.append("appmienphi chỉ nhận .html .css .js, ảnh (.png .jpg .webp .gif .svg .ico) và âm thanh .mp3 — hãy bỏ hoặc chuyển đổi: " + shown +
                      "\n    (âm thanh khác: chuyển sang .mp3; phông chữ/JSON: nhúng thẳng vào file .css/.js)")
    if total > UNZIPPED_MAX_BYTES:
        errors.append(f"Tổng dung lượng {total / MB:.1f}MB, tối đa {UNZIPPED_MAX_BYTES // MB}MB")
    index = files.get("index.html")
    if index:
        with open(index, encoding="utf-8", errors="replace") as f:
            html = f.read()
        tag = SDK_TAG_RE.search(html)
        if not tag:
            warns.append("index.html chưa nhúng SDK: <script src=\"https://be.appmienphi.com/sdk/appdata.js\"></script> (không có thì không lưu cloud / bảng xếp hạng được)")
        elif PROD_SDK_URL not in tag.group(0):
            errors.append(f"Thẻ SDK phải trỏ tới {PROD_SDK_URL} (đang là: {tag.group(0)})")
        if uses_rooms and not re.search(r"""<meta\s+name=["']appmienphi:rooms["']\s+content=["']true["']""", html, re.I):
            warns.append('Game dùng AppData.rooms nhưng index.html thiếu <meta name="appmienphi:rooms" content="true"> (trang game sẽ không hiện nút "Chơi với bạn bè")')
        if not re.search(r"<title>\s*\S", html, re.I):
            warns.append("index.html chưa có <title> — tiêu đề giúp game dễ được tìm thấy")
        if 'name="viewport"' not in html:
            warns.append('index.html thiếu <meta name="viewport" ...> — trên điện thoại sẽ hiển thị nhỏ xíu')
    return files, errors, warns, total


def report(errors, warns, total, count):
    for w in warns:
        print("  ! " + w)
    for e in errors:
        print("  ✗ " + e)
    if errors:
        print(f"\nChưa đăng được: {len(errors)} lỗi.")
        return False
    print(f"  ✓ Hợp lệ: {count} file, {total / 1024:.0f}KB")
    return True


# ======================================================================================
# Lệnh
# ======================================================================================

def short_path(p):
    rel = os.path.relpath(p)
    return p if rel.startswith(".." + os.sep + "..") else rel


def cmd_create(args):
    templates = os.path.join(assets_dir(), "templates")
    tpl = os.path.join(templates, args.template)
    if not os.path.isdir(tpl):
        die(f"Không có mẫu '{args.template}'. Có: {', '.join(sorted(os.listdir(templates)))}")
    dest = os.path.abspath(args.name)
    if os.path.exists(dest) and os.listdir(dest):
        die(f"Thư mục {dest} đã có file, chọn tên khác")
    slug = slugify(os.path.basename(dest))
    title = args.title or os.path.basename(dest).replace("-", " ").strip().capitalize()
    shutil.copytree(tpl, dest, dirs_exist_ok=True)
    for dirpath, _, filenames in os.walk(dest):
        for n in filenames:
            p = os.path.join(dirpath, n)
            if os.path.splitext(n)[1] in (".html", ".js", ".css", ".json", ".md", ""):
                with open(p, encoding="utf-8") as f:
                    text = f.read()
                with open(p, "w", encoding="utf-8") as f:
                    f.write(text.replace("__GAME_TITLE__", title).replace("__GAME_SLUG__", slug))
    rel = short_path(dest)
    amp = short_path(os.path.abspath(__file__))
    print(f"✓ Đã tạo {rel}/ từ mẫu '{args.template}'\n")
    print(f"  python3 {amp} start {rel}" + ("          # 1 người chơi" if args.template == "multiplayer" else ""))
    if args.template == "multiplayer":
        print(f"  python3 {amp} multiplayer {rel}    # 2 cửa sổ, 2 người chơi")
    print(f"  python3 {amp} pack {rel}           # nén để đăng lên appmienphi.com/dang-app.html")


def cmd_start(args):
    serve(args, open_players=1)


def cmd_multiplayer(args):
    if args.players < 2:
        args.players = 2
    serve(args, open_players=args.players)


def open_store(path):
    root, cfg = load_project(path)
    return DevStore(root, cfg)


def cmd_leaderboard(args):
    s = open_store(args.dir)
    if args.action == "seed":
        seed_board(s, args.board, args.count)
        print(f"✓ Thêm {args.count} điểm giả vào bảng '{args.board}'")
    elif args.action == "clear":
        s.data["boards"].pop(args.board, None)
        s.save()
        print(f"✓ Đã xoá bảng '{args.board}'")
    boards = s.data["boards"]
    if not boards:
        print("Chưa có bảng xếp hạng nào (game chưa gọi AppData.submitScore).")
        return
    for b in ([args.board] if args.board in boards else sorted(boards)):
        rows = sorted(boards[b].values(), key=lambda r: (-r["score"], r["updated_at"]))
        print(f"\n🏆 {b}  ({len(rows)} người)")
        for i, r in enumerate(rows[:50]):
            print(f"  {i + 1:>3}. {r['name'][:28]:<28} {r['score']:>10,}  ({r['plays']} lượt)")


def cmd_storage(args):
    s = open_store(args.dir)
    uid = f"dev-user-{args.player}"
    docs = s.data["docs"].get(uid, {})
    print("(Chỉ thấy dữ liệu lưu CLOUD giả lập. Dữ liệu 'Lưu trên máy' nằm trong IndexedDB của trình duyệt —\n"
          " xoá bằng nút 🛠 DEV › 'Xoá dữ liệu máy này' trong game.)\n")
    if args.action == "clear":
        s.data["docs"].pop(uid, None)
        s.data["files"].pop(uid, None)
        s.save()
        print(f"✓ Đã xoá dữ liệu cloud của Người chơi {args.player}")
        return
    if args.action == "get":
        if not args.key or args.key not in docs:
            die(f"Người chơi {args.player} chưa có tài liệu '{args.key}'")
        print(json.dumps(json.loads(docs[args.key]["raw"]), ensure_ascii=False, indent=2))
        return
    u = s.data["users"].get(uid)
    if not u:
        print(f"Người chơi {args.player} chưa đăng nhập lần nào.")
        return
    plan = s.plan_of(u)
    print(f"👤 {u['name']} — gói: {plan['name'] if plan else 'chưa có'} — đã dùng {s.storage_used(uid) / 1024:.1f}KB, "
          f"{u['usage'].get(vn_today(), 0)} lượt hôm nay")
    for k, d in sorted(docs.items()):
        print(f"  📄 {k:<30} {d['size']:>8,} byte   {d['updated_at']}")
    for f in s.data["files"].get(uid, {}).values():
        print(f"  🖼  {f['id']}  {f['name'][:24]:<24} {f['size']:>8,} byte")
    if not docs and not s.data["files"].get(uid):
        print("  (trống)")


def cmd_check(args):
    root, cfg = load_project(args.dir)
    print(f"Kiểm tra {root}")
    files, errors, warns, total = check(root, cfg)
    if not report(errors, warns, total, len(files)):
        sys.exit(1)


def cmd_pack(args):
    root, cfg = load_project(args.dir)
    print(f"Kiểm tra {root}")
    files, errors, warns, total = check(root, cfg)
    if not report(errors, warns, total, len(files)):
        sys.exit(1)
    out = os.path.abspath(args.out or os.path.join(root, "dist", cfg["slug"] + ".zip"))
    os.makedirs(os.path.dirname(out), exist_ok=True)
    buf = io.BytesIO()
    with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z:
        for rel, full in sorted(files.items()):
            z.write(full, rel)
    if buf.tell() > PACKAGE_MAX_BYTES:
        die(f"Gói nén {buf.tell() / MB:.1f}MB, tối đa {PACKAGE_MAX_BYTES // MB}MB")
    with open(out, "wb") as f:
        f.write(buf.getvalue())
    print(f"\n✓ {short_path(out)}  ({buf.tell() / 1024:.0f}KB)\n  Đăng tại https://appmienphi.com/dang-app.html")


def cmd_update(args):
    if os.path.isdir(os.path.join(HERE, "templates")):
        die("Đang chạy amp.py trong repo (có templates/ cạnh nó) — cập nhật bằng git pull")
    new = fetch(BE_BASE + "/sdk/amp.py")
    if b"def main()" not in new:
        die("File tải về không phải amp.py")
    me = os.path.abspath(__file__)
    with open(me + ".tmp", "wb") as f:
        f.write(new)
    os.replace(me + ".tmp", me)
    assets_dir(refresh=True)
    load_sdk(None, True)
    print(f"✓ Đã cập nhật amp.py, mẫu game và SDK mới nhất từ {BE_BASE}")


def main():
    p = argparse.ArgumentParser(prog="amp", description="Công cụ làm game appmienphi trên máy",
                                formatter_class=argparse.RawDescriptionHelpFormatter)
    sub = p.add_subparsers(dest="cmd", metavar="lệnh")
    sub.required = True

    c = sub.add_parser("create", help="tạo game mới từ mẫu", aliases=["scaffold"])
    c.add_argument("name", help="tên thư mục game, vd hung-sao")
    c.add_argument("--template", "-t", default="basic", help="basic (1 người + bảng xếp hạng) | multiplayer (phòng chơi cùng bạn)")
    c.add_argument("--title", help="tên hiển thị của game")
    c.set_defaults(fn=cmd_create)

    def server_args(sp, players):
        sp.add_argument("dir", nargs="?", default=".", help="thư mục game (mặc định: thư mục hiện tại)")
        sp.add_argument("--port", type=int, default=5173)
        sp.add_argument("--players", "-n", type=int, default=players, help="số người chơi (mỗi người 1 cổng)")
        sp.add_argument("--no-open", action="store_true", help="không tự mở trình duyệt")
        sp.add_argument("--sdk", help="dùng file appdata.js trên máy thay bản production")
        sp.add_argument("--refresh-sdk", action="store_true", help="tải lại SDK production ngay")

    sp = sub.add_parser("start", help="chạy game + giả lập appmienphi", aliases=["dev"])
    server_args(sp, 1)
    sp.set_defaults(fn=cmd_start)
    sp = sub.add_parser("multiplayer", help="chạy và mở N cửa sổ, mỗi cửa sổ 1 người chơi", aliases=["mp"])
    server_args(sp, 2)
    sp.set_defaults(fn=cmd_multiplayer)

    sp = sub.add_parser("leaderboard", help="xem / tạo điểm giả / xoá bảng xếp hạng giả lập", aliases=["lb"])
    sp.add_argument("action", nargs="?", default="show", choices=["show", "seed", "clear"])
    sp.add_argument("--dir", default=".")
    sp.add_argument("--board", "-b", default="main")
    sp.add_argument("--count", "-c", type=int, default=15)
    sp.set_defaults(fn=cmd_leaderboard)

    sp = sub.add_parser("storage", help="xem / xoá dữ liệu cloud giả lập của người chơi")
    sp.add_argument("action", nargs="?", default="list", choices=["list", "get", "clear"])
    sp.add_argument("key", nargs="?")
    sp.add_argument("--dir", default=".")
    sp.add_argument("--player", "-p", type=int, default=1)
    sp.set_defaults(fn=cmd_storage)

    sp = sub.add_parser("check", help="kiểm tra gói theo đúng luật khi đăng")
    sp.add_argument("dir", nargs="?", default=".")
    sp.set_defaults(fn=cmd_check)
    sp = sub.add_parser("pack", help="kiểm tra + nén thành .zip để đăng")
    sp.add_argument("dir", nargs="?", default=".")
    sp.add_argument("--out", "-o", help="đường dẫn file .zip (mặc định dist/<slug>.zip)")
    sp.set_defaults(fn=cmd_pack)

    sp = sub.add_parser("update", help="tải bản amp.py + mẫu game + SDK mới nhất")
    sp.set_defaults(fn=cmd_update)

    args = p.parse_args()
    args.fn(args)


if __name__ == "__main__":
    main()
