#!/usr/bin/env python3
"""filehub CLI — 烛龙智元 · 文件中转站 上传/下载工具(零依赖,仅用 Python 标准库)。

浏览器/脚本把文件字节直传阿里云 OSS,后端只签发临时 URL,故不限带宽、不限大小。
本工具封装了登录、单次直传、大文件分片(可断点续传)、秒传、下载、列举、删除、连接自检。

口令即空间:任意非空访问口令对应一个独立的私有空间,同口令可重复访问;
共享文件时上传方与下载方必须使用同一口令。

提供口令的两种方式(命令行参数优先于环境变量):
  环境变量:  export FILEHUB_PASSWORD='<口令>'
  命令行:    python filehub.py <命令> --password '<口令>'

其它环境变量 / 参数:
  FILEHUB_URL / --url           站点地址(默认 https://files.dragonai.tech)
  FILEHUB_INSECURE=1 / --insecure  跳过 TLS 校验(仅当本机缺少 CA 证书时临时用)
  FILEHUB_SINGLE_MAX            单次直传阈值字节(默认 104857600 = 100MB,超过走分片)
  FILEHUB_STATE_DIR             断点续传记录目录(默认 ~/.filehub/uploads)
  --json                        以 JSON 输出结果(便于 agent 解析)

用法:
  filehub.py status                                   # 连接自检:可达?口令就绪?进入哪个空间?
  filehub.py upload   <本地文件> [--folder 目录前缀/] [--name 重命名] [--restart]
  filehub.py upload-status <本地文件> [--folder ...] [--name ...]   # 查某文件的续传进度,不上传
  filehub.py uploads                                  # 列出所有未完成的分片上传(可续传/可放弃)
  filehub.py abort    <key> [--upload-id <id>]        # 放弃一个未完成的分片上传
  filehub.py download <key> <保存路径>
  filehub.py ls       [前缀]
  filehub.py rm       <key> [<key> ...]
  filehub.py mkdir    <路径>

退出码(契约):
  0  成功
  1  一般错误(网络 / OSS / 参数等)
  3  需要口令(未提供访问口令,或会话失效)—— agent 应据此向用户索取口令后重试
  4  上传中断但可续传 —— agent 重新执行相同 upload 命令即自动续传

输出约定:最终结果走 stdout(upload 成功打对象 key;--json 时打一个 JSON 对象);
进度/重试/续传等过程事件走 stderr(--json 时为 JSONL 事件流,带 "event" 字段)。
"""
import os, sys, ssl, json, time, math, hashlib, argparse, shutil
import urllib.request, urllib.parse, urllib.error, http.cookiejar

EXIT_OK, EXIT_ERR, EXIT_AUTH, EXIT_INCOMPLETE = 0, 1, 3, 4

OCTET = "application/octet-stream"
HASH_CHUNK = 8 * 1024 * 1024
SIGN_BATCH = 500
PART_RETRY = 5

# 运行时配置(env 默认,main() 里可被命令行参数覆盖)
BASE = os.environ.get("FILEHUB_URL", "https://files.dragonai.tech").rstrip("/")
PW = os.environ.get("FILEHUB_PASSWORD")
PW_SOURCE = "env" if PW else None
SINGLE_MAX = int(os.environ.get("FILEHUB_SINGLE_MAX", str(100 * 1024 * 1024)))
STATE_DIR = os.environ.get("FILEHUB_STATE_DIR") or os.path.join(
    os.path.expanduser("~"), ".filehub", "uploads")
JSON_OUT = False

_ctx = ssl.create_default_context()
if os.environ.get("FILEHUB_INSECURE") == "1":
    _ctx.check_hostname = False
    _ctx.verify_mode = ssl.CERT_NONE

_cj = http.cookiejar.CookieJar()


class _NoRedirect(urllib.request.HTTPRedirectHandler):
    def redirect_request(self, *a, **k):
        return None


def _build_openers():
    global _op, _noredir
    _op = urllib.request.build_opener(
        urllib.request.HTTPCookieProcessor(_cj), urllib.request.HTTPSHandler(context=_ctx))
    _noredir = urllib.request.build_opener(
        _NoRedirect, urllib.request.HTTPCookieProcessor(_cj), urllib.request.HTTPSHandler(context=_ctx))


_build_openers()


# --------------------------------------------------------------------------- #
# 输出 / 退出
# --------------------------------------------------------------------------- #
def log(msg):
    sys.stderr.write(msg + "\n")


def progress(event, human=None, **kw):
    """过程事件 -> stderr。--json 时为 JSONL(带 event 字段),否则为人类可读文本。"""
    if JSON_OUT:
        rec = {"event": event}
        rec.update(kw)
        sys.stderr.write(json.dumps(rec, ensure_ascii=False) + "\n")
        sys.stderr.flush()
    elif human is not None:
        sys.stderr.write(human + "\n")
        sys.stderr.flush()


def emit(obj, human=None):
    """最终结果 -> stdout。--json 时打 JSON 对象,否则打人类可读文本(human)。"""
    if JSON_OUT:
        sys.stdout.write(json.dumps(obj, ensure_ascii=False) + "\n")
    elif human is not None:
        sys.stdout.write(human + "\n")


def die(msg, code=EXIT_ERR):
    if JSON_OUT:
        sys.stdout.write(json.dumps({"ok": False, "error": str(msg)}, ensure_ascii=False) + "\n")
    sys.stderr.write("[filehub] " + str(msg) + "\n")
    sys.exit(code)


def die_auth(detail="未提供访问口令。", hint=None):
    """需要口令:agent 的关键信号——应停下来向用户索取口令后重试。退出码固定 3。"""
    hint = hint or "本平台「口令即空间」:任意非空口令对应一个独立私有空间,同口令可重复访问。"
    if JSON_OUT:
        sys.stdout.write(json.dumps({
            "ok": False, "code": "auth_required", "action": "ask_user_for_password",
            "detail": detail, "hint": hint,
            "howToProvide": ["python filehub.py <命令> --password '<口令>'",
                             "或先 export FILEHUB_PASSWORD='<口令>'"],
        }, ensure_ascii=False) + "\n")
    sys.stderr.write(
        "[filehub] AUTH_REQUIRED —— " + detail + "\n" + hint + "\n"
        "请向用户索取访问口令,然后用任一方式重试:\n"
        "    python filehub.py <命令> --password '<口令>'\n"
        "    # 或先  export FILEHUB_PASSWORD='<口令>'\n"
    )
    sys.exit(EXIT_AUTH)


# --------------------------------------------------------------------------- #
# HTTP
# --------------------------------------------------------------------------- #
class ApiError(Exception):
    def __init__(self, status, detail, code=None, hint=None):
        super().__init__(detail)
        self.status, self.detail, self.code, self.hint = status, detail, code, hint


def api(path, body=None, method=None, raise_http=False):
    """调用后端 JSON 接口(带会话 cookie + CSRF 头)。
    默认:401 转「需要口令」信号(exit 3),其它 HTTP 错误 die。
    raise_http=True:所有错误抛 ApiError,交调用方处理(用于续传 409 等)。"""
    m = method or ("POST" if body is not None else "GET")
    h = {"X-Requested-With": "XMLHttpRequest"}
    data = None
    if body is not None:
        data = json.dumps(body).encode("utf-8")
        h["Content-Type"] = "application/json"
    req = urllib.request.Request(BASE + path, data=data, method=m, headers=h)
    try:
        r = _op.open(req, timeout=120)
        raw = r.read()
        return json.loads(raw) if raw else {}
    except urllib.error.URLError as e:
        if isinstance(e, urllib.error.HTTPError):
            raw = e.read()
            try:
                info = json.loads(raw)
            except Exception:
                info = {"detail": raw[:300].decode("utf-8", "replace")}
            detail = info.get("detail") or ("HTTP %d" % e.code)
            hint = info.get("hint")
            if raise_http:
                raise ApiError(e.code, detail, info.get("code"), hint)
            if e.code == 401:
                die_auth(detail, hint)
            msg = "HTTP %d %s -> %s" % (e.code, path, detail)
            if hint:
                msg += "\n  提示:" + hint
            die(msg)
        if raise_http:
            raise ApiError(0, str(getattr(e, "reason", e)))
        die("连接失败:%s(%s)。请检查网络与站点地址 FILEHUB_URL=%s" % (getattr(e, "reason", e), path, BASE))


def put_oss(url, data, on_event=None):
    """把字节直传到 OSS 预签名 URL(无 cookie,Content-Type 必须 octet-stream)。
    失败重试,指数退避(并发出 retry 事件让 agent 知道是在等待而非卡死)。"""
    last = None
    for attempt in range(PART_RETRY):
        try:
            req = urllib.request.Request(url, data=data, method="PUT",
                                         headers={"Content-Type": OCTET})
            with urllib.request.urlopen(req, timeout=600, context=_ctx) as r:
                et = r.getheader("ETag")
                if not et:
                    raise IOError("OSS 未返回 ETag")
                return et.strip().strip('"')
        except Exception as e:
            last = e
            if attempt < PART_RETRY - 1:
                wait = min(2 ** attempt, 8)
                if on_event:
                    on_event("retry", attempt=attempt + 1, max=PART_RETRY, wait=wait,
                             error=str(e)[:160])
                time.sleep(wait)
    raise IOError("OSS PUT 失败(已重试 %d 次):%s" % (PART_RETRY, last))


# --------------------------------------------------------------------------- #
# 鉴权
# --------------------------------------------------------------------------- #
def login():
    """用口令登录,建立会话。无口令时发 AUTH_REQUIRED(exit 3)。
    返回后端响应:{ok, space, isNew, hint}。"""
    if not PW:
        die_auth("未提供访问口令(缺少 --password 或 FILEHUB_PASSWORD)。")
    return api("/api/login", {"password": PW}) or {}


def content_hash(path):
    digs = []
    with open(path, "rb") as f:
        while True:
            b = f.read(HASH_CHUNK)
            if not b:
                break
            digs.append(hashlib.sha256(b).digest())
    return hashlib.sha256(b"".join(digs)).hexdigest()


# --------------------------------------------------------------------------- #
# 断点续传状态(本地持久化:把 uploadId 等记到磁盘,进程崩溃/超时后可恢复)
# --------------------------------------------------------------------------- #
def _state_key(space, ident, size):
    h = hashlib.sha1(("%s|%s|%d" % (space, ident, size)).encode("utf-8")).hexdigest()[:20]
    return os.path.join(STATE_DIR, h + ".json")


def _state_load(p):
    try:
        with open(p, encoding="utf-8") as f:
            return json.load(f)
    except Exception:
        return None


def _state_save(p, obj):
    try:
        os.makedirs(STATE_DIR, exist_ok=True)
        with open(p, "w", encoding="utf-8") as f:
            json.dump(obj, f, ensure_ascii=False)
    except Exception:
        pass


def _state_clear(p):
    try:
        os.remove(p)
    except Exception:
        pass


def _ident(folder, name):
    return (folder or "") + "::" + name


# --------------------------------------------------------------------------- #
# 命令
# --------------------------------------------------------------------------- #
def cmd_status(args):
    """连接自检:服务器可达?口令是否就绪?进入了哪个空间?是否空间为空?"""
    reachable = True
    try:
        api("/api/health", raise_http=True)
    except Exception:
        reachable = False
    if not PW:
        if JSON_OUT:
            print(json.dumps({
                "ok": False, "code": "auth_required", "action": "ask_user_for_password",
                "server": BASE, "reachable": reachable, "passwordSet": False,
                "detail": "未提供访问口令。向用户索取口令后,用 --password 或 FILEHUB_PASSWORD 重试。",
            }, ensure_ascii=False))
            sys.exit(EXIT_AUTH)
        log("站点:        %s" % BASE)
        log("可达:        %s" % ("是" if reachable else "否"))
        log("口令:        未提供")
        die_auth("status:未提供访问口令。")
    me = login()
    space, is_new = me.get("space"), me.get("isNew")
    if JSON_OUT:
        print(json.dumps({
            "ok": True, "server": BASE, "reachable": reachable,
            "passwordSet": True, "passwordSource": PW_SOURCE,
            "space": space, "isNew": is_new, "hint": me.get("hint"),
        }, ensure_ascii=False))
        return
    log("站点:        %s" % BASE)
    log("可达:        是")
    log("口令:        已就绪(来源:%s)" % (PW_SOURCE or "?"))
    log("空间:        %s%s" % (space or "?", "(全新/空)" if is_new else "(已有文件)" if is_new is False else ""))
    if is_new:
        log("提示:        这是一个空白空间。若你预期这里已有文件,请确认口令是否输错。")
    log("连接就绪 ✓  可以开始 upload / download / ls。")


def _upload_multipart(path, name, folder, size, space, restart=False):
    """大文件分片上传,支持断点续传 + 过程事件。中断时 exit 4(可续传)。"""
    mtime = int(os.path.getmtime(path))
    sp = _state_key(space, _ident(folder, name), size)
    st = _state_load(sp)
    done = {}            # 服务端已确认的分片: partNumber -> etag
    key = uid = ps = pc = None
    resumed = False

    if st and not restart and st.get("space") == space \
            and st.get("size") == size and st.get("mtime") == mtime:
        try:
            lp = api("/api/upload/list-parts",
                     {"key": st["key"], "uploadId": st["uploadId"]}, raise_http=True)
            done = {p["partNumber"]: p["etag"] for p in lp.get("parts", [])}
            key, uid, ps, pc = st["key"], st["uploadId"], st["partSize"], st["partCount"]
            resumed = True
            progress("resume", "续传:服务端已确认 %d/%d 片,继续补传缺失分片。" % (len(done), pc),
                     key=key, partsUploaded=len(done), partsTotal=pc)
        except ApiError as e:
            if e.status == 409:
                _state_clear(sp)
                progress("session_expired", "原上传会话已过期,重新初始化。", key=st.get("key"))
            else:
                die("查询续传进度失败:%s" % e.detail)
    elif st and restart:
        # 主动重来:放弃旧会话
        try:
            api("/api/upload/abort", {"key": st["key"], "uploadId": st["uploadId"]})
        except SystemExit:
            pass
        _state_clear(sp)

    if not resumed:
        r = api("/api/upload/init", {"filename": name, "size": size, "folder": folder})
        key, uid, ps, pc = r["key"], r["uploadId"], r["partSize"], r["partCount"]
        _state_save(sp, {"space": space, "key": key, "uploadId": uid, "partSize": ps,
                         "partCount": pc, "size": size, "mtime": mtime,
                         "path": os.path.abspath(path)})
        progress("init", "分片上传开始:partSize=%d partCount=%d" % (ps, pc),
                 key=key, partsTotal=pc, partSize=ps)

    missing = [n for n in range(1, pc + 1) if n not in done]
    urls = {}
    for i in range(0, len(missing), SIGN_BATCH):
        batch = missing[i:i + SIGN_BATCH]
        for u in api("/api/upload/sign-parts",
                     {"key": key, "uploadId": uid, "partNumbers": batch})["urls"]:
            urls[u["partNumber"]] = u["url"]

    parts = [{"partNumber": n, "etag": et} for n, et in done.items()]
    sent = 0

    def mk_on_event(n):
        def _ev(ev, **kw):
            if ev == "retry":
                progress("retry", "  分片 %d 第 %d/%d 次重试(等待 %ds):%s"
                         % (n, kw.get("attempt"), kw.get("max"), kw.get("wait"), kw.get("error", "")),
                         partNumber=n, **kw)
        return _ev

    try:
        with open(path, "rb") as f:
            for n in missing:
                f.seek((n - 1) * ps)
                chunk = f.read(ps)
                et = put_oss(urls[n], chunk, on_event=mk_on_event(n))
                parts.append({"partNumber": n, "etag": et})
                sent += 1
                pdone = len(done) + sent
                pct = round(pdone * 100.0 / pc, 1)
                progress("part", "  分片 %d/%d ✓ (%.1f%%)" % (pdone, pc, pct),
                         key=key, partNumber=n, partsDone=pdone, partsTotal=pc, pct=pct)
    except Exception as e:
        pdone = len(done) + sent
        pct = round(pdone * 100.0 / pc, 1)
        if JSON_OUT:
            sys.stdout.write(json.dumps({
                "ok": False, "code": "incomplete", "resumable": True,
                "key": key, "uploadId": uid,
                "partsUploaded": pdone, "partsTotal": pc, "pct": pct, "statePath": sp,
                "hint": "重新执行相同的 upload 命令即可自动续传;或用 upload-status 查询、abort 放弃。",
            }, ensure_ascii=False) + "\n")
        sys.stderr.write(
            "[filehub] 上传中断(可续传):已传 %d/%d 片(%.1f%%)。原因:%s\n"
            "  → 重新执行相同 upload 命令自动续传;`filehub.py uploads` 可查看,`abort` 可放弃。\n"
            % (pdone, pc, pct, e))
        sys.exit(EXIT_INCOMPLETE)

    api("/api/upload/complete", {"key": key, "uploadId": uid, "parts": parts})
    _state_clear(sp)
    progress("complete", "已上传(分片合并):%s" % key, key=key, partsTotal=pc)
    return key


def cmd_upload(args):
    path = args.path
    if not os.path.isfile(path):
        die("文件不存在:" + path)
    name = args.name or os.path.basename(path)
    folder = args.folder or ""
    size = os.path.getsize(path)
    me = login()
    space = me.get("space")

    # —— 秒传 ——
    if not args.no_instant:
        try:
            h = content_hash(path)
            ex = api("/api/upload/exists", {"hash": h, "size": size})
            if ex.get("exists"):
                r = api("/api/upload/instant", {"hash": h, "filename": name, "folder": folder})
                key = r["key"]
                progress("instant", "秒传完成(0 字节上传)", key=key)
                emit({"ok": True, "key": key, "mode": "instant", "size": size}, key)
                return
        except SystemExit:
            raise
        except Exception:
            h = None  # 秒传失败不影响正常上传
    else:
        h = None

    if size <= SINGLE_MAX:
        # —— 单次直传 ——
        r = api("/api/upload/put-url", {"filename": name, "size": size, "folder": folder})

        def _ev(ev, **kw):
            if ev == "retry":
                progress("retry", "  单传第 %d/%d 次重试(等待 %ds):%s"
                         % (kw.get("attempt"), kw.get("max"), kw.get("wait"), kw.get("error", "")), **kw)
        try:
            with open(path, "rb") as f:
                put_oss(r["url"], f.read(), on_event=_ev)
        except Exception as e:
            die("上传失败(可重试):%s" % e)
        key = r["key"]
        mode = "single"
        progress("uploaded", "已上传(单次直传):%s" % key, key=key)
    else:
        key = _upload_multipart(path, name, folder, size, space, restart=args.restart)
        mode = "multipart"

    if h:
        try:
            api("/api/upload/record-hash", {"hash": h, "key": key})
        except SystemExit:
            pass
    emit({"ok": True, "key": key, "mode": mode, "size": size}, key)


def cmd_upload_status(args):
    """查询某个本地文件的续传进度(不上传)。"""
    path = args.path
    if not os.path.isfile(path):
        die("文件不存在:" + path)
    me = login()
    space = me.get("space")
    name = args.name or os.path.basename(path)
    folder = args.folder or ""
    size = os.path.getsize(path)
    mtime = int(os.path.getmtime(path))
    sp = _state_key(space, _ident(folder, name), size)
    st = _state_load(sp)
    if not st or st.get("space") != space:
        out = {"ok": True, "pending": False, "hint": "无续传记录;直接 upload 即可。"}
        if JSON_OUT:
            print(json.dumps(out, ensure_ascii=False))
        else:
            log("无续传记录:直接 upload 即可。")
        return
    stale = (st.get("size") != size or st.get("mtime") != mtime)
    try:
        lp = api("/api/upload/list-parts",
                 {"key": st["key"], "uploadId": st["uploadId"]}, raise_http=True)
    except ApiError as e:
        if e.status == 409:
            _state_clear(sp)
            out = {"ok": True, "pending": False, "expired": True, "key": st.get("key"),
                   "hint": "原上传会话已过期;重新 upload 会从头开始。"}
            if JSON_OUT:
                print(json.dumps(out, ensure_ascii=False))
            else:
                log("上传会话已过期:重新 upload 会从头开始。")
            return
        die("查询失败:%s" % e.detail)
    pc = st.get("partCount")
    pu = lp.get("partsUploaded")
    pct = round(pu * 100.0 / pc, 1) if pc else None
    out = {"ok": True, "pending": True, "resumable": not stale, "stale": stale,
           "key": st["key"], "uploadId": st["uploadId"],
           "partsUploaded": pu, "partsTotal": pc, "bytesUploaded": lp.get("bytesUploaded"),
           "pct": pct,
           "hint": ("文件已改动(大小/时间不符),续传会从头开始。" if stale
                    else "重新执行 upload 即自动续传。")}
    if JSON_OUT:
        print(json.dumps(out, ensure_ascii=False))
    else:
        log("续传状态:%s" % st["key"])
        log("  进度:    %s/%s 片 (%.1f%%)" % (pu, pc, pct or 0))
        log("  可续传:  %s" % ("否(文件已改动,将从头开始)" if stale else "是(重新 upload 自动续传)"))


def cmd_uploads(args):
    """列出所有未完成的分片上传:服务端在途 + 本地续传记录。"""
    me = login()
    space = me.get("space")
    d = api("/api/upload/uploads")
    server = d.get("uploads", [])
    local = []
    try:
        for fn in os.listdir(STATE_DIR):
            if not fn.endswith(".json"):
                continue
            st = _state_load(os.path.join(STATE_DIR, fn))
            if st and st.get("space") == space:
                local.append({"key": st.get("key"), "uploadId": st.get("uploadId"),
                              "partsTotal": st.get("partCount"), "path": st.get("path")})
    except FileNotFoundError:
        pass
    if JSON_OUT:
        print(json.dumps({"ok": True, "space": space,
                          "serverPending": server, "localState": local}, ensure_ascii=False))
        return
    if not server and not local:
        log("没有未完成的上传。")
        return
    if server:
        log("未完成的分片上传(服务端在途):")
        for u in server:
            print("  %s  uploadId=%s…  initiated=%s" % (u["key"], (u["uploadId"] or "")[:12], u.get("initiated")))
    if local:
        log("本地续传记录(本机可自动续传):")
        for u in local:
            print("  %s  partsTotal=%s  <- %s" % (u["key"], u.get("partsTotal"), u.get("path")))
    log("续传:重新执行原 upload 命令。放弃:filehub.py abort <key>。")


def cmd_abort(args):
    me = login()
    space = me.get("space")
    key = args.key
    uid = args.upload_id
    if not uid:
        d = api("/api/upload/uploads")
        matches = [u for u in d.get("uploads", []) if u["key"] == key]
        if not matches:
            die("未找到 key=%s 的未完成上传;用 `uploads` 查看,或用 --upload-id 指定。" % key)
        uid = matches[0]["uploadId"]
    api("/api/upload/abort", {"key": key, "uploadId": uid})
    cleared = 0
    try:
        for fn in os.listdir(STATE_DIR):
            p = os.path.join(STATE_DIR, fn)
            st = _state_load(p)
            if st and st.get("space") == space and st.get("key") == key:
                _state_clear(p)
                cleared += 1
    except FileNotFoundError:
        pass
    log("已放弃上传:%s" % key)
    emit({"ok": True, "aborted": key, "uploadId": uid, "localCleared": cleared})


def cmd_download(args):
    login()
    url = BASE + "/api/download?key=" + urllib.parse.quote(args.key)
    loc = None
    try:
        _noredir.open(urllib.request.Request(url, headers={"X-Requested-With": "x"}), timeout=60)
    except urllib.error.HTTPError as e:
        if e.code in (301, 302, 303, 307, 308):
            loc = e.headers.get("Location")
        elif e.code == 401:
            raw = e.read()
            try:
                info = json.loads(raw)
            except Exception:
                info = {}
            die_auth(info.get("detail") or "下载需要登录。", info.get("hint"))
        else:
            raw = e.read()
            try:
                info = json.loads(raw)
            except Exception:
                info = {}
            detail = info.get("detail") or e.code
            msg = "下载失败:%s" % detail
            if info.get("hint"):
                msg += "\n  提示:" + info["hint"]
            die(msg)
    if not loc:
        die("未取得下载地址")
    # 原样请求预签名 URL(不能让 urllib 改写,否则签名失效)
    try:
        with urllib.request.urlopen(urllib.request.Request(loc), timeout=600, context=_ctx) as r, \
                open(args.save, "wb") as out:
            shutil.copyfileobj(r, out, length=1024 * 1024)
    except urllib.error.HTTPError as e:
        if e.code in (403, 404):
            die("下载失败:对象不存在或不可访问(HTTP %d)。\n"
                "  提示:key 可能拼写有误,或当前口令对应的空间不对(口令即空间)——用 ls 确认,"
                "或确认与上传方使用同一口令。" % e.code)
        die("下载失败:HTTP %d" % e.code)
    except Exception as e:
        die("下载失败:%s" % e)
    log("已下载:%s" % args.save)
    emit({"ok": True, "key": args.key, "saved": args.save})


def cmd_ls(args):
    login()
    prefix = args.prefix or ""
    marker = ""
    items = []
    while True:
        d = api("/api/files?prefix=%s&marker=%s" % (
            urllib.parse.quote(prefix), urllib.parse.quote(marker)))
        for fo in d.get("folders", []):
            if JSON_OUT:
                items.append({"type": "dir", "key": fo})
            else:
                print("DIR   %s" % fo)
        for fi in d.get("files", []):
            if JSON_OUT:
                items.append({"type": "file", "key": fi["key"], "size": fi["size"],
                              "lastModified": fi.get("lastModified")})
            else:
                print("%-10d %s" % (fi["size"], fi["key"]))
        marker = d.get("nextMarker")
        if not marker:
            break
    if JSON_OUT:
        print(json.dumps({"ok": True, "prefix": prefix, "items": items}, ensure_ascii=False))


def cmd_rm(args):
    login()
    d = api("/api/delete", {"keys": args.keys})
    n = len(d.get("deleted", []))
    log("已删除 %d 项" % n)
    emit({"ok": True, "deleted": d.get("deleted", [])})


def cmd_mkdir(args):
    login()
    d = api("/api/mkdir", {"path": args.path})
    log("已创建:%s" % d["key"])
    emit({"ok": True, "key": d["key"]})


# --------------------------------------------------------------------------- #
# 入口
# --------------------------------------------------------------------------- #
def _apply_globals(args):
    global BASE, PW, PW_SOURCE, JSON_OUT, _ctx
    if getattr(args, "url", None):
        BASE = args.url.rstrip("/")
    if getattr(args, "password", None):
        PW = args.password
        PW_SOURCE = "flag"
    if getattr(args, "json", False):
        JSON_OUT = True
    if getattr(args, "insecure", False):
        _ctx.check_hostname = False
        _ctx.verify_mode = ssl.CERT_NONE
        _build_openers()


def main():
    common = argparse.ArgumentParser(add_help=False)
    common.add_argument("--password", "-p", default=None,
                        help="访问口令(口令即空间;优先于 FILEHUB_PASSWORD)")
    common.add_argument("--url", default=None, help="站点地址(默认 https://files.dragonai.tech)")
    common.add_argument("--json", action="store_true", help="以 JSON 输出,便于 agent 解析")
    common.add_argument("--insecure", action="store_true", help="跳过 TLS 校验(仅测试用)")

    p = argparse.ArgumentParser(prog="filehub",
                                description="烛龙智元文件中转站 上传/下载 CLI(口令即空间)")
    sub = p.add_subparsers(dest="cmd", required=True)

    st = sub.add_parser("status", parents=[common], help="连接自检:可达性 / 口令 / 所在空间")
    st.set_defaults(func=cmd_status)

    up = sub.add_parser("upload", parents=[common], help="上传文件(自动选单传/分片,支持断点续传/秒传)")
    up.add_argument("path"); up.add_argument("--folder", default=""); up.add_argument("--name", default="")
    up.add_argument("--no-instant", action="store_true", help="禁用秒传(跳过内容哈希)")
    up.add_argument("--restart", action="store_true", help="忽略并放弃已有续传记录,从头重传")
    up.set_defaults(func=cmd_upload)

    us = sub.add_parser("upload-status", parents=[common], help="查询某文件的续传进度(不上传)")
    us.add_argument("path"); us.add_argument("--folder", default=""); us.add_argument("--name", default="")
    us.set_defaults(func=cmd_upload_status)

    ups = sub.add_parser("uploads", parents=[common], help="列出所有未完成的分片上传")
    ups.set_defaults(func=cmd_uploads)

    ab = sub.add_parser("abort", parents=[common], help="放弃一个未完成的分片上传")
    ab.add_argument("key"); ab.add_argument("--upload-id", default=None, help="可选;不给则按 key 自动查找")
    ab.set_defaults(func=cmd_abort)

    dl = sub.add_parser("download", parents=[common], help="下载文件")
    dl.add_argument("key"); dl.add_argument("save")
    dl.set_defaults(func=cmd_download)

    l = sub.add_parser("ls", parents=[common], help="列举对象")
    l.add_argument("prefix", nargs="?", default="")
    l.set_defaults(func=cmd_ls)

    r = sub.add_parser("rm", parents=[common], help="删除对象")
    r.add_argument("keys", nargs="+")
    r.set_defaults(func=cmd_rm)

    m = sub.add_parser("mkdir", parents=[common], help="新建文件夹")
    m.add_argument("path")
    m.set_defaults(func=cmd_mkdir)

    args = p.parse_args()
    _apply_globals(args)
    args.func(args)


if __name__ == "__main__":
    main()
