import os
import sys
import re
import random
import argparse
import platform
import subprocess
import urllib.request
from datetime import datetime, timezone, timedelta
import json
import time
import threading
import socketserver
from http.server import HTTPServer, BaseHTTPRequestHandler
from urllib.parse import urlparse, parse_qs, unquote
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler
from qiniu import Auth, put_file_v2, CdnManager, BucketManager

# 确保同目录的共享模块可导入
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from artifacts_schema import ARTIFACTS_FILENAME, create_empty_artifacts, now_iso as _now_iso

# 跨平台获取 openclaw 目录
def get_openclaw_path():
    if platform.system() == 'Windows' or os.path.exists('/root/.openclaw'):
        return '/root/.openclaw'
    return os.path.expanduser('~/.openclaw')

claw = os.environ.get("CLAW_NAME", "claw")

BASE_URL = f"https://cdn.yiranlaoshi.com/{claw}"

QINIU_KEY = "T3tgxM7EMx1j4VESw4m4PIfFXoOvBo-wQEOQewXX"
QINIU_TOKEN = "PVZvlKVOjX2RqlV2ILMg-QwpNMssOlpVbaEzypz0"


QINIU_BUCKET = "yiran1"

access_key = QINIU_KEY
secret_key = QINIU_TOKEN
bucket_name = QINIU_BUCKET
q = Auth(access_key, secret_key)
cdn_manager = CdnManager(q)
bucket_manager = BucketManager(q)

# 是否将 __MY_ARTIFACTS__.json 同步到 OSS
# True  = 上传到七牛（旧行为）
# False = 不上传，且主动从七牛删除已有的（新行为）
SYNC_ARTIFACTS_JSON_TO_OSS = False


# 忽略的目录名（路径中只要包含这些目录段就跳过）
IGNORE_DIR_PATTERNS = {'.git', 'node_modules', '__pycache__', '.myclaw', '__history__', 'history'}

# 忽略的文件名
IGNORE_FILE_PATTERNS = {'.myclaw-sync.pid', '.DS_Store', '.gitignore', '.gitkeep', 'history.json'}

# 历史版本目录名和索引文件名
HISTORY_DIR = "__history__"
HISTORY_FILENAME = "history.json"

# 回滚压制表：{ abs_path: 写入时间戳 }
# 回滚时往 workspace 写文件会触发 on_modified，用此表跳过快照，避免产生多余版本
_rollback_suppressed: dict = {}
_ROLLBACK_SUPPRESS_TTL = 3.0  # 秒

# 时间窗口合并：同一文件在此时间窗口内的多次变更只保留最新快照（覆盖，不新增版本）
_HISTORY_MERGE_WINDOW = 20.0  # 秒


def _is_rollback_suppressed(abs_path: str) -> bool:
    ts = _rollback_suppressed.get(abs_path)
    if ts and time.time() - ts < _ROLLBACK_SUPPRESS_TTL:
        return True
    _rollback_suppressed.pop(abs_path, None)
    return False


def _load_history_json(history_json_path: str, workspace_id: str) -> dict:
    if os.path.exists(history_json_path):
        try:
            with open(history_json_path, "r", encoding="utf-8") as f:
                return json.load(f)
        except Exception:
            pass
    return _empty_history(workspace_id)


def save_history_version(workspace_id: str, relative_path: str, file_path: str):
    """把当前文件内容作为新快照写入 __history__/vN/，并更新 history.json。"""
    base_path = get_openclaw_path()
    history_base = os.path.join(base_path, workspace_id, HISTORY_DIR)
    history_json_path = os.path.join(history_base, HISTORY_FILENAME)

    os.makedirs(history_base, exist_ok=True)

    history_data = _load_history_json(history_json_path, workspace_id)

    # 检查该文件最后一次快照是否在合并窗口内
    now_ts = time.time()
    records = history_data.get("records", [])
    last_record = next(
        (r for r in reversed(records) if r.get("path") == relative_path),
        None
    )

    if last_record:
        try:
            last_ts = datetime.fromisoformat(last_record["snapshot_at"]).timestamp()
        except Exception:
            last_ts = 0
        within_window = (now_ts - last_ts) < _HISTORY_MERGE_WINDOW
    else:
        within_window = False

    rel_parts = relative_path.replace("\\", "/").split("/")

    if within_window:
        # 覆盖最后一次快照，不新增版本
        ver_dir_name = last_record["version_dir"]
        dest_path = os.path.join(history_base, ver_dir_name, *rel_parts)
        os.makedirs(os.path.dirname(dest_path), exist_ok=True)
        try:
            with open(file_path, "rb") as src, open(dest_path, "wb") as dst:
                dst.write(src.read())
        except Exception as e:
            print(f"[history] 快照覆盖失败: {e}")
            return
        # 更新该 record 的时间戳
        last_record["snapshot_at"] = now_iso()
        with open(history_json_path, "w", encoding="utf-8") as f:
            json.dump(history_data, f, ensure_ascii=False, indent=2)
        print(f"[history] 合并覆盖快照 {ver_dir_name}: {relative_path}")
    else:
        # 超出时间窗口，新增版本
        next_ver = history_data.get("next_version", 1)
        ver_dir_name = f"v{next_ver}"

        dest_path = os.path.join(history_base, ver_dir_name, *rel_parts)
        os.makedirs(os.path.dirname(dest_path), exist_ok=True)
        try:
            with open(file_path, "rb") as src, open(dest_path, "wb") as dst:
                dst.write(src.read())
        except Exception as e:
            print(f"[history] 快照写入失败: {e}")
            return

        history_data["next_version"] = next_ver + 1
        history_data["records"].append({
            "version": next_ver,
            "version_dir": ver_dir_name,
            "path": relative_path,
            "snapshot_at": now_iso()
        })
        if "current_versions" not in history_data:
            history_data["current_versions"] = {}
        history_data["current_versions"][relative_path] = next_ver

        with open(history_json_path, "w", encoding="utf-8") as f:
            json.dump(history_data, f, ensure_ascii=False, indent=2)

        print(f"[history] 已保存快照 {ver_dir_name}: {relative_path}")


def rollback_to_version(workspace_id: str, relative_path: str, version_dir: str):
    """
    将指定历史版本的文件复制回 workspace，更新 current_versions。
    返回错误信息字符串，成功返回 None。
    """
    base_path = get_openclaw_path()
    history_base = os.path.join(base_path, workspace_id, HISTORY_DIR)
    history_json_path = os.path.join(history_base, HISTORY_FILENAME)

    # 安全检查
    if ".." in version_dir or ".." in relative_path:
        return "invalid path"

    rel_parts = relative_path.replace("\\", "/").split("/")
    src_path = os.path.join(history_base, version_dir, *rel_parts)
    if not os.path.exists(src_path):
        return f"历史文件不存在: {version_dir}/{relative_path}"

    workspace_path = os.path.join(base_path, workspace_id)
    dest_path = os.path.join(workspace_path, *rel_parts)

    # 压制本次写盘触发的 on_modified 快照
    _rollback_suppressed[dest_path] = time.time()

    try:
        os.makedirs(os.path.dirname(dest_path), exist_ok=True)
        with open(src_path, "rb") as src, open(dest_path, "wb") as dst:
            dst.write(src.read())
    except Exception as e:
        _rollback_suppressed.pop(dest_path, None)
        return f"文件写入失败: {e}"

    # 更新 current_versions（从 version_dir 解析版本号）
    history_data = _load_history_json(history_json_path, workspace_id)
    if "current_versions" not in history_data:
        history_data["current_versions"] = {}
    try:
        ver_num = int(version_dir.lstrip("v"))
    except ValueError:
        ver_num = -1
    history_data["current_versions"][relative_path] = ver_num
    with open(history_json_path, "w", encoding="utf-8") as f:
        json.dump(history_data, f, ensure_ascii=False, indent=2)

    print(f"[history] 已回滚 {relative_path} → {version_dir}")
    return None


def _empty_history(workspace_id: str) -> dict:
    return {
        "workspace_id": workspace_id,
        "next_version": 1,
        "current_versions": {},
        "records": []
    }


def _path_has_ignored_dir(filepath):
    """检查路径中是否包含需要忽略的目录段"""
    parts = os.path.normpath(filepath).split(os.sep)
    return any(part in IGNORE_DIR_PATTERNS for part in parts)


class MyHandler(FileSystemEventHandler):

    def _is_file_event(self, event):
        return not getattr(event, "is_directory", False)

    def _should_ignore(self, filepath):
        basename = os.path.basename(filepath)
        if basename in IGNORE_FILE_PATTERNS:
            return True
        if _path_has_ignored_dir(filepath):
            return True
        # 忽略 .openclaw 根目录下的隐藏文件（非 workspace 目录内的）
        if basename.startswith('.') and 'workspace' not in filepath:
            return True
        return False

    # def on_created(self, event):
    #     if not self._is_file_event(event):
    #         return
    #     print(f"🟢 新建: {event.src_path}")
    #     file_gen(event.src_path, "add")

    def on_modified(self, event):
        if not self._is_file_event(event):
            return
        if self._should_ignore(event.src_path):
            return
        print(f"🟡 修改: {event.src_path}")
        file_gen(event.src_path, "add")

    def on_deleted(self, event):
        if not self._is_file_event(event):
            return
        if self._should_ignore(event.src_path):
            return
        print(f"🔴 删除: {event.src_path}")
        file_gen(event.src_path, "delete")

    def on_moved(self, event):
        if not self._is_file_event(event):
            return
        if self._should_ignore(event.src_path):
            return
        print(f"🔵 移动: {event.src_path} -> {getattr(event, 'dest_path', '')}")
        file_gen(event.src_path, "delete")
        if getattr(event, 'dest_path', ''):
            file_gen(event.dest_path, "add")


def now_iso():
    return _now_iso()


def gen_id():
    return f"asset-{int(datetime.now().timestamp() * 1000)}"


def get_type(file_path):
    return file_path.split(".")[-1] if "." in file_path else "unknown"


def init_config(workspace_id, file_path, method="add"):
    base_path = get_openclaw_path()
    file = f"{base_path}/{workspace_id}/.myclaw/{ARTIFACTS_FILENAME}"

    # 确保目录存在
    os.makedirs(os.path.dirname(file), exist_ok=True)

    now = now_iso()

    # 如果文件不存在，先初始化
    if not os.path.exists(file):
        data = create_empty_artifacts(workspace_id, BASE_URL)
    else:
        with open(file, "r", encoding="utf-8") as f:
            try:
                data = json.load(f)
            except:
                data = create_empty_artifacts(workspace_id, BASE_URL)
        # 确保根级字段存在且最新
        if "created_at" not in data:
            data["created_at"] = now
        data["updated_at"] = now
        data["base_url"] = BASE_URL  # 始终同步为当前 CDN 前缀

    if method == "delete":
        # 删除逻辑：将匹配 path 的项过滤掉，并更新根级更新时间
        data["assets"] = [asset for asset in data.get(
            "assets", []) if asset.get("path") != file_path]
        data["updated_at"] = now
    else:
        found = False
        # 查找是否已存在该 path
        for asset in data.get("assets", []):
            if asset.get("path") == file_path:
                # 保证已有 created_at，不被覆盖；更新 updated_at 和 type（以防扩展名变化）
                asset.setdefault("created_at", now)
                asset["updated_at"] = now
                asset["type"] = get_type(file_path)
                found = True
                break

        # 不存在则新增（asset 已包含创建/更改时间）
        if not found:
            new_asset = {
                "id": gen_id(),
                "type": get_type(file_path),
                "path": file_path,
                "created_at": now,
                "updated_at": now
            }
            data["assets"].append(new_asset)

        # 更新根级更新时间
        data["updated_at"] = now

    # 写回文件
    with open(file, "w", encoding="utf-8") as f:
        json.dump(data, f, ensure_ascii=False, indent=2)


def file_gen(path, method):
    # 规范化路径，兼容 Windows 和 *nix
    norm_path = os.path.normpath(path)
    parts = norm_path.split(os.sep)

    # 查找包含 workspace 标识的分段（例如 workspace 或 workspace-xxx）
    space_idx = -1
    for i, p in enumerate(parts):
        if "workspace" in p:
            space_idx = i
            break

    if space_idx == -1:
        # 路径中不包含 workspace，不处理
        return

    space_id = parts[space_idx]
    # 相对路径：workspace 后面的所有段，使用 '/' 作为存储格式（保证跨平台一致）
    relative_parts = parts[space_idx + 1:]
    if not relative_parts:
        # 没有文件名，可能是目录事件，忽略
        return
    relative_path = "/".join(relative_parts)

    # 上传 key 保留 workspace 段（用于 CDN 存储），但写入 JSON 时只保存相对路径
    path_url = f"{space_id}/{relative_path}"
    key = f"{claw}/{path_url}"

    if method == "delete":
        # 删除时不需要上传，只清理配置文件记录，传入的 file_path 不包含 space_id（relative_path）
        init_config(space_id, relative_path, method="delete")
        print(f"🗑️ 已删除配置记录: {relative_path}")
    else:
        # ── 历史版本：把当前内容作为快照存档（仅覆盖行为，非配置文件）──
        # 回滚写盘触发的 on_modified 跳过，避免产生多余版本
        if os.path.basename(path) != ARTIFACTS_FILENAME and not _is_rollback_suppressed(path):
            save_history_version(space_id, relative_path, path)

        # ── 上传到 OSS ───────────────────────────────────────────
        try:
            token = q.upload_token(bucket_name, key, 3600)
            # 直接使用 put_file_v2 上传文件路径，避免读取到空内容时导致 SDK 报错缺少 data 参数
            ret, info = put_file_v2(token, key, path)

            # 刷新 CDN
            cdn_url = f"https://cdn.yiranlaoshi.com/{key}"
            cdn_ret, cdn_info = cdn_manager.refresh_urls([cdn_url])

            # 如果是 __MY_ARTIFACTS__.json 配置文件，只上传不写入（防止循环触发）
            if os.path.basename(path) == ARTIFACTS_FILENAME:
                print(f"🚀 已上传配置文件: {relative_path}")
                print(f"风 CDN刷新: {cdn_url} - {cdn_info.status_code if info else 'unknown'}")
            else:
                # 记录时使用不包含 workspace 的相对路径
                init_config(space_id, relative_path, method="add")
                print(f"🚀 已上传并记录: {relative_path}")
                print(f"风 CDN刷新: {cdn_url} - {cdn_info.status_code if info else 'unknown'}")
        except Exception as e:
            print(f"❌ 上传/读取文件失败: {e}")


def sync_all(workspace_id):
    """全量同步指定 workspace 下所有文件"""
    base_path = get_openclaw_path()
    workspace_path = f"{base_path}/{workspace_id}"

    if not os.path.exists(workspace_path):
        print(f"[错误] workspace 不存在: {workspace_path}")
        return False

    print(f"[全量同步] workspace: {workspace_id}")
    print(f"[全量同步] 目录: {workspace_path}")
    print("-" * 50)

    file_count = 0
    success_count = 0

    for root, dirs, files in os.walk(workspace_path):
        # 剪枝：跳过忽略目录，避免递归进入
        dirs[:] = [d for d in dirs if d not in IGNORE_DIR_PATTERNS]

        for filename in files:
            # 排除配置文件自身（防止循环）
            if filename == ARTIFACTS_FILENAME:
                continue
            if filename in IGNORE_FILE_PATTERNS:
                continue

            local_path = os.path.join(root, filename)
            rel_path = os.path.relpath(local_path, workspace_path)
            relative_path = rel_path.replace(os.sep, "/")

            file_count += 1

            # 上传到七牛云
            path_url = f"{workspace_id}/{relative_path}"
            key = f"{claw}/{path_url}"

            try:
                token = q.upload_token(bucket_name, key, 3600)
                ret, info = put_file_v2(token, key, local_path)
                cdn_url = f"https://cdn.yiranlaoshi.com/{key}"
                cdn_manager.refresh_urls([cdn_url])
                print(f"  ✅ {relative_path}")
                success_count += 1
            except Exception as e:
                print(f"  ❌ {relative_path}: {e}")

            # 写入配置记录
            init_config(workspace_id, relative_path, method="add")

    print("-" * 50)
    print(f"[完成] 共 {file_count} 个文件，成功 {success_count} 个")

    # 反向查对：JSON 中有记录但磁盘上不存在的，剔除
    config_file = f"{workspace_path}/.myclaw/{ARTIFACTS_FILENAME}"
    if os.path.exists(config_file):
        with open(config_file, "r", encoding="utf-8") as f:
            try:
                data = json.load(f)
            except:
                data = None

        if data and data.get("assets"):
            before = len(data["assets"])
            data["assets"] = [
                asset for asset in data["assets"]
                if os.path.exists(os.path.join(workspace_path, asset.get("path", "")))
            ]
            removed = before - len(data["assets"])
            if removed > 0:
                data["updated_at"] = now_iso()
                with open(config_file, "w", encoding="utf-8") as f:
                    json.dump(data, f, ensure_ascii=False, indent=2)
                print(f"[清理] 剔除 {removed} 条已不存在的记录")

    # 最后处理配置文件的 OSS 同步
    key = f"{claw}/{workspace_id}/.myclaw/{ARTIFACTS_FILENAME}"
    if SYNC_ARTIFACTS_JSON_TO_OSS:
        config_path = f"{workspace_path}/.myclaw/{ARTIFACTS_FILENAME}"
        if os.path.exists(config_path):
            print(f"[配置] 上传配置文件...")
            try:
                token = q.upload_token(bucket_name, key, 3600)
                ret, info = put_file_v2(token, key, config_path)
                cdn_url = f"https://cdn.yiranlaoshi.com/{key}"
                cdn_manager.refresh_urls([cdn_url])
                print(f"  ✅ __MY_ARTIFACTS__.json")
            except Exception as e:
                print(f"  ❌ __MY_ARTIFACTS__.json: {e}")
    else:
        print(f"[配置] 从 OSS 删除配置文件...")
        try:
            ret, info = bucket_manager.delete(bucket_name, key)
            if info.status_code == 200 or info.status_code == 612:
                # 612 = 文件不存在，视为成功
                print(f"  ✅ 已从 OSS 删除 __MY_ARTIFACTS__.json")
            else:
                print(f"  ❌ 删除失败: {info.status_code}")
        except Exception as e:
            print(f"  ❌ 删除失败: {e}")

    return True


# ═══════════════════════════════════════════════════════════════
# 模板同步：从 CDN 增量下载到本地
# ═══════════════════════════════════════════════════════════════

_sync_state = {
    'running': False,
    'last_result': None,
    'completed': [],   # 已下载完成的 folder 名列表
    'total': 0,        # 本次需要下载的 folder 总数
}
_sync_lock = threading.Lock()

_SYNC_LOG = os.path.join(get_openclaw_path(), 'sync-templates.log')

def _sync_log(msg):
    line = '[{}] {}\n'.format(datetime.now(timezone.utc).strftime('%H:%M:%S'), msg)
    print(line, end='')
    try:
        with open(_SYNC_LOG, 'a', encoding='utf-8') as f:
            f.write(line)
    except Exception:
        pass

TEMPLATE_CDN_PREFIX_BASE = 'myclaw/live/yiran/skills/yiran-playground-template-use/templates'
TEMPLATE_LOCAL_DIR = os.path.join(get_openclaw_path(), 'skills', 'yiran-playground-template-use', 'templates')
TEMPLATE_LOCAL_INDEX = os.path.join(get_openclaw_path(), 'skills', 'yiran-playground-template-use', 'template-index.json')
TEMPLATE_CDN_INDEX_URL = 'https://cdn.yiranlaoshi.com/myclaw/live/yiran/skills/yiran-playground-template-use/template-index.json'


def _do_sync_templates(cdn_index, local_index, pending):
    """后台线程：按 folder+version 全量下载有变化的模板文件
    pending: list of (folder_name, version_str, is_new)

    流程：
    1. 删除旧版本目录（保证本地只有一个版本）
    2. 下载到临时目录 .tmp/
    3. 下载完成后 rename .tmp/ → v{N}/（版本目录存在即代表就绪）
    """
    import shutil
    try:
        added = []
        updated = []

        _sync_log('开始同步，共 {} 个 folder'.format(len(pending)))

        for folder_name, version_str, is_new in pending:
            _sync_log('{} {}/{} → 下载中...'.format('新增' if is_new else '更新', folder_name, version_str))

            cdn_prefix = '{}/{}/{}/'.format(TEMPLATE_CDN_PREFIX_BASE, folder_name, version_str)
            folder_dir = os.path.join(TEMPLATE_LOCAL_DIR, folder_name)
            version_dir = os.path.join(folder_dir, version_str)
            tmp_dir = os.path.join(folder_dir, '.tmp')

            # 1. 删除旧版本目录和残留临时目录
            if os.path.isdir(folder_dir):
                for entry in os.listdir(folder_dir):
                    if re.match(r'^v\d+$', entry) and entry != version_str:
                        shutil.rmtree(os.path.join(folder_dir, entry), ignore_errors=True)
                        _sync_log('  删除旧版本: {}'.format(entry))
            if os.path.isdir(tmp_dir):
                shutil.rmtree(tmp_dir, ignore_errors=True)

            # 2. 下载到 .tmp/
            os.makedirs(tmp_dir, exist_ok=True)
            cdn_files = _list_files_from_qiniu(cdn_prefix)
            _sync_log('  列举到 {} 个文件'.format(len(cdn_files)))
            for item in cdn_files:
                key = item.get('key', '')
                filename = key[len(cdn_prefix):]
                if not filename:
                    continue
                ok = _download_from_cdn(key, os.path.join(tmp_dir, filename))
                _sync_log('  {} {}'.format('✓' if ok else '✗', filename))

            # 3. rename .tmp/ → v{N}/ （目录存在即代表该版本就绪）
            os.rename(tmp_dir, version_dir)
            _sync_log('  → 已就绪: {}/{}'.format(folder_name, version_str))

            if is_new:
                added.append(folder_name)
            else:
                updated.append(folder_name)

            with _sync_lock:
                _sync_state['completed'].append(folder_name)

        _sync_log('完成。新增: {}, 更新: {}'.format(added, updated))
        with _sync_lock:
            _sync_state['running'] = False
            _sync_state['last_result'] = {'changed': True, 'added': added, 'updated': updated}

    except Exception as e:
        import traceback
        _sync_log('异常: {}\n{}'.format(e, traceback.format_exc()))
        with _sync_lock:
            _sync_state['running'] = False


# ═══════════════════════════════════════════════════════════════
# Fork：从 CDN 克隆他人 workspace 到本地
# ═══════════════════════════════════════════════════════════════

# CDN 根域名（不含 scheme）
CDN_HOST = "cdn.yiranlaoshi.com"

# Fork URL 固定前缀：https://cdn.yiranlaoshi.com/myclaw/show/{claw}/{workspace}/{version}/...
FORK_CDN_PREFIX = "myclaw/show"

# 异步 job 状态表 { job_id -> {"status": "running"|"done"|"failed", ...} }
_fork_jobs = {}


def _list_files_from_qiniu(prefix):
    """按前缀列出七牛 OSS 上的所有文件"""
    marker = None
    files = []
    print(f"[Qiniu] 列举前缀: bucket={bucket_name}, prefix={prefix}")
    while True:
        ret, eof, info = bucket_manager.list(
            bucket_name, prefix=prefix, marker=marker, limit=1000)
        if ret is not None:
            batch = ret.get('items', [])
            files.extend(batch)
            marker = ret.get('marker')
            print(f"[Qiniu] 本批 {len(batch)} 个文件, eof={eof}")
            if eof:
                break
        else:
            print(f"[Qiniu] ❌ list 失败: status={info.status_code}, error={info.text_body}")
            break
    print(f"[Qiniu] 共找到 {len(files)} 个文件")
    return files


def _download_from_cdn(key, local_path):
    """从公共 CDN 下载单个文件"""
    from urllib.parse import quote
    # key 可能含中文，需要对路径部分做 percent-encode（保留 / 分隔符）
    encoded_key = quote(key, safe='/')
    url = f"https://cdn.yiranlaoshi.com/{encoded_key}"
    os.makedirs(os.path.dirname(local_path), exist_ok=True)
    try:
        import ssl
        ctx = ssl.create_default_context()
        ctx.check_hostname = False
        ctx.verify_mode = ssl.CERT_NONE
        with urllib.request.urlopen(url, context=ctx) as resp, open(local_path, 'wb') as f:
            f.write(resp.read())
        return True
    except Exception as e:
        print(f"  ❌ 下载失败 {key}: {e}")
        return False


def _do_fork(job_id, src_clawname, src_workspace, src_version, entry_rel_path=None, target_agent=None):
    """在后台线程中执行 fork
    - target_agent 为 None 或对应目录不存在：新建 workspace
    - target_agent 存在：将文件下载到该 workspace 下的子文件夹
    """
    try:
        now = datetime.now()
        time_id = now.strftime('%d') + 'd' + now.strftime('%M') + 'm'

        base_name = src_workspace
        if base_name.startswith("workspace-"):
            base_name = base_name[len("workspace-"):]
        folder_name = f"{base_name}-v{src_version}-fork-{time_id}"

        base_path = get_openclaw_path()

        # 判断是否使用已有 workspace
        use_existing = False
        if target_agent:
            target_workspace = f"workspace-{target_agent}" if not target_agent.startswith("workspace-") else target_agent
            target_workspace_dir = os.path.join(base_path, target_workspace)
            if os.path.isdir(target_workspace_dir):
                use_existing = True

        if use_existing:
            # 下载到已有 workspace 的子文件夹中
            agent_name = target_agent if not target_agent.startswith("workspace-") else target_agent[len("workspace-"):]
            workspace_name = target_workspace
            target_dir = os.path.join(target_workspace_dir, folder_name)
            print(f"[Fork] 使用已有空间: {workspace_name}, 子文件夹: {folder_name}")
        else:
            # 新建 workspace
            agent_name = folder_name
            workspace_name = f"workspace-{agent_name}"
            target_dir = os.path.join(base_path, workspace_name)
            print(f"[Fork] 新建空间: {workspace_name}")

            mc_result = subprocess.run(
                ["mc", "new", agent_name],
                capture_output=True, text=True
            )
            if mc_result.returncode != 0:
                _fork_jobs[job_id] = {
                    "status": "failed",
                    "error": f"mc new 失败: {mc_result.stderr.strip()}"
                }
                return

        if entry_rel_path:
            print(f"[Fork] 入口文件: {entry_rel_path}")

        # 列出源文件
        prefix = f"{FORK_CDN_PREFIX}/{src_clawname}/{src_workspace}/{src_version}/"
        print(f"[Fork] 列出文件: {prefix}")
        files = _list_files_from_qiniu(prefix)

        if not files:
            _fork_jobs[job_id] = {"status": "failed", "error": "CDN 上未找到任何文件"}
            return

        print(f"[Fork] 找到 {len(files)} 个文件，开始下载...")
        success = 0
        downloaded_rels = []
        for item in files:
            key = item['key']
            rel_path = unquote(key[len(prefix):])
            if not rel_path:
                continue
            # 已有空间模式：rel_path 前加子文件夹前缀
            record_rel = os.path.join(folder_name, rel_path) if use_existing else rel_path
            local_path = os.path.join(target_dir, rel_path)
            if _download_from_cdn(key, local_path):
                success += 1
                downloaded_rels.append(record_rel)
                print(f"  ✓ {record_rel}")

        print(f"[Fork] 完成: 成功 {success}/{len(files)} 个文件")

        # 写入 JSON 记录，入口文件最后写，确保其 updated_at 最新排第一
        entry_decoded = os.path.join(folder_name, unquote(entry_rel_path)) if (use_existing and entry_rel_path) else (unquote(entry_rel_path) if entry_rel_path else None)
        for rel in downloaded_rels:
            if rel != entry_decoded:
                init_config(workspace_name, rel, method="add")
        if entry_decoded and entry_decoded in downloaded_rels:
            init_config(workspace_name, entry_decoded, method="add")
            print(f"[Fork] 入口文件已置顶: {entry_decoded}")

        # 文件到位后再发分析消息
        fork_message = (
            "你好！请你仔细阅读我 workspace 下的所有文件，"
            "简单介绍一下这个项目有哪些资源、大概是做什么用的，"
            "几句话说清楚就好，不用太长。"
        )
        try:
            subprocess.Popen(
                ["openclaw", "agent", "--agent", agent_name, "--message", fork_message, "--json"],
                stdout=subprocess.DEVNULL,
                stderr=subprocess.DEVNULL
            )
            print(f"[Fork] 已发送分析消息 → {agent_name}")
        except Exception as e:
            print(f"[Fork] 发送消息失败（非致命）: {e}")

        _fork_jobs[job_id] = {
            "status": "done",
            "workspace": workspace_name,
            "folder": folder_name if use_existing else None,
            "files": success,
            "total": len(files)
        }

    except Exception as e:
        print(f"[Fork] 异常: {e}")
        _fork_jobs[job_id] = {"status": "failed", "error": str(e)}


def start_fork(remote_url, target_agent=None):
    """
    解析 remote_url，生成 job_id，在后台线程执行 fork。
    URL 格式: https://cdn.yiranlaoshi.com/myclaw/show/{clawname}/{workspace}/{version}/...
    target_agent: 可选，指定已有 workspace 的 agent 名（不含 workspace- 前缀亦可）
    """
    parsed = urlparse(remote_url)
    # path: /myclaw/show/{clawname}/{workspace}/{version}/...
    parts = parsed.path.strip('/').split('/')
    # parts: ['myclaw', 'show', clawname, workspace, version, ...]
    if len(parts) < 5 or parts[0] != 'myclaw' or parts[1] != 'show':
        return None, "URL 格式不正确，期望 .../myclaw/show/{claw}/{workspace}/{version}/..."

    src_clawname = parts[2]
    src_workspace = parts[3]
    src_version = parts[4]
    # parts[5:] 是入口文件相对路径（URL 中 version 之后的部分）
    entry_rel_path = '/'.join(parts[5:]) if len(parts) > 5 else None

    if not src_workspace.startswith('workspace'):
        return None, f"无法识别 workspace: {src_workspace}"

    job_id = f"fork-{int(time.time() * 1000)}"
    _fork_jobs[job_id] = {"status": "running"}

    t = threading.Thread(
        target=_do_fork,
        args=(job_id, src_clawname, src_workspace, src_version, entry_rel_path, target_agent),
        daemon=True
    )
    t.start()

    return job_id, None


# ═══════════════════════════════════════════════════════════════
# 内嵌 HTTP API 服务
# ═══════════════════════════════════════════════════════════════

API_PORT = int(os.environ.get("MYCLAW_API_PORT", "18800"))


class MyclawAPIHandler(BaseHTTPRequestHandler):
    """轻量 HTTP API，供前端获取作品列表和配置"""
    # 使用默认 HTTP/1.0 — 天然 connection close，无 keep-alive 阻塞问题

    def _cors_headers(self):
        self.send_header('Access-Control-Allow-Origin', '*')
        self.send_header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
        self.send_header('Access-Control-Allow-Headers', 'Content-Type')
        self.send_header('Access-Control-Allow-Private-Network', 'true')
        self.send_header('Cache-Control', 'no-cache, no-store, must-revalidate')

    def _send_json(self, data, status=200):
        """统一 JSON 响应：自动设 Content-Type / Content-Length / CORS"""
        body = json.dumps(data, ensure_ascii=False).encode('utf-8')
        self.send_response(status)
        self._cors_headers()
        self.send_header('Content-Type', 'application/json; charset=utf-8')
        self.send_header('Content-Length', str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def _send_json_raw(self, raw_bytes, status=200):
        """发送已编码的 JSON 字节（跳过二次序列化）"""
        self.send_response(status)
        self._cors_headers()
        self.send_header('Content-Type', 'application/json; charset=utf-8')
        self.send_header('Content-Length', str(len(raw_bytes)))
        self.end_headers()
        self.wfile.write(raw_bytes)

    def do_OPTIONS(self):
        self.send_response(200)
        self._cors_headers()
        self.send_header('Content-Length', '0')
        self.end_headers()

    def _parse_path(self):
        """解析并标准化请求路径，兼容 nginx /sync 前缀反代"""
        parsed = urlparse(self.path)
        path = parsed.path.rstrip('/')
        # nginx: location /sync → proxy_pass http://127.0.0.1:8080/sync
        # 请求路径会带 /sync 前缀，剥掉后统一路由
        if path.startswith('/sync'):
            path = path[len('/sync'):]
        if not path:
            path = '/'
        params = parse_qs(parsed.query)
        return path, params

    def do_POST(self):
        path, params = self._parse_path()

        if path == '/api/resync':
            return self._handle_resync(params)
        elif path == '/api/fork':
            return self._handle_fork()
        elif path == '/api/history/rollback':
            return self._handle_rollback()
        elif path == '/api/agents/trash':
            return self._handle_agents_trash()
        elif path == '/api/template/copy':
            return self._handle_template_copy()
        elif path == '/api/template/deploy':
            return self._handle_template_deploy()
        else:
            self._send_json({"error": "not found"}, 404)

    def do_GET(self):
        path, params = self._parse_path()

        if path == '/api/config':
            return self._handle_config()
        elif path == '/api/artifacts':
            return self._handle_artifacts(params)
        elif path == '/api/history':
            return self._handle_history(params)
        elif path == '/api/history/file':
            return self._handle_history_file(params)
        elif path == '/api/fork/status':
            return self._handle_fork_status(params)
        elif path == '/api/agents':
            return self._handle_agents_list()
        elif path == '/api/file':
            return self._handle_file(params)
        elif path == '/api/sync-templates':
            return self._handle_sync_templates(params)
        elif path == '/api/sync-status':
            return self._handle_sync_status()
        else:
            self._send_json({"error": "not found"}, 404)

    def _handle_agents_list(self):
        """GET /api/agents → 复用 mc list-agents --json"""
        try:
            ret = subprocess.run(
                ['mc', 'list-agents', '--json'],
                capture_output=True, timeout=15, text=True
            )
            if ret.returncode == 0:
                self._send_json_raw(ret.stdout.strip().encode('utf-8'))
            else:
                self._send_json({'error': ret.stderr.strip() or 'mc list-agents 执行失败'}, 500)
        except FileNotFoundError:
            self._send_json({'error': 'mc 命令未找到，请确认 MyClaw 已安装'}, 500)
        except subprocess.TimeoutExpired:
            self._send_json({'error': 'mc list-agents 超时'}, 500)
        except Exception as e:
            self._send_json({'error': str(e)}, 500)

    def _handle_agents_trash(self):
        """POST /api/agents/trash  Body: {"paths": [...]} → 复用 mc trash-paths"""
        try:
            length = int(self.headers.get('Content-Length', 0))
            body = json.loads(self.rfile.read(length).decode('utf-8')) if length else {}
        except Exception:
            self._send_json({'error': '请求体解析失败'}, 400)
            return

        paths = [p for p in body.get('paths', []) if p and isinstance(p, str)]
        if not paths:
            self._send_json({'error': '缺少有效的 paths 参数'}, 400)
            return

        try:
            ret = subprocess.run(
                ['mc', 'trash-paths', '--json'] + paths,
                capture_output=True, timeout=30, text=True
            )
            if ret.returncode in (0, 1):  # 1 = 部分失败，仍返回 JSON
                self._send_json_raw(ret.stdout.strip().encode('utf-8'))
            else:
                self._send_json({'error': ret.stderr.strip() or 'mc trash-paths 执行失败'}, 500)
        except FileNotFoundError:
            self._send_json({'error': 'mc 命令未找到，请确认 MyClaw 已安装'}, 500)
        except subprocess.TimeoutExpired:
            self._send_json({'error': 'mc trash-paths 超时'}, 500)
        except Exception as e:
            self._send_json({'error': str(e)}, 500)

    def _handle_config(self):
        """GET /api/config → 返回 claw 名称和 CDN base_url"""
        self._send_json({"claw": claw, "base_url": BASE_URL})

    def _handle_artifacts(self, params):
        """GET /api/artifacts?workspace=xxx → 直接从磁盘读取 __MY_ARTIFACTS__.json"""
        workspace = params.get('workspace', [''])[0]
        if not workspace:
            self._send_json({"error": "missing workspace param"}, 400)
            return

        base_path = get_openclaw_path()
        json_path = os.path.join(base_path, workspace, '.myclaw', ARTIFACTS_FILENAME)

        if not os.path.exists(json_path):
            self._send_json(create_empty_artifacts(workspace, BASE_URL))
            return

        try:
            with open(json_path, 'r', encoding='utf-8') as f:
                content = f.read()
            self._send_json_raw(content.encode('utf-8'))
        except Exception as e:
            self._send_json({"error": str(e)}, 500)

    def _handle_history(self, params):
        """GET /api/history?workspace=xxx → 返回 __history__/history.json"""
        workspace = params.get('workspace', [''])[0]
        if not workspace:
            self._send_json({"error": "missing workspace param"}, 400)
            return

        base_path = get_openclaw_path()
        json_path = os.path.join(base_path, workspace, HISTORY_DIR, HISTORY_FILENAME)

        if not os.path.exists(json_path):
            self._send_json({"workspace_id": workspace, "next_version": 1, "records": []})
            return

        try:
            with open(json_path, 'r', encoding='utf-8') as f:
                content = f.read()
            self._send_json_raw(content.encode('utf-8'))
        except Exception as e:
            self._send_json({"error": str(e)}, 500)

    def _handle_history_file(self, params):
        """GET /api/history/file?workspace=xxx&version=v1&path=xxx → 返回历史版本文件内容"""
        workspace = params.get('workspace', [''])[0]
        version = params.get('version', [''])[0]
        rel_path = params.get('path', [''])[0]

        if not workspace or not version or not rel_path:
            self._send_json({"error": "missing params"}, 400)
            return

        # 安全检查：防止路径穿越
        if '..' in version or '..' in rel_path:
            self._send_json({"error": "invalid path"}, 400)
            return

        base_path = get_openclaw_path()
        rel_parts = rel_path.replace('\\', '/').split('/')
        file_path = os.path.join(base_path, workspace, HISTORY_DIR, version, *rel_parts)

        if not os.path.exists(file_path):
            self._send_json({"error": "file not found"}, 404)
            return

        try:
            with open(file_path, 'rb') as f:
                content = f.read()
            # 根据扩展名设置 Content-Type
            ext = rel_path.rsplit('.', 1)[-1].lower() if '.' in rel_path else ''
            ct_map = {
                'html': 'text/html; charset=utf-8',
                'htm': 'text/html; charset=utf-8',
                'css': 'text/css; charset=utf-8',
                'js': 'application/javascript; charset=utf-8',
                'json': 'application/json; charset=utf-8',
            }
            content_type = ct_map.get(ext, 'text/plain; charset=utf-8')
            self.send_response(200)
            self._cors_headers()
            self.send_header('Content-Type', content_type)
            self.send_header('Content-Length', str(len(content)))
            self.end_headers()
            self.wfile.write(content)
        except Exception as e:
            self._send_json({"error": str(e)}, 500)

    def _handle_rollback(self):
        """POST /api/history/rollback  Body: {"workspace":"...","version":"v2","path":"..."}"""
        try:
            length = int(self.headers.get('Content-Length', 0))
            body = json.loads(self.rfile.read(length).decode('utf-8')) if length else {}
        except Exception:
            self._send_json({"error": "请求体解析失败"}, 400)
            return

        workspace = body.get('workspace', '')
        version = body.get('version', '')
        rel_path = body.get('path', '')

        if not workspace or not version or not rel_path:
            self._send_json({"error": "缺少参数"}, 400)
            return

        err = rollback_to_version(workspace, rel_path, version)
        if err:
            self._send_json({"ok": False, "error": err}, 400)
        else:
            self._send_json({"ok": True})

    def _handle_resync(self, params):
        """POST /api/resync?workspace=xxx → 触发全量同步"""
        workspace = params.get('workspace', [''])[0]
        if not workspace:
            self._send_json({"error": "missing workspace param"}, 400)
            return

        print(f"[API] 收到 resync 请求: {workspace}")
        try:
            result = sync_all(workspace)
            self._send_json({"ok": result, "workspace": workspace})
        except Exception as e:
            self._send_json({"ok": False, "error": str(e)}, 500)

    def _handle_fork(self):
        """POST /api/fork  Body: {"url": "...", "targetAgent": "myspace"(可选), "clawName": "alice"(可选)}"""
        try:
            length = int(self.headers.get('Content-Length', 0))
            body = json.loads(self.rfile.read(length).decode('utf-8')) if length else {}
            remote_url = body.get('url', '')
            target_agent = body.get('targetAgent', None) or None
        except Exception:
            self._send_json({"error": "请求体解析失败"}, 400)
            return

        if not remote_url:
            self._send_json({"error": "缺少 url 参数"}, 400)
            return

        job_id, err = start_fork(remote_url, target_agent)
        if err:
            self._send_json({"error": err}, 400)
            return

        self._send_json({"job_id": job_id})

    def _handle_fork_status(self, params):
        """GET /api/fork/status?job_id=xxx"""
        job_id = params.get('job_id', [''])[0]
        if not job_id or job_id not in _fork_jobs:
            self._send_json({"error": "job 不存在"}, 404)
            return
        self._send_json(_fork_jobs[job_id])

    def _skill_scripts_root(self):
        """返回 yiran-playground-template-use/scripts 的绝对路径"""
        return os.path.join(
            get_openclaw_path(), 'skills',
            'yiran-playground-template-use', 'scripts'
        )

    def _resolve_template_path(self, folder):
        """校验 folder 并返回模板最新版本绝对路径；非法或未就绪时直接响应并返回 None"""
        if not folder or '..' in folder or '/' in folder or '\\' in folder:
            self._send_json({'error': 'invalid folder'}, 400)
            return None
        folder_dir = os.path.join(
            get_openclaw_path(), 'skills',
            'yiran-playground-template-use', 'templates', folder
        )
        # 找最新 v{N} 子目录（目录存在即代表就绪）
        max_v = 0
        version_path = None
        if os.path.isdir(folder_dir):
            for entry in os.listdir(folder_dir):
                m = re.match(r'^v(\d+)$', entry)
                if m:
                    v = int(m.group(1))
                    if v > max_v:
                        max_v = v
                        version_path = os.path.join(folder_dir, entry)
        if not version_path:
            self._send_json({'error': '模板尚未下载完成，请稍后重试', 'code': 'not_ready'}, 409)
            return None
        return version_path

    def _run_skill_script(self, cmd, timeout=60):
        """运行 skill 脚本，解析 stdout JSON 并返回 (data, err)"""
        try:
            result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
            output = (result.stdout or '').strip()
            if output:
                try:
                    return json.loads(output), None
                except json.JSONDecodeError:
                    pass
            return None, result.stderr.strip() or output or '未知错误'
        except subprocess.TimeoutExpired:
            return None, f'执行超时（{timeout}s）'
        except Exception as e:
            return None, str(e)

    def _handle_template_copy(self):
        """POST /api/template/copy  Body: {"folder": "a100_...", "workspace": "workspace-xxx"}
        → deploy_to_workspace.py <template_path> <workspace_abs_path>   （不加 --with-tui）"""
        try:
            length = int(self.headers.get('Content-Length', 0))
            body = json.loads(self.rfile.read(length).decode('utf-8')) if length else {}
        except Exception:
            self._send_json({'error': '请求体解析失败'}, 400)
            return

        folder = body.get('folder', '')
        workspace = body.get('workspace', '')  # e.g. "workspace-xxx"
        if not workspace:
            self._send_json({'error': '缺少 workspace 参数'}, 400)
            return

        template_path = self._resolve_template_path(folder)
        if template_path is None:
            return

        workspace_abs = os.path.join(get_openclaw_path(), workspace)
        script = os.path.join(self._skill_scripts_root(), 'deploy_to_workspace.py')

        data, err = self._run_skill_script(
            [sys.executable, script, template_path, workspace_abs],
            timeout=30
        )
        if err:
            self._send_json({'ok': False, 'error': err}, 500)
        else:
            self._send_json(data)

    def _handle_template_deploy(self):
        """POST /api/template/deploy  Body: {"folder": "a100_...", "agent": "playgrounds"}
        agent 默认 "playgrounds"；不传则自动生成唯一名 tpl-MMDDHHMM。
        流程：mc new {agent}（已存在忽略）→ deploy_to_workspace.py --with-tui
        """
        try:
            length = int(self.headers.get('Content-Length', 0))
            body = json.loads(self.rfile.read(length).decode('utf-8')) if length else {}
        except Exception:
            self._send_json({'error': '请求体解析失败'}, 400)
            return

        folder = body.get('folder', '')
        template_path = self._resolve_template_path(folder)
        if template_path is None:
            return

        if body.get('agent'):
            agent_name = body['agent']
        else:
            # 作品编号-版本号-小时分钟，例如 a104-v1-1911
            series_num = re.match(r'^([a-z]+\d+)_', folder)
            series_num = series_num.group(1) if series_num else 'tpl'
            version_str = os.path.basename(template_path)  # template_path 末段就是 v1/v2...
            agent_name = '{}-{}-{}'.format(series_num, version_str, datetime.now().strftime('%H%M'))

        # mc new（已存在则忽略错误）
        try:
            ret = subprocess.run(['mc', 'new', agent_name], capture_output=True, text=True, timeout=30)
            output = (ret.stdout or '') + (ret.stderr or '')
            if ret.returncode != 0 and '已存在' not in output and 'exists' not in output.lower():
                self._send_json({'ok': False, 'error': 'mc new 失败: ' + output.strip()}, 500)
                return
        except Exception as e:
            self._send_json({'ok': False, 'error': str(e)}, 500)
            return

        workspace_path = os.path.join(get_openclaw_path(), 'workspace-' + agent_name)
        script = os.path.join(self._skill_scripts_root(), 'deploy_to_workspace.py')

        data, err = self._run_skill_script(
            [sys.executable, script, template_path, workspace_path, '--with-tui'],
            timeout=60
        )
        if err:
            self._send_json({'ok': False, 'error': err}, 500)
        else:
            self._send_json(data)

    def _handle_file(self, params):
        """GET /api/file?path=relative/path → 通用文件服务，路径相对于 .openclaw 根目录
        安全：os.path.realpath 解析后必须以 openclaw 根为前缀，防止路径穿越"""
        rel_path = params.get('path', [''])[0]
        if not rel_path:
            self._send_json({"error": "missing path param"}, 400)
            return

        base_path = os.path.realpath(get_openclaw_path())
        abs_path = os.path.realpath(os.path.join(base_path, rel_path))

        # 路径必须在 .openclaw 目录内
        if not abs_path.startswith(base_path + os.sep):
            self._send_json({"error": "forbidden"}, 403)
            return

        if not os.path.isfile(abs_path):
            self._send_json({"error": "file not found"}, 404)
            return

        try:
            with open(abs_path, 'rb') as f:
                content = f.read()

            ext = abs_path.rsplit('.', 1)[-1].lower() if '.' in abs_path else ''
            ct_map = {
                'html': 'text/html; charset=utf-8',
                'htm':  'text/html; charset=utf-8',
                'css':  'text/css; charset=utf-8',
                'js':   'application/javascript; charset=utf-8',
                'json': 'application/json; charset=utf-8',
                'txt':  'text/plain; charset=utf-8',
                'md':   'text/plain; charset=utf-8',
                'png':  'image/png',
                'jpg':  'image/jpeg',
                'jpeg': 'image/jpeg',
                'gif':  'image/gif',
                'svg':  'image/svg+xml',
                'webp': 'image/webp',
            }
            content_type = ct_map.get(ext, 'application/octet-stream')

            self.send_response(200)
            self._cors_headers()
            self.send_header('Content-Type', content_type)
            self.send_header('Content-Length', str(len(content)))
            self.end_headers()
            self.wfile.write(content)
        except Exception as e:
            self._send_json({"error": str(e)}, 500)

    def _handle_sync_templates(self, params=None):
        """GET /api/sync-templates[?force=1]
        快速比对 CDN index_updated_at 与本地，若一致立即返回 {changed: false}。
        若有变化，启动后台线程下载，立即返回 {changed: true, syncing: true}。
        force=1：删除本地 index，强制重新全量比对。
        """
        # force=1：清除本地 index 强制重新下载
        if params and params.get('force', [''])[0] == '1':
            if os.path.isfile(TEMPLATE_LOCAL_INDEX):
                os.remove(TEMPLATE_LOCAL_INDEX)
        # 快速拉取 CDN index（仅 JSON，通常 <1s）
        try:
            import ssl
            ctx = ssl.create_default_context()
            ctx.check_hostname = False
            ctx.verify_mode = ssl.CERT_NONE
            url_with_ts = TEMPLATE_CDN_INDEX_URL + '?t=' + str(int(time.time()))
            with urllib.request.urlopen(url_with_ts, timeout=10, context=ctx) as resp:
                cdn_index = json.loads(resp.read().decode('utf-8'))
        except Exception as e:
            self._send_json({'error': 'CDN index 获取失败: ' + str(e)}, 502)
            return

        # 读取本地 index
        local_index = {}
        if os.path.isfile(TEMPLATE_LOCAL_INDEX):
            try:
                with open(TEMPLATE_LOCAL_INDEX, 'r', encoding='utf-8') as f:
                    local_index = json.load(f)
            except Exception:
                pass

        cdn_updated_at = cdn_index.get('index_updated_at', '')
        local_updated_at = local_index.get('index_updated_at', '')

        if cdn_updated_at and cdn_updated_at == local_updated_at:
            self._send_json({'changed': False})
            return

        # 计算需要下载的 folder 列表（新格式：index.templates 为 {id: record} 平铺字典）
        # 就绪标准：{folder}/{version}/ 目录存在即代表完整就绪
        cdn_templates = cdn_index.get('templates', {})
        local_templates = local_index.get('templates', {})
        pending = []  # list of (folder_name, version_str, is_new)
        for tid, record in cdn_templates.items():
            folder_name = record.get('文件夹名', '')
            version_str = record.get('version', 'v1')
            if not folder_name:
                continue
            version_dir = os.path.join(TEMPLATE_LOCAL_DIR, folder_name, version_str)
            if os.path.isdir(version_dir):
                continue  # 本地已有该版本，无需下载
            is_new = tid not in local_templates
            pending.append((folder_name, version_str, is_new))

        if not pending:
            # index_updated_at 变了但所有 folder 都没变，直接更新 index 即可
            try:
                os.makedirs(os.path.dirname(TEMPLATE_LOCAL_INDEX), exist_ok=True)
                with open(TEMPLATE_LOCAL_INDEX, 'w', encoding='utf-8') as f:
                    json.dump(cdn_index, f, ensure_ascii=False, indent=2)
            except Exception:
                pass
            self._send_json({'changed': False})
            return

        # 已有同步在跑，直接返回当前进度
        with _sync_lock:
            if _sync_state['running']:
                self._send_json({
                    'changed': True, 'syncing': True,
                    'completed': list(_sync_state['completed']),
                    'total': _sync_state['total'],
                })
                return

        # 先写 index.json，让前端列表立刻出现
        try:
            os.makedirs(os.path.dirname(TEMPLATE_LOCAL_INDEX), exist_ok=True)
            with open(TEMPLATE_LOCAL_INDEX, 'w', encoding='utf-8') as f:
                json.dump(cdn_index, f, ensure_ascii=False, indent=2)
        except Exception as e:
            self._send_json({'error': 'index 写入失败: ' + str(e)}, 500)
            return

        # 初始化状态，启动后台线程
        with _sync_lock:
            _sync_state['running'] = True
            _sync_state['last_result'] = None
            _sync_state['completed'] = []
            _sync_state['total'] = len(pending)

        t = threading.Thread(
            target=_do_sync_templates,
            args=(cdn_index, local_index, pending),
            daemon=True,
        )
        t.start()
        self._send_json({
            'changed': True, 'syncing': True,
            'completed': [],
            'total': len(pending),
        })

    def _handle_sync_status(self):
        """GET /api/sync-status
        返回当前后台同步进度。
        运行中: {running: true, completed: [...], total: N}
        完成后: {running: false, result: {changed, added, updated}}
        """
        with _sync_lock:
            running = _sync_state['running']
            completed = list(_sync_state['completed'])
            total = _sync_state['total']
            last_result = _sync_state['last_result']

        # 扫描磁盘，返回实际已就绪的 folder 列表（v{N}/ 目录存在即就绪）
        ready = []
        if os.path.isdir(TEMPLATE_LOCAL_DIR):
            for folder in os.listdir(TEMPLATE_LOCAL_DIR):
                folder_path = os.path.join(TEMPLATE_LOCAL_DIR, folder)
                if not os.path.isdir(folder_path):
                    continue
                for version_entry in os.listdir(folder_path):
                    if re.match(r'^v\d+$', version_entry) and os.path.isdir(os.path.join(folder_path, version_entry)):
                        ready.append(folder)
                        break

        if running:
            self._send_json({'running': True, 'completed': completed, 'total': total, 'ready': ready})
        else:
            self._send_json({'running': False, 'result': last_result, 'ready': ready})

    def log_message(self, format, *args):
        # 静默日志，避免轮询刷屏
        pass


def cleanup_ignored_assets(workspace_id):
    """启动时清理 __MY_ARTIFACTS__.json 中属于忽略目录的旧记录"""
    base_path = get_openclaw_path()
    file = f"{base_path}/{workspace_id}/.myclaw/{ARTIFACTS_FILENAME}"
    if not os.path.exists(file):
        return
    try:
        with open(file, "r", encoding="utf-8") as f:
            data = json.load(f)
    except Exception:
        return

    before = len(data.get("assets", []))
    data["assets"] = [
        asset for asset in data.get("assets", [])
        if not _path_has_ignored_dir(asset.get("path", ""))
        and os.path.basename(asset.get("path", "")) not in IGNORE_FILE_PATTERNS
    ]
    after = len(data["assets"])

    if before != after:
        data["updated_at"] = now_iso()
        with open(file, "w", encoding="utf-8") as f:
            json.dump(data, f, ensure_ascii=False, indent=2)
        print(f"[cleanup] 清理忽略文件记录: {before - after} 条（{workspace_id}）")
    else:
        print(f"[cleanup] 无需清理（{workspace_id}）")


def start_api_server():
    """在后台线程启动 HTTP API 服务（多线程，避免 resync 阻塞轮询）"""
    class ThreadedHTTPServer(socketserver.ThreadingMixIn, HTTPServer):
        allow_reuse_address = True
        daemon_threads = True

    server = ThreadedHTTPServer(('0.0.0.0', API_PORT), MyclawAPIHandler)
    thread = threading.Thread(target=server.serve_forever, daemon=True)
    thread.start()
    print(f"[myclaw-api] ✅ HTTP API 服务已启动: http://127.0.0.1:{API_PORT}")
    print(f"[myclaw-api]   GET /api/config     → claw 配置")
    print(f"[myclaw-api]   GET /api/artifacts   → 作品列表")


if __name__ == "__main__":
    parser = argparse.ArgumentParser(description="文件同步服务")
    parser.add_argument("--agent", help="启动前先全量同步指定 workspace")
    parser.add_argument("--port", type=int, default=API_PORT, help="API 服务端口 (默认 18800)")
    args = parser.parse_args()

    API_PORT = args.port

    base_path = get_openclaw_path()
    path = base_path

    # 启动时清理所有 workspace 中的忽略文件记录
    for entry in os.scandir(base_path):
        if entry.is_dir() and "workspace" in entry.name:
            cleanup_ignored_assets(entry.name)

    # 如果指定了 --agent，先全量同步
    if args.agent:
        if not sync_all(args.agent):
            sys.exit(1)
        print("")

    # 启动 HTTP API 服务
    start_api_server()

    event_handler = MyHandler()
    observer = Observer()
    observer.schedule(event_handler, path, recursive=True)

    observer.start()
    print(f"开始监听目录: {path}")

    try:
        while True:
            time.sleep(1)
    except KeyboardInterrupt:
        observer.stop()

    observer.join()