#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
CKB 运营端静态站点部署（统一主入口，全程宝塔面板 API，无需 SSH）。
默认宝塔面板主机（最新）：43.139.27.93；站点根：/www/wwwroot/auto-devlop/ckb-operation。
未设置 BT_PANEL_URL 时，面板地址为 https://{DEPLOY_HOST 或默认 IP}:9988。

流程与 soul-api/master.py 对齐：
  探活(GetDiskInfo) → 本地 pnpm 构建 dist → 打包 zip → /files?action=upload
  → /files?action=UnZip 到 dist2 → MvFile 切换 dist/dist2（等价原 dist→dist1→删）→ DeleteFile 删包

环境变量（与 soul-api 共用宝塔相关项）：
  BT_PANEL_URL、BT_API_KEY、BT_UPLOAD_CHUNK_MB（可选，默认 4）
  DEPLOY_HOST（未设 BT_PANEL_URL 时默认 https://{DEPLOY_HOST}:9988）
  DEPLOY_BASE_PATH / DEPLOY_BASE_PATH_PROD / DEPLOY_BASE_PATH_DEV

deploy.py 作为 dev 包装入口：default_profile=dev。
"""

from __future__ import print_function

import argparse
import hashlib
import json
import os
import subprocess
import sys
import tempfile
import time
import zipfile

try:
    import requests

    try:
        import urllib3

        urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
    except Exception:
        pass
except ImportError:
    requests = None

# 与 soul-api/master.py 一致，便于同一套面板密钥
BT_API_KEY_DEFAULT = "qcWubCdlfFjS2b2DMT1lzPFaDfmv1cBT"

# 默认宝塔面板主机（最新 IP）；可用环境变量 DEPLOY_HOST 或 BT_PANEL_URL 覆盖
DEFAULT_DEPLOY_HOST = "43.139.27.93"
DEFAULT_BASE_PATH = "/www/wwwroot/auto-devlop/ckb-operation"

PROFILE_PRESETS = {
    "prod": {
        "title": "ckb-operation",
        "default_base_path": DEFAULT_BASE_PATH,
        "build_cmd": ["pnpm", "build"],
        "build_desc": "pnpm build（正式环境）",
    },
    "dev": {
        "title": "ckb-operation-dev",
        "default_base_path": DEFAULT_BASE_PATH,
        "build_cmd": ["pnpm", "run", "build:dev"],
        "build_desc": "pnpm run build:dev（测试环境）",
    },
}


def resolve_profile(profile_name):
    profile = (profile_name or "prod").strip().lower()
    if profile not in PROFILE_PRESETS:
        raise ValueError("不支持的 profile: %s" % profile_name)
    return profile


def _resolve_base_path(profile, cli_base_path=None):
    preset = PROFILE_PRESETS[profile]
    if cli_base_path:
        return cli_base_path.rstrip("/")

    profile_env_key = "DEPLOY_BASE_PATH_%s" % profile.upper()
    base = (os.environ.get(profile_env_key) or "").strip()
    if not base:
        base = (os.environ.get("DEPLOY_BASE_PATH") or "").strip()
    if not base:
        base = preset["default_base_path"]
    return base.rstrip("/")


def get_cfg(profile, cli_base_path=None):
    base = _resolve_base_path(profile, cli_base_path=cli_base_path)
    host = os.environ.get("DEPLOY_HOST", DEFAULT_DEPLOY_HOST)
    bt_url = (os.environ.get("BT_PANEL_URL") or "").strip().rstrip("/")
    if not bt_url:
        bt_url = "https://%s:9988" % host
    return {
        "profile": profile,
        "title": PROFILE_PRESETS[profile]["title"],
        "build_cmd": list(PROFILE_PRESETS[profile]["build_cmd"]),
        "build_desc": PROFILE_PRESETS[profile]["build_desc"],
        "host": host,
        "base_path": base,
        "dist_path": base + "/dist",
        "dist2_path": base + "/dist2",
        "dist1_path": base + "/dist1",
        "bt_panel_url": bt_url,
        "bt_api_key": os.environ.get("BT_API_KEY", BT_API_KEY_DEFAULT),
    }


def resolve_project_root():
    script_dir = os.path.dirname(os.path.abspath(__file__))
    if os.path.isfile(os.path.join(script_dir, "package.json")):
        return script_dir
    return os.path.dirname(script_dir)


def ensure_dist_ready(root):
    return os.path.isdir(os.path.join(root, "dist")) and os.path.isfile(
        os.path.join(root, "dist", "index.html")
    )


def run_build(root, cfg):
    print("[1/5] 本地构建 %s ..." % cfg["build_desc"])
    use_shell = sys.platform == "win32"
    try:
        r = subprocess.run(
            cfg["build_cmd"],
            cwd=root,
            shell=use_shell,
            timeout=300,
            capture_output=True,
            text=True,
            encoding="utf-8",
            errors="replace",
        )
        if r.returncode != 0:
            print("  [失败] 构建失败，退出码:", r.returncode)
            for line in (r.stdout or "").strip().split("\n")[-10:]:
                if line:
                    print("    " + line)
            for line in (r.stderr or "").strip().split("\n")[-10:]:
                if line:
                    print("    " + line)
            return False
    except subprocess.TimeoutExpired:
        print("  [失败] 构建超时")
        return False
    except FileNotFoundError:
        print("  [失败] 未找到 pnpm，请安装: npm install -g pnpm")
        return False
    except Exception as e:
        print("  [失败] 构建异常:", str(e))
        return False

    if not ensure_dist_ready(root):
        print("  [失败] 未找到 dist/index.html")
        return False
    print("  [成功] 构建完成")
    return True


def pack_dist_zip(root, profile):
    print("[2/5] 打包 dist 为 zip ...")
    dist_dir = os.path.join(root, "dist")
    if not os.path.isdir(dist_dir):
        print("  [失败] 未找到 dist 目录")
        return None

    index_html = os.path.join(dist_dir, "index.html")
    if not os.path.isfile(index_html):
        print("  [失败] 未找到 dist/index.html，请先执行构建")
        return None

    zip_name = "ckb_operation_%s_deploy.zip" % profile
    zip_path = os.path.join(tempfile.gettempdir(), zip_name)
    try:
        with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
            for dirpath, _dirs, filenames in os.walk(dist_dir):
                for name in filenames:
                    full = os.path.join(dirpath, name)
                    arcname = os.path.relpath(full, dist_dir).replace("\\", "/")
                    zf.write(full, arcname)
        print(
            "  [成功] 打包完成: %s (%.2f MB)"
            % (zip_path, os.path.getsize(zip_path) / 1024 / 1024)
        )
        return zip_path
    except Exception as e:
        print("  [失败] 打包异常:", str(e))
        return None


# ---------- 宝塔 API（与 soul-api/master.py 同源精简） ----------


def _bt_signed_post(base_url, key, path, extra_data, timeout=20):
    req_time = int(time.time())
    sk_md5 = hashlib.md5(key.encode()).hexdigest()
    req_token = hashlib.md5(("%s%s" % (req_time, sk_md5)).encode()).hexdigest()
    data = {"request_time": req_time, "request_token": req_token}
    data.update(extra_data or {})
    return requests.post(base_url + path, data=data, timeout=timeout, verify=False)


def _bt_parse_json_response(r):
    if r is None or r.status_code != 200:
        return None
    ct = (r.headers.get("content-type") or "").lower()
    if "json" in ct:
        try:
            return r.json()
        except Exception:
            pass
    t = (r.text or "").lstrip()
    if t.startswith("{"):
        try:
            return json.loads(r.text)
        except Exception:
            pass
    return None


def bt_panel_ping(cfg):
    if not requests:
        return True
    url = (cfg.get("bt_panel_url") or "").rstrip("/")
    key = cfg.get("bt_api_key") or ""
    if not url or not key:
        print("[宝塔API 探活] 缺少 BT_PANEL_URL 或 BT_API_KEY，已跳过")
        return True
    req_time = int(time.time())
    sk_md5 = hashlib.md5(key.encode()).hexdigest()
    req_token = hashlib.md5(("%s%s" % (req_time, sk_md5)).encode()).hexdigest()
    try:
        r = requests.post(
            url + "/system?action=GetDiskInfo",
            data={"request_time": req_time, "request_token": req_token},
            timeout=20,
            verify=False,
        )
        if r.status_code != 200:
            print("[宝塔API 探活] HTTP %s" % r.status_code)
            return False
        ct = (r.headers.get("content-type") or "").lower()
        j = {}
        if "json" in ct:
            try:
                j = r.json()
            except Exception:
                j = {}
        else:
            t = (r.text or "").lstrip()
            if t.startswith("{"):
                try:
                    j = json.loads(r.text)
                except Exception:
                    j = {}
        if isinstance(j, dict) and j.get("status") is False:
            print("[宝塔API 探活] %s" % (j.get("msg") or j))
            return False
        print("[宝塔API 探活] 成功（GetDiskInfo）", flush=True)
        return True
    except Exception as e:
        print("[宝塔API 探活] 异常: %s" % e)
        return False


def _bt_upload_headers():
    return {
        "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
        "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
    }


def _bt_parse_upload_response(r):
    if r is None or r.status_code != 200:
        return "err", None, "HTTP %s" % (r.status_code if r else "?")
    raw = (r.text or "").strip()
    if raw.isdigit():
        return "progress", int(raw), None
    j = _bt_parse_json_response(r)
    if isinstance(j, dict):
        if j.get("status") is True:
            return "ok", None, None
        return "err", None, j.get("msg") or str(j)
    return "err", None, raw[:300] if raw else "empty body"


def bt_upload_file_resumable(cfg, local_path, remote_dir, remote_name):
    if not requests:
        print("  [失败] 需要 requests：pip install requests")
        return False
    url = (cfg.get("bt_panel_url") or "").rstrip("/")
    key = cfg.get("bt_api_key") or ""
    if not url or not key:
        print("  [失败] 缺少 BT_PANEL_URL 或 BT_API_KEY")
        return False
    total = os.path.getsize(local_path)
    chunk_mb = int(os.environ.get("BT_UPLOAD_CHUNK_MB", "4"))
    chunk_size = max(1, chunk_mb) * 1024 * 1024
    f_start = 0
    last_start = -1
    upload_url = url + "/files?action=upload"
    headers = _bt_upload_headers()
    print(
        "  [宝塔API] 上传 %s → %s/%s（共 %.2f MB，每块 %d MB）…"
        % (
            os.path.basename(local_path),
            remote_dir,
            remote_name,
            total / 1024 / 1024,
            chunk_mb,
        )
    )
    try:
        with open(local_path, "rb") as fp:
            while f_start < total:
                if f_start == last_start:
                    print("  [失败] 上传停滞 offset=%s（请删服务器上同名 .upload.tmp 后重试）" % f_start)
                    return False
                last_start = f_start
                fp.seek(f_start)
                buf = fp.read(min(chunk_size, total - f_start))
                if not buf:
                    print("  [失败] 读取本地文件失败 offset=%s" % f_start)
                    return False
                req_time = int(time.time())
                sk_md5 = hashlib.md5(key.encode()).hexdigest()
                req_token = hashlib.md5(("%s%s" % (req_time, sk_md5)).encode()).hexdigest()
                data = {
                    "request_time": str(req_time),
                    "request_token": req_token,
                    "f_path": remote_dir,
                    "f_name": remote_name,
                    "f_size": str(total),
                    "f_start": str(f_start),
                }
                files = {"blob": ("blob", buf, "application/octet-stream")}
                r = requests.post(
                    upload_url,
                    data=data,
                    files=files,
                    headers=headers,
                    timeout=600,
                    verify=False,
                )
                kind, prog, err = _bt_parse_upload_response(r)
                if kind == "ok":
                    print("  [成功] 宝塔上传完成")
                    return True
                if kind == "progress":
                    if prog <= f_start:
                        print("  [失败] 上传偏移未前进: server=%s local=%s" % (prog, f_start))
                        return False
                    f_start = prog
                    continue
                print("  [失败] 上传接口: %s" % (err or kind))
                return False
        print("  [失败] 上传未正常结束（offset=%s size=%s）" % (f_start, total))
        return False
    except Exception as e:
        print("  [失败] 上传异常: %s" % e)
        return False


def bt_delete_file(cfg, remote_path):
    url = (cfg.get("bt_panel_url") or "").rstrip("/")
    key = cfg.get("bt_api_key") or ""
    if not url or not key or not remote_path:
        return False
    j = _bt_parse_json_response(
        _bt_signed_post(url, key, "/files?action=DeleteFile", {"path": remote_path})
    )
    if isinstance(j, dict) and j.get("status") is True:
        return True
    if isinstance(j, dict) and j.get("msg"):
        print("  [宝塔API] DeleteFile: %s" % j.get("msg"))
    return False


def bt_delete_dir(cfg, remote_dir):
    """递归删除目录（不存在时可能返回失败，调用方可忽略）。"""
    url = (cfg.get("bt_panel_url") or "").rstrip("/")
    key = cfg.get("bt_api_key") or ""
    if not url or not key or not remote_dir:
        return False
    j = _bt_parse_json_response(
        _bt_signed_post(
            url,
            key,
            "/files?action=DeleteDir",
            {"path": remote_dir},
            timeout=120,
        )
    )
    if isinstance(j, dict) and j.get("status") is True:
        return True
    return False


def bt_create_dir(cfg, path):
    url = (cfg.get("bt_panel_url") or "").rstrip("/")
    key = cfg.get("bt_api_key") or ""
    if not url or not key or not path:
        return False
    j = _bt_parse_json_response(
        _bt_signed_post(url, key, "/files?action=CreateDir", {"path": path})
    )
    if isinstance(j, dict) and j.get("status") is True:
        return True
    msg = (j.get("msg") if isinstance(j, dict) else "") or ""
    if msg and ("已存在" in msg or "exist" in msg.lower()):
        return True
    if isinstance(j, dict) and j.get("msg"):
        print("  [宝塔API] CreateDir: %s" % j.get("msg"))
    return False


def bt_mv_file(cfg, sfile, dfile):
    url = (cfg.get("bt_panel_url") or "").rstrip("/")
    key = cfg.get("bt_api_key") or ""
    if not url or not key:
        return False, "no url/key"
    j = _bt_parse_json_response(
        _bt_signed_post(
            url,
            key,
            "/files?action=MvFile",
            {"sfile": sfile, "dfile": dfile},
            timeout=120,
        )
    )
    if isinstance(j, dict) and j.get("status") is True:
        return True, None
    msg = j.get("msg") if isinstance(j, dict) else str(j)
    return False, msg


def bt_unzip_remote_zip(cfg, sfile, dfile):
    url = (cfg.get("bt_panel_url") or "").rstrip("/")
    key = cfg.get("bt_api_key") or ""
    coding = "UTF-8"
    for type1 in ("zip", "ZIP"):
        j = _bt_parse_json_response(
            _bt_signed_post(
                url,
                key,
                "/files?action=UnZip",
                {"sfile": sfile, "dfile": dfile, "type1": type1, "coding": coding},
                timeout=300,
            )
        )
        if isinstance(j, dict) and j.get("status") is True:
            print("  [成功] 宝塔解压完成 (type1=%s)" % type1)
            return True
        if isinstance(j, dict) and j.get("msg"):
            print("  [宝塔API] UnZip type1=%s: %s" % (type1, j.get("msg")))
    return False


def deploy_via_btapi(cfg, zip_path):
    print("[3/5] 宝塔 API：准备 dist2、上传、解压 …")
    if not requests:
        print("  [失败] 需要 requests：pip install requests")
        return False

    base = cfg["base_path"].rstrip("/")
    dist = cfg["dist_path"].rstrip("/")
    dist2 = cfg["dist2_path"].rstrip("/")
    dist1 = cfg["dist1_path"].rstrip("/")
    remote_zip_name = "ckb_operation_deploy.zip"
    remote_zip = base + "/" + remote_zip_name
    dfile_dist2 = dist2 + "/"

    bt_delete_dir(cfg, dist1)
    bt_delete_dir(cfg, dist2)
    if not bt_create_dir(cfg, dist2):
        print("  [失败] 无法创建 dist2: %s" % dist2)
        return False

    if not bt_upload_file_resumable(cfg, zip_path, base, remote_zip_name):
        return False

    print("  [宝塔API] 解压 %s → %s …" % (remote_zip, dfile_dist2))
    if not bt_unzip_remote_zip(cfg, remote_zip, dfile_dist2):
        print("  [提示] 解压失败时可检查面板「文件」权限与压缩包格式")
        return False

    if bt_delete_file(cfg, remote_zip):
        print("  [已清理] 远程 %s" % remote_zip)
    else:
        print("  [提示] 未能删除远程压缩包，可在面板手动删: %s" % remote_zip)

    print("[4/5] 宝塔 API：目录切换（等价原 dist→dist1→dist2→dist）…")
    bt_delete_dir(cfg, dist1)

    ok_move_old, err_old = bt_mv_file(cfg, dist, dist1)
    if not ok_move_old:
        if err_old and isinstance(err_old, str):
            low = err_old.lower()
            if "不存在" not in err_old and "not exist" not in low and "不存在" not in low:
                print("  [宝塔API] 移动 dist→dist1: %s" % err_old)

    ok_swap, err_swap = bt_mv_file(cfg, dist2, dist)
    if not ok_swap:
        print("  [失败] 将 dist2 切换为 dist 失败: %s" % err_swap)
        print("  [提示] 可到面板「文件」手动把 %s 改名为 dist" % dist2)
        return False

    if ok_move_old:
        if not bt_delete_dir(cfg, dist1):
            print("  [警告] 未能删除临时目录 dist1: %s（可面板手动删）" % dist1)

    print("  [成功] 新版本已生效: %s" % dist)
    print("[5/5] 完成（纯 API；若 403 请在面板核对站点目录属主 www）")
    return True


def create_parser(default_profile):
    parser = argparse.ArgumentParser(
        description="CKB 静态站点部署（宝塔 API：上传 zip、解压、目录切换）",
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    parser.add_argument(
        "--profile",
        choices=tuple(PROFILE_PRESETS.keys()),
        default=default_profile,
        help="部署档位：prod=正式环境，dev=测试环境",
    )
    parser.add_argument(
        "--base-path",
        default="",
        help="覆盖部署目录（默认按 profile 取 DEPLOY_BASE_PATH 或预设路径）",
    )
    parser.add_argument("--no-build", action="store_true", help="跳过本地构建，直接上传现有 dist")
    parser.add_argument(
        "--skip-bt-ping",
        action="store_true",
        help="跳过宝塔面板 API 探活（GetDiskInfo）",
    )
    return parser


def main(argv=None, default_profile="prod"):
    default_profile = resolve_profile(default_profile)
    parser = create_parser(default_profile=default_profile)
    args = parser.parse_args(argv)

    profile = resolve_profile(args.profile)
    root = resolve_project_root()
    cfg = get_cfg(profile=profile, cli_base_path=(args.base_path or "").strip())

    print("=" * 60)
    print("  %s 部署（宝塔 API）" % cfg["title"])
    print("=" * 60)
    print("  profile: %s" % cfg["profile"])
    print("  面板: %s" % cfg["bt_panel_url"])
    print("  站点根: %s" % cfg["base_path"])
    print("  静态目录: %s" % cfg["dist_path"])
    print("=" * 60)

    if not args.skip_bt_ping:
        if not bt_panel_ping(cfg):
            print("[失败] 宝塔 API 探活未通过（可用 --skip-bt-ping 跳过）")
            return 1

    if not args.no_build:
        if not run_build(root, cfg):
            return 1
    else:
        if not ensure_dist_ready(root):
            print("[错误] 未找到 dist/index.html，请先执行构建或去掉 --no-build")
            return 1
        print("[1/5] 跳过本地构建")

    zip_path = pack_dist_zip(root, profile=profile)
    if not zip_path:
        return 1

    try:
        if not deploy_via_btapi(cfg, zip_path):
            return 1
    finally:
        try:
            if zip_path and os.path.isfile(zip_path):
                os.remove(zip_path)
        except Exception:
            pass

    print("")
    print("  部署完成！站点目录: %s" % cfg["dist_path"])
    return 0


if __name__ == "__main__":
    sys.exit(main())
