"""Files 路由(user-rooted,不绑 task)+ 用户磁盘用量 + pptx 在线预览。

文件面板的目录树 mutation 入口(DESIGN §7.4):顶层目录 rename 复用
core.working_dirs 的 DB-aware 服务，delete/move 走本路由状态闸。路径安全原语在
web/userfiles.py。
"""
from __future__ import annotations

import asyncio
import os
import tempfile
from pathlib import Path
from uuid import UUID

from fastapi import Depends, File, Form, HTTPException, UploadFile
from fastapi.responses import FileResponse
from sqlalchemy import func, select

from core.paths import from_db_path, to_db_path
from core.storage import session_scope
from core.storage.models import Task
from core.working_dirs import (
    WorkingDirConflictError,
    WorkingDirRenameError,
    rename_working_dir,
)

from ..common import norm_path
from ..schemas import (
    FileDeleteRequest,
    FileMkdirRequest,
    FileRenameRequest,
    FileTransferRequest,
)
from ..userfiles import (
    enumerate_files,
    load_user_root,
    rel_to,
    safe_join,
    system_wd_names,
    validate_transfer,
)

# pptx→PDF 预览:按解析后的 pptx 绝对路径加锁,防同一文件并发重复转换(DESIGN §8.3)。
_pptx_preview_locks: dict[str, asyncio.Lock] = {}


def _numbered_upload_name(filename: str, index: int) -> str:
    """在完整扩展名前追加编号，例如 data.tar.gz -> data (1).tar.gz。"""
    path = Path(filename)
    suffix = "" if filename.startswith(".") and filename.count(".") == 1 else "".join(path.suffixes)
    stem = filename[:-len(suffix)] if suffix else filename
    return f"{stem} ({index}){suffix}"


def _write_upload_without_overwrite(dest_dir: Path, filename: str, data: bytes) -> Path:
    """以原子独占创建保存上传；并发重名时继续递增编号。"""
    index = 0
    while True:
        candidate = filename if index == 0 else _numbered_upload_name(filename, index)
        dest = dest_dir / candidate
        try:
            fd = os.open(dest, os.O_WRONLY | os.O_CREAT | os.O_EXCL)
        except FileExistsError:
            index += 1
            continue
        try:
            with os.fdopen(fd, "wb") as handle:
                handle.write(data)
        except Exception:
            dest.unlink(missing_ok=True)
            raise
        return dest


def _replace_upload(dest: Path, data: bytes) -> None:
    """先完整写入同目录临时文件，再原子替换明确选择的目标。"""
    fd, tmp_name = tempfile.mkstemp(prefix=".zcbot-upload-", dir=dest.parent)
    tmp = Path(tmp_name)
    try:
        with os.fdopen(fd, "wb") as handle:
            handle.write(data)
        os.replace(tmp, dest)
    except Exception:
        tmp.unlink(missing_ok=True)
        raise


def _pptx_lock_for(abs_path: str) -> asyncio.Lock:
    lock = _pptx_preview_locks.get(abs_path)
    if lock is None:
        lock = _pptx_preview_locks[abs_path] = asyncio.Lock()
    return lock


def _regular_file_response(target: Path, display_path: str) -> FileResponse:
    if not target.exists():
        raise HTTPException(404, f"file not found: {display_path}")
    if not target.is_file():
        raise HTTPException(400, f"not a file: {display_path}")
    media_type = "image/svg+xml" if target.suffix.lower() == ".svg" else None
    return FileResponse(
        path=str(target),
        filename=target.name,
        media_type=media_type,
        headers={"Cache-Control": "no-cache"},
    )


def _task_working_dir(task_id: str, user_id: UUID, root: Path) -> tuple[UUID, Path]:
    try:
        tid = UUID(task_id)
    except ValueError:
        raise HTTPException(404, f"invalid task id: {task_id!r}")
    with session_scope() as s:
        db_path = s.execute(
            select(Task.working_dir).where(Task.task_id == tid, Task.user_id == user_id)
        ).scalar_one_or_none()
    if not db_path:
        raise HTTPException(404, "task not found")
    working_dir = from_db_path(db_path).resolve()
    try:
        working_dir.relative_to(root.resolve())
    except ValueError:
        raise HTTPException(400, "task working_dir is outside user workspace")
    return tid, working_dir


def _task_file_target(root: Path, working_dir: Path, path: str, legacy: bool) -> Path:
    """Resolve canonical task refs, with a read-only fallback chain for old cards.

    Historical messages used user-root paths, while one known failure accidentally emitted
    a task-relative path with the same leading directory name.  Old cards therefore try the
    original user-root meaning first, then both task-relative interpretations.  New refs never
    use this branch and remain unambiguous.
    """
    if not legacy:
        return safe_join(working_dir, path)
    candidates = [safe_join(root, path), safe_join(working_dir, path)]
    normalized = str(path or "").replace("\\", "/")
    if "/" in normalized:
        candidates.append(safe_join(working_dir, normalized.split("/", 1)[1]))
    for candidate in candidates:
        if candidate.is_file():
            return candidate
    return candidates[0]


def _artifact_target(
    root: Path,
    user_id: UUID,
    artifact_id: str,
) -> Path:
    try:
        aid = UUID(artifact_id)
    except ValueError:
        raise HTTPException(404, "invalid artifact id")
    from core.storage.models import Artifact

    with session_scope() as s:
        current_path = s.execute(
            select(Artifact.current_path).where(
                Artifact.artifact_id == aid,
                Artifact.user_id == user_id,
                Artifact.status == "active",
            )
        ).scalar_one_or_none()
    if not current_path:
        raise HTTPException(404, "artifact not found")
    return safe_join(root, current_path)


async def _pptx_preview_response(target: Path, display_path: str) -> FileResponse:
    from ..pptx_render import (
        PptxConvertError,
        SofficeNotFoundError,
        pptx_to_pdf,
    )

    if not target.exists():
        raise HTTPException(404, f"file not found: {display_path}")
    if not target.is_file():
        raise HTTPException(400, f"not a file: {display_path}")
    if target.suffix.lower() not in (".pptx", ".ppt"):
        raise HTTPException(400, f"not a pptx: {display_path}")
    abs_path = str(target.resolve())
    loop = asyncio.get_event_loop()
    async with _pptx_lock_for(abs_path):
        try:
            pdf_path = await loop.run_in_executor(None, pptx_to_pdf, target)
        except SofficeNotFoundError as e:
            raise HTTPException(501, str(e))
        except PptxConvertError as e:
            raise HTTPException(500, str(e))
    return FileResponse(
        path=str(pdf_path),
        media_type="application/pdf",
        headers={"Cache-Control": "no-cache"},
    )


def register_file_routes(app, *, require_user) -> None:
    @app.get("/v1/user/storage", tags=["user"])
    def user_storage(user_id: UUID = Depends(require_user)):
        """当前用户磁盘用量 + 配额。

        数据来自后台扫描落库的 user_disk_usage(默 15min 一次),非实时;
        无扫描记录(新用户 / 首次扫描前)bytes_used/file_count=0、scanned_at=None。
        limit_bytes<=0 或 None → 不限(前端不画进度条)。
        """
        from core.agent_builder import load_config as _load_cfg
        from core.storage.disk_quota import get_user_usage, parse_bytes
        _quotas_cfg = (_load_cfg().get("quotas") or {})
        _limit = parse_bytes(_quotas_cfg.get("disk_bytes_per_user"))
        usage = get_user_usage(user_id)
        if usage is None:
            bytes_used, file_count, scanned_at = 0, 0, None
        else:
            bytes_used, file_count, scanned_at = usage
        return {
            "bytes_used": bytes_used,
            "file_count": file_count,
            "limit_bytes": (_limit if (_limit and _limit > 0) else None),
            "scanned_at": scanned_at.isoformat() if scanned_at else None,
        }

    @app.get("/v1/files", tags=["files"])
    def list_files(
        path: str = "",
        include_hidden: bool = False,
        task_id: str = "",
        user_id: UUID = Depends(require_user),
    ):
        """列 user_root 下子目录条目 + 面包屑。`path` 留空 → user_root;
        `../` / 绝对 → 400。dotfile/dotdir 默认隐藏；`include_hidden=true`
        仅在 `task_id` 属于当前用户且 current 位于该 task 工作目录子树时展示
        dotdir，永不从 user_root 展示平台目录。根目录层再隐藏系统工作目录
        (定时任务 / 渠道对话,见 system_wd_names)。点文件始终隐藏。
        """
        root = load_user_root(user_id)
        current = safe_join(root, path)
        include_task_hidden = False
        if include_hidden and task_id:
            _tid, working_dir = _task_working_dir(task_id, user_id, root)
            try:
                current.relative_to(working_dir)
                include_task_hidden = True
            except ValueError:
                pass
        entries, crumbs, exists = enumerate_files(
            root,
            current,
            include_hidden_dirs=include_task_hidden,
        )
        if not rel_to(root, current):
            hidden = system_wd_names(user_id)
            if hidden:
                entries = [
                    e for e in entries
                    if not (e["is_dir"] and e["name"] in hidden)
                ]
        return {
            "root": norm_path(str(root)),
            "current": rel_to(root, current),
            "exists": exists,
            "crumbs": crumbs,
            "entries": entries,
            "hidden_dirs_included": include_task_hidden,
        }

    @app.get("/v1/files/download", tags=["files"])
    def download_file(
        path: str,
        user_id: UUID = Depends(require_user),
    ):
        """下载 user_root 下单个 regular file(目录 → 400 / 不存在 → 404)。"""
        root = load_user_root(user_id)
        target = safe_join(root, path)
        # workspace 文件可变, 禁浏览器启发式缓存 (RFC 7234 默认能缓数小时)
        # 否则文件改了 SPA 预览还是旧内容
        # (Starlette FileResponse 不实现 304, 总是 200 全量; workspace 文件小, 可接受)
        # .svg 显式给 image/svg+xml: 部分部署环境 mimetypes 未注册 svg, FileResponse
        # 会猜成 octet-stream, 前端 <img> 就渲染不出 SVG 预览
        return _regular_file_response(target, path)

    @app.get("/v1/tasks/{task_id}/files/download", tags=["files"])
    def download_task_file(
        task_id: str,
        path: str,
        legacy: bool = False,
        artifact_id: str = "",
        user_id: UUID = Depends(require_user),
    ):
        """Download a file addressed relative to the task's current working_dir."""
        root = load_user_root(user_id)
        _tid, working_dir = _task_working_dir(task_id, user_id, root)
        target = (
            _artifact_target(root, user_id, artifact_id)
            if artifact_id
            else _task_file_target(root, working_dir, path, legacy)
        )
        return _regular_file_response(target, path)

    @app.get("/v1/files/preview_pdf", tags=["files"])
    async def preview_pdf(
        path: str,
        user_id: UUID = Depends(require_user),
    ):
        """把 user_root 下的 .pptx 转成 PDF 返回,供前端复用 PDF iframe 在线预览。

        转换跑在 backend host(不进沙盒),按需触发 + 缓存到 `.preview/`(DESIGN §8.3)。
        soffice 缺失 → 501;转换失败/超时 → 500;前端据此回退到下载。
        """
        root = load_user_root(user_id)
        target = safe_join(root, path)
        return await _pptx_preview_response(target, path)

    @app.get("/v1/tasks/{task_id}/files/preview_pdf", tags=["files"])
    async def preview_task_pdf(
        task_id: str,
        path: str,
        legacy: bool = False,
        artifact_id: str = "",
        user_id: UUID = Depends(require_user),
    ):
        """Preview a PPT addressed relative to the task's current working_dir."""
        root = load_user_root(user_id)
        _tid, working_dir = _task_working_dir(task_id, user_id, root)
        target = (
            _artifact_target(root, user_id, artifact_id)
            if artifact_id
            else _task_file_target(root, working_dir, path, legacy)
        )
        return await _pptx_preview_response(target, path)

    @app.post("/v1/files/upload", tags=["files"])
    async def upload_files(
        path: str = Form(""),
        conflict: str = Form("rename"),
        target_name: str = Form(""),
        files: list[UploadFile] = File(...),
        user_id: UUID = Depends(require_user),
    ):
        """multipart 多文件上传到 `<user_root>/<path>/`。
        路径不存在自动 mkdir(parents=True);默认重名自动编号保留两份。
        仅显式 `conflict=overwrite` 可覆盖；`target_name` 用于替换指定文件。
        文件名严格校验(含 `/ \\ ..` 或为空 → 400)。
        """
        # 磁盘配额 gate(§7.5 #4):超额 413 阻止上传,提示 user 清旧产物
        from core.agent_builder import load_config as _load_cfg
        from core.storage.disk_quota import check_disk_quota, parse_bytes
        _quotas_cfg = (_load_cfg().get("quotas") or {})
        _limit = parse_bytes(_quotas_cfg.get("disk_bytes_per_user"))
        if _limit is not None and _limit > 0:
            _err = check_disk_quota(user_id, _limit)
            if _err is not None:
                raise HTTPException(413, _err)

        root = load_user_root(user_id)
        dest_dir = safe_join(root, path)
        if dest_dir.exists() and not dest_dir.is_dir():
            raise HTTPException(400, f"upload target is a file, not a directory: {path}")
        dest_dir.mkdir(parents=True, exist_ok=True)

        if conflict not in ("rename", "overwrite"):
            raise HTTPException(400, f"invalid conflict policy: {conflict!r}")
        if target_name and (conflict != "overwrite" or len(files or []) != 1):
            raise HTTPException(400, "target_name requires one file and conflict=overwrite")

        saved: list[dict] = []
        for up in files or []:
            raw_name = target_name or up.filename or ""
            if (
                not raw_name
                or raw_name in (".", "..")
                or "/" in raw_name or "\\" in raw_name
                or any(part in (".", "..") for part in Path(raw_name).parts)
            ):
                raise HTTPException(400, f"invalid filename: {raw_name!r}")
            requested_dest = dest_dir / raw_name
            try:
                requested_dest.resolve().relative_to(root.resolve())
            except ValueError:
                raise HTTPException(400, f"path escapes user_root: {raw_name!r}")
            data = await up.read()
            if conflict == "overwrite":
                if requested_dest.exists() and not requested_dest.is_file():
                    raise HTTPException(409, f"upload target is not a file: {raw_name!r}")
                _replace_upload(requested_dest, data)
                dest = requested_dest
            else:
                dest = _write_upload_without_overwrite(dest_dir, raw_name, data)
            saved.append({"name": dest.name, "size": len(data), "rel": rel_to(root, dest)})
        if not saved:
            raise HTTPException(400, "no files uploaded")
        return {"count": len(saved), "saved": saved}

    @app.post("/v1/files/mkdir", tags=["files"])
    def create_directory(
        body: FileMkdirRequest,
        user_id: UUID = Depends(require_user),
    ):
        """在 `<user_root>/<path>/` 下创建一个直接子目录。

        name 与 working_dir 共用 leaf 校验，禁止路径分隔符、NUL 和点目录；
        parent 必须已存在且为目录，目标已存在返回 409，不递归补建 parent。
        """
        from core.agent_builder import InvalidTaskName, validate_task_name

        root = load_user_root(user_id)
        parent = safe_join(root, body.path)
        if not parent.exists():
            raise HTTPException(404, f"parent directory not found: {body.path!r}")
        if not parent.is_dir():
            raise HTTPException(400, f"parent is not a directory: {body.path!r}")
        try:
            name = validate_task_name(body.name)
        except InvalidTaskName as e:
            raise HTTPException(400, f"name 不合法: {e}")

        target = parent / name
        if target.exists():
            raise HTTPException(409, f"target already exists: {rel_to(root, target)!r}")
        try:
            target.mkdir()
        except FileExistsError:
            raise HTTPException(
                409, f"target already exists: {rel_to(root, target)!r}"
            )
        except OSError as e:
            raise HTTPException(400, f"mkdir failed: {e}")
        return {"ok": True, "path": rel_to(root, target), "name": name}

    @app.post("/v1/files/delete", tags=["files"])
    def delete_file(
        body: FileDeleteRequest,
        user_id: UUID = Depends(require_user),
    ):
        """删 user_root 下文件或目录。

        - `recursive=False`(默认):目录必须为空(`rmdir`),非空 → 400
        - `recursive=True`:`shutil.rmtree`;若目标是顶层目录且被某 task.working_dir
          引用 → 409,提示先 DELETE task(避免 DB 还引用、FS artifacts 已清的错位)
        - 顶层空目录 / 子级空目录无论 recursive 与否都可删:task.working_dir 字段不动,
          下次 build_agent 按需 mkdir 重建,FS 目录视为可重生
        - root → 400;不存在 → 404
        - 已通过结构化 artifact_refs 发布的文件先移入平台隐藏回收区；用户侧仍立即消失
          普通文件仍物理删除；递归目录仅回收其中的 artifact
        """
        root = load_user_root(user_id)
        target = safe_join(root, body.path)
        if target.resolve() == root.resolve():
            raise HTTPException(400, "cannot delete user_root")
        if not target.exists():
            raise HTTPException(404, f"path not found: {body.path}")
        target_is_dir = target.is_dir()
        if target_is_dir and not body.recursive:
            try:
                if any(target.iterdir()):
                    raise HTTPException(400, "delete failed: directory is not empty")
            except OSError as e:
                raise HTTPException(400, f"delete failed: {e}")

        if target_is_dir and body.recursive:
            is_top_level = target.parent.resolve() == root.resolve()
            if is_top_level:
                db_form = to_db_path(target)
                with session_scope() as s:
                    n = s.execute(
                        select(func.count()).select_from(Task).where(
                            Task.user_id == user_id,
                            Task.working_dir == db_form,
                            Task.deleted_at.is_(None),  # 软删 task 不再算引用
                        )
                    ).scalar_one() or 0
                if n:
                    raise HTTPException(
                        409,
                        f"该顶层目录正被 {n} 个 task 引用,不能递归删除;"
                        f"请先 DELETE task,再清残留文件",
                    )

        try:
            from core.artifact_lifecycle import trash_active_artifacts

            trashed_artifacts = trash_active_artifacts(
                user_id=user_id,
                user_root=root,
                target=target,
            )
            if target_is_dir:
                if body.recursive:
                    import shutil
                    shutil.rmtree(target)
                else:
                    target.rmdir()
            elif not trashed_artifacts:
                target.unlink()
        except OSError as e:
            raise HTTPException(400, f"delete failed: {e}")
        return {
            "ok": True,
            "path": body.path,
            "artifacts_trashed": trashed_artifacts,
        }

    @app.post("/v1/files/rename", tags=["files"])
    def rename_path(
        body: FileRenameRequest,
        user_id: UUID = Depends(require_user),
    ):
        """重命名 user_root 下文件或目录(任意深度)。

        - `path` 必填,指被重命名对象;不能为 user_root
        - `new_name` 是新 leaf 名(`validate_task_name`:非空 / 不含 `/\\..` / 非 dotfile / ≤255);
          不是路径,parent 自动取自原 path
        - 目标 sibling `<parent>/<new_name>` 不能已存在(防覆盖)
        - **path 是顶层目录**(user_root 直接子项,且为目录)→ DB-aware:
          * 同事务内 `SELECT ... FOR UPDATE` 锁该目录对应 task;任一 run_status 在
            running/cancelling → 409(避免 BG 线程握旧路径而 DB 已指新路径)
          * `check_no_subtask(new_db, exclude=被改名 tids)` 防止改名后跟其它 task 形成嵌套
          * `UPDATE tasks SET working_dir=new_db WHERE task_id IN (...)` 先写 DB
          * 再 `os.rename` FS;失败 → 抛错 → session_scope 回滚 DB
          * 唯一不一致窗口是 "FS 已改名 + commit 阶段失败"(PG 单事务 commit 极少失败)
        - 非顶层(子目录 / 文件)→ 纯 FS rename,不动 DB
        """
        from core.agent_builder import InvalidTaskName, validate_task_name

        root = load_user_root(user_id)
        target = safe_join(root, body.path)
        if target.resolve() == root.resolve():
            raise HTTPException(400, "cannot rename user_root")
        if not target.exists():
            raise HTTPException(404, f"path not found: {body.path}")

        try:
            new_name = validate_task_name(body.new_name)
        except InvalidTaskName as e:
            raise HTTPException(400, f"new_name 不合法: {e}")

        if new_name == target.name:
            raise HTTPException(400, f"new_name 与原名相同: {new_name!r}")

        new_target = target.parent / new_name
        if new_target.exists():
            raise HTTPException(
                409, f"target already exists: {rel_to(root, new_target)!r}"
            )

        is_top_level_dir = (
            target.is_dir() and target.parent.resolve() == root.resolve()
        )

        if not is_top_level_dir:
            try:
                target.rename(new_target)
                from core.artifact_lifecycle import rename_active_artifacts
                rename_active_artifacts(
                    user_id=user_id,
                    user_root=root,
                    old_path=target,
                    new_path=new_target,
                )
            except OSError as e:
                raise HTTPException(400, f"rename failed: {e}")
            except Exception as e:
                try:
                    new_target.rename(target)
                except OSError:
                    pass
                raise HTTPException(500, f"artifact metadata update failed: {e}")
            return {
                "ok": True,
                "old": body.path,
                "new": rel_to(root, new_target),
                "tasks_updated": 0,
            }

        # 顶层目录:网页和对话内延迟动作共用同一个 DB-aware 服务。
        try:
            renamed = rename_working_dir(
                user_id=user_id,
                old_path=target,
                new_path=new_target,
            )
        except WorkingDirConflictError as e:
            raise HTTPException(409, str(e))
        except WorkingDirRenameError as e:
            # 保持原 files API 的普通 FS 失败 400 语义。
            raise HTTPException(400, str(e))

        return {
            "ok": True,
            "old": body.path,
            "new": rel_to(root, new_target),
            "tasks_updated": renamed.tasks_updated,
        }

    @app.post("/v1/files/copy", tags=["files"])
    def copy_files(
        body: FileTransferRequest,
        user_id: UUID = Depends(require_user),
    ):
        """批量拷贝 paths → dest_dir/<name>(目录递归)。

        - 不覆盖(任一目标已存在 → 409)
        - 不能拷到自己 / 自身子树
        - 顶层目录(可能是某 task 的 working_dir)可以拷:不创建 task；其中 artifact
          为副本创建独立身份并记录 copied_from_artifact_id
        - 部分失败语义:任一 FS 拷贝抛错 → 抛 HTTPException,**前面已成功的拷贝保留**
          (无 FS 事务可回滚;预检通过后通常不会失败,失败也是磁盘满 / 权限这类不能恢复的)
        """
        import shutil
        root = load_user_root(user_id)
        sources, dest = validate_transfer(root, body.paths, body.dest_dir)
        transferred: list[dict] = []
        for src in sources:
            target = dest / src.name
            try:
                if src.is_dir():
                    shutil.copytree(src, target)
                else:
                    shutil.copy2(src, target)
                from core.artifact_lifecycle import copy_active_artifacts
                artifacts_copied = copy_active_artifacts(
                    user_id=user_id,
                    user_root=root,
                    source=src,
                    target=target,
                )
            except OSError as e:
                raise HTTPException(
                    500,
                    f"copy failed at {src.name!r}: {e} "
                    f"(已成功 {len(transferred)} 项,剩余未处理)",
                )
            except Exception as e:
                if target.is_dir():
                    shutil.rmtree(target, ignore_errors=True)
                else:
                    target.unlink(missing_ok=True)
                raise HTTPException(
                    500, f"copy metadata failed at {src.name!r}: {e}"
                )
            transferred.append({
                "old": rel_to(root, src),
                "new": rel_to(root, target),
                "artifacts_copied": artifacts_copied,
            })
        return {"ok": True, "count": len(transferred), "transferred": transferred}

    @app.post("/v1/files/move", tags=["files"])
    def move_files(
        body: FileTransferRequest,
        user_id: UUID = Depends(require_user),
    ):
        """批量移动 paths → dest_dir/<name>。

        - 不覆盖、不自嵌套(同 /copy)
        - **顶层目录是某 task 的 working_dir → 409**,维持 "working_dir = 顶层目录" invariant
          (允许的话 task working_dir 沉到子目录会让 rename 顶层的 DB-aware 逻辑失效;
          用户想归档:先 DELETE task)
        - 拷贝(`/copy`)无此限制，因为副本不创建 task；artifact 身份独立复制
        - 部分失败:同 /copy,前面成功的不回滚(`shutil.move` 失败几乎只发生在
          跨卷拷贝中断,workspace 都在同一磁盘下罕见)
        """
        import shutil
        root = load_user_root(user_id)
        sources, dest = validate_transfer(root, body.paths, body.dest_dir)

        # 顶层目录-是-某 task.working_dir → 闸
        top_level_dir_srcs = [
            s for s in sources
            if s.is_dir() and s.parent.resolve() == root.resolve()
        ]
        if top_level_dir_srcs:
            db_forms = [to_db_path(s) for s in top_level_dir_srcs]
            with session_scope() as s:
                rows = s.execute(
                    select(Task.working_dir, func.count())
                    .where(
                        Task.user_id == user_id,
                        Task.working_dir.in_(db_forms),
                    )
                    .group_by(Task.working_dir)
                ).all()
            occupied = {wd: n for wd, n in rows}
            if occupied:
                # 反查 db_form → src.name 给报错文案
                form2name = {to_db_path(s): s.name for s in top_level_dir_srcs}
                names = ", ".join(
                    f"{form2name[wd]!r}({n} 个 task)"
                    for wd, n in occupied.items()
                )
                raise HTTPException(
                    409,
                    f"以下顶层目录正被 task 引用,不能移动:{names};"
                    f"请先删 task,或改用复制",
                )

        transferred: list[dict] = []
        for src in sources:
            target = dest / src.name
            try:
                shutil.move(str(src), str(target))
                from core.artifact_lifecycle import rename_active_artifacts
                artifacts_moved = rename_active_artifacts(
                    user_id=user_id,
                    user_root=root,
                    old_path=src,
                    new_path=target,
                )
            except OSError as e:
                raise HTTPException(
                    500,
                    f"move failed at {src.name!r}: {e} "
                    f"(已成功 {len(transferred)} 项,剩余未处理)",
                )
            except Exception as e:
                try:
                    shutil.move(str(target), str(src))
                except OSError:
                    pass
                raise HTTPException(
                    500, f"move metadata failed at {src.name!r}: {e}"
                )
            transferred.append({
                "old": rel_to(root, src),
                "new": rel_to(root, target),
                "artifacts_moved": artifacts_moved,
            })
        return {"ok": True, "count": len(transferred), "transferred": transferred}
