package service

import (
	"archive/tar"
	"archive/zip"
	"compress/gzip"
	"encoding/json"
	"fmt"
	"io"
	"os"
	"os/exec"
	"path"
	"path/filepath"
	"regexp"
	"strconv"
	"strings"
	"time"

	"github.com/1Panel-dev/1Panel/agent/app/dto"
	"github.com/1Panel-dev/1Panel/agent/app/task"
	"github.com/1Panel-dev/1Panel/agent/constant"
	"github.com/1Panel-dev/1Panel/agent/global"
	"github.com/1Panel-dev/1Panel/agent/utils/cmd"
	"github.com/1Panel-dev/1Panel/agent/utils/common"
)

const clawhubGlobalRegistry = "https://clawhub.com"
const clawhubChinaRegistry = "https://mirror-cn.clawhub.com"
const localSkillHubSource = "local-hub"
const localSkillHubPublishedStatus = "published"
const clawHubSkillTmpSubDir = "1panel/tmp/clawhub-skills"
const hermesManagedSkillsDir = "/opt/data/skills"

type openclawSkillsList struct {
	Skills []openclawSkillListItem `json:"skills"`
}

type openclawSkillListItem struct {
	Name        string `json:"name"`
	Description string `json:"description"`
	Source      string `json:"source"`
	Bundled     bool   `json:"bundled"`
	Disabled    bool   `json:"disabled"`
}

type openclawSkillInfo struct {
	SkillKey string `json:"skillKey"`
}

type skillhubSearchPayload struct {
	Skills  []dto.AgentSkillSearchItem `json:"skills"`
	Results []dto.AgentSkillSearchItem `json:"results"`
}

type localSkillHubItem struct {
	ID              uint
	Name            string
	Status          string
	ApplicableAgent string
	PackagePath     string
}

var clawhubSearchLinePattern = regexp.MustCompile(`^(\S+)\s+(.+?)\s+\(([\d.]+)\)$`)
var ansiEscapePattern = regexp.MustCompile(`\x1b\[[0-9;?]*[ -/]*[@-~]`)
var safeLocalSkillDirNamePattern = regexp.MustCompile(`[^a-zA-Z0-9._-]+`)

func (a AgentService) ListSkills(req dto.AgentIDReq) ([]dto.AgentSkillItem, error) {
	agent, install, err := a.loadAgentAndInstall(req.AgentID)
	if err != nil {
		return nil, err
	}
	if err := ensureContainerRunning(install.ContainerName); err != nil {
		return nil, err
	}
	if agent.AgentType == constant.AppHermesAgent {
		return listHermesSkills(install.ContainerName)
	}
	if agent.AgentType != constant.AppOpenclaw {
		return nil, fmt.Errorf("%s does not support", agent.AgentType)
	}
	output, err := cmd.RunDockerExecWithStdout(5*time.Minute, install.ContainerName, "openclaw", "skills", "list", "--json")
	if err != nil {
		return nil, err
	}
	if len(output) == 0 {
		return nil, nil
	}
	return parseOpenclawSkillsList(output)
}

func (a AgentService) SearchSkills(req dto.AgentSkillSearchReq) ([]dto.AgentSkillSearchItem, error) {
	if req.Source == localSkillHubSource {
		return nil, fmt.Errorf("local skills hub is provided by enterprise edition")
	}
	if global.CONF.Base.IsOffline {
		return nil, fmt.Errorf("offline environment cannot access remote Skills Hub")
	}
	agent, install, err := a.loadAgentAndInstall(req.AgentID)
	if err != nil {
		return nil, err
	}
	if err := ensureContainerRunning(install.ContainerName); err != nil {
		return nil, err
	}
	if agent.AgentType == constant.AppHermesAgent {
		return searchHermesSkills(install.ContainerName, req.Source, req.Keyword)
	}
	if agent.AgentType != constant.AppOpenclaw {
		return nil, fmt.Errorf("%s does not support", agent.AgentType)
	}
	output, err := loadOpenclawSkillSearchOutput(install.ContainerName, req.Source, req.Keyword)
	if err != nil {
		return nil, err
	}
	if len(output) == 0 {
		return nil, nil
	}
	switch req.Source {
	case "skillhub":
		return parseSkillhubSearchResult(output)
	default:
		return parseClawhubSearchResult(output, req.Source), nil
	}
}

func (a AgentService) UpdateSkill(req dto.AgentSkillUpdateReq) error {
	agent, install, err := a.loadOpenclawAgentAndInstall(req.AgentID)
	if err != nil {
		return err
	}
	if err := ensureContainerRunning(install.ContainerName); err != nil {
		return err
	}
	conf, err := readOpenclawConfig(agent.ConfigPath)
	if err != nil {
		return err
	}
	skillKey, err := getOpenclawSkillKey(install.ContainerName, req.Name)
	if err != nil {
		return err
	}
	setOpenclawSkillEnabled(conf, skillKey, req.Enabled)
	return writeOpenclawConfigRaw(agent.ConfigPath, conf)
}

func (a AgentService) InstallSkill(req dto.AgentSkillInstallReq) error {
	if global.CONF.Base.IsOffline && req.Source != localSkillHubSource {
		return fmt.Errorf("offline environment cannot access remote Skills Hub")
	}
	agent, install, err := a.loadAgentAndInstall(req.AgentID)
	if err != nil {
		return err
	}
	if err := ensureContainerRunning(install.ContainerName); err != nil {
		return err
	}
	if req.Source == localSkillHubSource {
		if !global.IsMaster {
			return fmt.Errorf("local skills hub is only available on the master node")
		}
		hostPath, skillName, err := resolveLocalSkillPackage(agent.AgentType, req.Slug)
		if err != nil {
			return err
		}
		installTask, err := task.NewTaskWithOps(skillName, task.TaskInstall, task.TaskScopeAI, req.TaskID, req.AgentID)
		if err != nil {
			return err
		}
		installTask.AddSubTask("Install local skill", func(t *task.Task) error {
			mgr := cmd.NewCommandMgr(cmd.WithTask(*t), cmd.WithContext(t.TaskCtx), cmd.WithTimeout(20*time.Minute))
			return installLocalSkillPackage(mgr, install.ContainerName, agent.AgentType, agent.ConfigPath, hostPath, skillName)
		}, nil)
		go func() {
			if err := installTask.Execute(); err != nil {
				global.LOG.Errorf("install local skill failed: %v", err)
			}
		}()
		return nil
	}
	installTask, err := task.NewTaskWithOps(req.Slug, task.TaskInstall, task.TaskScopeAI, req.TaskID, req.AgentID)
	if err != nil {
		return err
	}
	if agent.AgentType == constant.AppHermesAgent {
		installTask.AddSubTask("Install Hermes skill", func(t *task.Task) error {
			mgr := cmd.NewCommandMgr(cmd.WithTask(*t), cmd.WithContext(t.TaskCtx), cmd.WithTimeout(20*time.Minute))
			return mgr.Run("docker", buildHermesDockerExecArgs(install.ContainerName, "skills", "install", req.Slug, "--yes")...)
		}, nil)
		go func() {
			if err := installTask.Execute(); err != nil {
				global.LOG.Errorf("install hermes skill failed: %v", err)
			}
		}()
		return nil
	}
	if agent.AgentType != constant.AppOpenclaw {
		return fmt.Errorf("%s does not support", agent.AgentType)
	}
	installTask.AddSubTask("Install OpenClaw skill", func(t *task.Task) error {
		mgr := cmd.NewCommandMgr(cmd.WithTask(*t), cmd.WithContext(t.TaskCtx), cmd.WithTimeout(20*time.Minute))
		return installOpenclawSkill(mgr, install.ContainerName, req.Source, req.Slug)
	}, nil)
	go func() {
		if err := installTask.Execute(); err != nil {
			global.LOG.Errorf("install openclaw skill failed: %v", err)
		}
	}()
	return nil
}

func installLocalSkillPackage(mgr *cmd.CommandHelper, containerName, agentType, configPath, hostPath, skillName string) error {
	targetDir, err := resolveAgentLocalSkillDir(agentType)
	if err != nil {
		return err
	}
	tempDir, err := os.MkdirTemp("", "1panel-agent-skill-*")
	if err != nil {
		return err
	}
	defer os.RemoveAll(tempDir)
	extractRoot := filepath.Join(tempDir, "extract")
	if err := os.MkdirAll(extractRoot, 0755); err != nil {
		return err
	}
	if err := extractLocalSkillPackage(hostPath, extractRoot); err != nil {
		return err
	}
	copyRoot, installedSkillName, err := normalizeLocalSkillInstallRoot(extractRoot, filepath.Join(tempDir, "install"), skillName)
	if err != nil {
		return err
	}
	if err := mgr.Run("docker", "exec", containerName, "mkdir", "-p", targetDir); err != nil {
		return err
	}
	if err := mgr.Run("docker", "cp", copyRoot+"/.", containerName+":"+targetDir); err != nil {
		return err
	}
	if agentType == constant.AppOpenclaw {
		return registerOpenclawLocalSkill(containerName, configPath, installedSkillName)
	}
	return nil
}

func resolveAgentLocalSkillDir(agentType string) (string, error) {
	switch agentType {
	case constant.AppOpenclaw:
		return openclawManagedSkillsDir, nil
	case constant.AppHermesAgent:
		return hermesManagedSkillsDir, nil
	default:
		return "", fmt.Errorf("%s does not support", agentType)
	}
}

func resolveLocalSkillPackage(agentType, skillID string) (string, string, error) {
	skillID = strings.TrimSpace(skillID)
	id, err := strconv.ParseUint(skillID, 10, 64)
	if err != nil || id == 0 {
		return "", "", fmt.Errorf("invalid local skill id")
	}
	dbPath := filepath.Join(global.CONF.Base.InstallDir, "1panel", "db", "enterprise.db")
	if _, err := os.Stat(dbPath); err != nil {
		return "", "", fmt.Errorf("local skills hub is unavailable")
	}
	db, err := common.GetDBWithPath(dbPath)
	if err != nil {
		return "", "", err
	}
	defer common.CloseDB(db)
	var item localSkillHubItem
	if err := db.Table("skill_hub_items").
		Select("id,name,status,applicable_agent,package_path").
		Where("id = ? AND status = ?", uint(id), localSkillHubPublishedStatus).
		First(&item).Error; err != nil {
		return "", "", fmt.Errorf("local skill is not published or does not exist")
	}
	if !matchLocalSkillAgent(item.ApplicableAgent, agentType) {
		return "", "", fmt.Errorf("local skill does not support %s", agentType)
	}
	packagePath, err := validateLocalSkillPackagePath(item.PackagePath)
	if err != nil {
		return "", "", err
	}
	return packagePath, sanitizeLocalSkillDirName(item.Name, packagePath, skillID), nil
}

func matchLocalSkillAgent(applicableAgent, agentType string) bool {
	applicableAgent = strings.TrimSpace(applicableAgent)
	if applicableAgent == "" {
		return true
	}
	for _, item := range strings.Split(applicableAgent, ",") {
		normalized := normalizeLocalSkillAgent(strings.TrimSpace(item))
		if normalized == "common" || normalized == normalizeLocalSkillAgent(agentType) {
			return true
		}
	}
	return false
}

func normalizeLocalSkillAgent(agentType string) string {
	switch strings.ToLower(strings.TrimSpace(agentType)) {
	case constant.AppHermesAgent:
		return "hermes"
	default:
		return strings.ToLower(strings.TrimSpace(agentType))
	}
}

func sanitizeLocalSkillDirName(name, packagePath, fallback string) string {
	name = strings.TrimSpace(name)
	if name == "" {
		name = strings.TrimSuffix(filepath.Base(packagePath), localSkillPackageExtension(packagePath))
	}
	name = strings.Trim(safeLocalSkillDirNamePattern.ReplaceAllString(name, "-"), "-")
	if name == "" || name == "." || name == ".." {
		name = "skill-" + strings.TrimSpace(fallback)
	}
	if name == "skill-" {
		return "skill"
	}
	return name
}

func validateLocalSkillPackagePath(packagePath string) (string, error) {
	packagePath = strings.TrimSpace(packagePath)
	if packagePath == "" {
		return "", fmt.Errorf("skill package path is required")
	}
	if _, err := localSkillPackageFormat(packagePath); err != nil {
		return "", err
	}
	absPath, err := filepath.Abs(packagePath)
	if err != nil {
		return "", err
	}
	resolvedPath, err := filepath.EvalSymlinks(absPath)
	if err != nil {
		return "", err
	}
	if !isLocalSkillPackageAllowedPath(resolvedPath) {
		return "", fmt.Errorf("invalid local skill package path")
	}
	info, err := os.Stat(resolvedPath)
	if err != nil {
		return "", err
	}
	if info.IsDir() {
		return "", fmt.Errorf("skill package must be a file")
	}
	return resolvedPath, nil
}

func isLocalSkillPackageAllowedPath(resolvedPath string) bool {
	roots := []string{
		filepath.Join(global.CONF.Base.InstallDir, "1panel", "uploads", "skills-hub"),
		filepath.Join(global.CONF.Base.InstallDir, filepath.FromSlash(clawHubSkillTmpSubDir)),
	}
	for _, root := range roots {
		if isPathInsideResolvedRoot(resolvedPath, root) {
			return true
		}
	}
	return false
}

func isPathInsideResolvedRoot(resolvedPath, root string) bool {
	absRoot, err := filepath.Abs(root)
	if err != nil {
		return false
	}
	resolvedRoot, err := filepath.EvalSymlinks(absRoot)
	if err != nil {
		return false
	}
	rel, err := filepath.Rel(resolvedRoot, resolvedPath)
	return err == nil && rel != "." && rel != ".." && !strings.HasPrefix(rel, ".."+string(os.PathSeparator))
}

func normalizeLocalSkillInstallRoot(extractRoot, installRoot, skillName string) (string, string, error) {
	entries, err := os.ReadDir(extractRoot)
	if err != nil {
		return "", "", err
	}
	if len(entries) == 0 {
		return "", "", fmt.Errorf("skill package is empty")
	}
	if len(entries) == 1 && entries[0].IsDir() {
		return extractRoot, entries[0].Name(), nil
	}
	skillRoot := filepath.Join(installRoot, skillName)
	if err := os.MkdirAll(skillRoot, 0755); err != nil {
		return "", "", err
	}
	if err := copyLocalDirContents(extractRoot, skillRoot); err != nil {
		return "", "", err
	}
	return installRoot, skillName, nil
}

func registerOpenclawLocalSkill(containerName, configPath, skillName string) error {
	conf, err := readOpenclawConfig(configPath)
	if err != nil {
		return err
	}
	skillKey, err := getOpenclawSkillKey(containerName, skillName)
	if err != nil {
		return err
	}
	setOpenclawSkillEnabled(conf, skillKey, true)
	return writeOpenclawConfigRaw(configPath, conf)
}

func extractLocalSkillPackage(packagePath, targetDir string) error {
	format, err := localSkillPackageFormat(packagePath)
	if err != nil {
		return err
	}
	switch format {
	case "zip":
		return unzipLocalSkillPackage(packagePath, targetDir)
	case "tar", "targz":
		return untarLocalSkillPackage(packagePath, targetDir, format == "targz")
	case "7z":
		return un7zLocalSkillPackage(packagePath, targetDir)
	default:
		return fmt.Errorf("unsupported skill package format")
	}
}

func unzipLocalSkillPackage(packagePath, targetDir string) error {
	reader, err := zip.OpenReader(packagePath)
	if err != nil {
		return err
	}
	defer reader.Close()
	root, err := filepath.Abs(targetDir)
	if err != nil {
		return err
	}
	for _, file := range reader.File {
		name, err := safeLocalSkillZipEntryName(file.Name)
		if err != nil {
			return err
		}
		targetPath := filepath.Join(root, name)
		rel, err := filepath.Rel(root, targetPath)
		if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
			return fmt.Errorf("invalid zip entry: %s", file.Name)
		}
		if file.FileInfo().IsDir() {
			if err := os.MkdirAll(targetPath, file.Mode()); err != nil {
				return err
			}
			continue
		}
		if file.Mode()&os.ModeSymlink != 0 {
			return fmt.Errorf("unsupported zip entry: %s", file.Name)
		}
		if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil {
			return err
		}
		rc, err := file.Open()
		if err != nil {
			return err
		}
		dst, err := os.OpenFile(targetPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, file.Mode())
		if err != nil {
			_ = rc.Close()
			return err
		}
		_, copyErr := io.Copy(dst, rc)
		closeErr := dst.Close()
		_ = rc.Close()
		if copyErr != nil {
			return copyErr
		}
		if closeErr != nil {
			return closeErr
		}
	}
	return nil
}

func untarLocalSkillPackage(packagePath, targetDir string, gzipped bool) error {
	src, err := os.Open(packagePath)
	if err != nil {
		return err
	}
	defer src.Close()
	var reader io.Reader = src
	var gzipReader *gzip.Reader
	if gzipped {
		gzipReader, err = gzip.NewReader(src)
		if err != nil {
			return err
		}
		defer gzipReader.Close()
		reader = gzipReader
	}
	root, err := filepath.Abs(targetDir)
	if err != nil {
		return err
	}
	tarReader := tar.NewReader(reader)
	for {
		header, err := tarReader.Next()
		if err == io.EOF {
			break
		}
		if err != nil {
			return err
		}
		name, err := safeLocalSkillZipEntryName(header.Name)
		if err != nil {
			return err
		}
		targetPath := filepath.Join(root, name)
		rel, err := filepath.Rel(root, targetPath)
		if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
			return fmt.Errorf("invalid tar entry: %s", header.Name)
		}
		info := header.FileInfo()
		if info.IsDir() {
			if err := os.MkdirAll(targetPath, info.Mode()); err != nil {
				return err
			}
			continue
		}
		if header.Typeflag != tar.TypeReg && header.Typeflag != tar.TypeRegA {
			continue
		}
		if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil {
			return err
		}
		dst, err := os.OpenFile(targetPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, info.Mode())
		if err != nil {
			return err
		}
		_, copyErr := io.Copy(dst, tarReader)
		closeErr := dst.Close()
		if copyErr != nil {
			return copyErr
		}
		if closeErr != nil {
			return closeErr
		}
	}
	return nil
}

func un7zLocalSkillPackage(packagePath, targetDir string) error {
	if _, err := listLocal7zEntries(packagePath); err != nil {
		return err
	}
	root, err := filepath.Abs(targetDir)
	if err != nil {
		return err
	}
	bin, err := findLocal7zBinary()
	if err != nil {
		return err
	}
	tempDir, err := os.MkdirTemp("", "1panel-agent-skill-7z-*")
	if err != nil {
		return err
	}
	defer os.RemoveAll(tempDir)
	if err := cmd.NewCommandMgr(cmd.WithTimeout(20*time.Minute)).Run(bin, "x", "-y", "-o"+tempDir, packagePath); err != nil {
		return err
	}
	if err := validateExtractedLocalSkillPackage(tempDir); err != nil {
		return err
	}
	return copyLocalDirContents(tempDir, root)
}

func safeLocalSkillZipEntryName(name string) (string, error) {
	clean := path.Clean(strings.ReplaceAll(name, "\\", "/"))
	clean = strings.TrimLeft(clean, "/")
	if clean == "." || clean == "" || clean == ".." || strings.HasPrefix(clean, "../") {
		return "", fmt.Errorf("invalid zip entry: %s", name)
	}
	return clean, nil
}

func copyLocalDirContents(sourceRoot, targetRoot string) error {
	return filepath.Walk(sourceRoot, func(sourcePath string, info os.FileInfo, err error) error {
		if err != nil {
			return err
		}
		if sourcePath == sourceRoot {
			return nil
		}
		if info.Mode()&os.ModeSymlink != 0 || (!info.IsDir() && !info.Mode().IsRegular()) {
			return fmt.Errorf("unsupported skill package entry: %s", sourcePath)
		}
		rel, err := filepath.Rel(sourceRoot, sourcePath)
		if err != nil {
			return err
		}
		name, err := safeLocalSkillZipEntryName(rel)
		if err != nil {
			return err
		}
		targetPath := filepath.Join(targetRoot, name)
		if info.IsDir() {
			return os.MkdirAll(targetPath, info.Mode())
		}
		return copyLocalSkillFile(sourcePath, targetPath)
	})
}

func copyLocalSkillFile(source, target string) error {
	src, err := os.Open(source)
	if err != nil {
		return err
	}
	defer src.Close()
	if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil {
		return err
	}
	dst, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
	if err != nil {
		return err
	}
	if _, err := io.Copy(dst, src); err != nil {
		_ = dst.Close()
		return err
	}
	return dst.Close()
}

func validateExtractedLocalSkillPackage(root string) error {
	return filepath.Walk(root, func(currentPath string, info os.FileInfo, err error) error {
		if err != nil {
			return err
		}
		if currentPath == root {
			return nil
		}
		if info.Mode()&os.ModeSymlink != 0 || (!info.IsDir() && !info.Mode().IsRegular()) {
			return fmt.Errorf("unsupported skill package entry: %s", currentPath)
		}
		return nil
	})
}

func localSkillPackageExtension(name string) string {
	lower := strings.ToLower(filepath.Base(strings.TrimSpace(name)))
	if lower == "" || lower == "." {
		return ""
	}
	if strings.HasSuffix(lower, ".tar.gz") {
		return ".tar.gz"
	}
	return strings.ToLower(filepath.Ext(lower))
}

func localSkillPackageFormat(name string) (string, error) {
	switch localSkillPackageExtension(name) {
	case ".zip":
		return "zip", nil
	case ".7z":
		return "7z", nil
	case ".tar":
		return "tar", nil
	case ".tar.gz":
		return "targz", nil
	default:
		return "", fmt.Errorf("only .zip, .7z, .tar, and .tar.gz skill packages are supported")
	}
}

func findLocal7zBinary() (string, error) {
	for _, name := range []string{"7z", "7za", "7zr"} {
		if path, err := exec.LookPath(name); err == nil {
			return path, nil
		}
	}
	return "", fmt.Errorf("7z command is required to process .7z skill packages")
}

func listLocal7zEntries(packagePath string) ([]string, error) {
	bin, err := findLocal7zBinary()
	if err != nil {
		return nil, err
	}
	output, err := exec.Command(bin, "l", "-slt", packagePath).CombinedOutput()
	if err != nil {
		if message := strings.TrimSpace(string(output)); message != "" {
			return nil, fmt.Errorf("7z failed: %w: %s", err, message)
		}
		return nil, fmt.Errorf("7z failed: %w", err)
	}
	entries := make([]string, 0)
	for _, line := range strings.Split(string(output), "\n") {
		line = strings.TrimSpace(line)
		if !strings.HasPrefix(line, "Path = ") {
			continue
		}
		entry := strings.TrimSpace(strings.TrimPrefix(line, "Path = "))
		if entry == "" || entry == packagePath || entry == filepath.Base(packagePath) {
			continue
		}
		name, err := safeLocalSkillZipEntryName(entry)
		if err != nil {
			return nil, err
		}
		entries = append(entries, name)
	}
	if len(entries) == 0 {
		return nil, fmt.Errorf("skill package is empty")
	}
	return entries, nil
}

func (a AgentService) UninstallSkill(req dto.AgentSkillUninstallReq) error {
	agent, install, err := a.loadAgentAndInstall(req.AgentID)
	if err != nil {
		return err
	}
	if agent.AgentType != constant.AppHermesAgent {
		return fmt.Errorf("%s does not support", agent.AgentType)
	}
	if err := ensureContainerRunning(install.ContainerName); err != nil {
		return err
	}
	return cmd.NewCommandMgr(cmd.WithTimeout(5*time.Minute)).Run(
		"docker",
		buildHermesDockerExecCommandArgs(
			install.ContainerName,
			"sh",
			"-lc",
			fmt.Sprintf(`printf 'y\n' | %s skills uninstall "$1"`, hermesExecutablePath),
			"sh",
			req.Name,
		)...,
	)
}

func parseOpenclawSkillsList(output string) ([]dto.AgentSkillItem, error) {
	payloadBytes, err := extractEmbeddedJSON(output)
	if err != nil {
		return nil, err
	}
	if len(payloadBytes) == 0 {
		return nil, nil
	}
	var payload openclawSkillsList
	if err := json.Unmarshal(payloadBytes, &payload); err != nil {
		return nil, err
	}
	items := make([]dto.AgentSkillItem, 0, len(payload.Skills))
	for _, item := range payload.Skills {
		items = append(items, dto.AgentSkillItem{
			Name:        item.Name,
			Description: item.Description,
			Source:      item.Source,
			Bundled:     item.Bundled,
			Disabled:    item.Disabled,
		})
	}
	return items, nil
}

func loadOpenclawSkillSearchOutput(containerName, source, keyword string) (string, error) {
	switch source {
	case "skillhub":
		return cmd.RunDockerExecWithStdout(2*time.Minute, containerName, "skillhub", "search", keyword, "--json")
	default:
		return cmd.NewCommandMgr(cmd.WithTimeout(2*time.Minute)).
			RunWithStdout(
				"docker",
				"exec",
				"-e",
				"CLAWHUB_REGISTRY="+resolveClawhubRegistry(source),
				containerName,
				"clawhub",
				"search",
				keyword,
			)
	}
}

func parseSkillhubSearchResult(output string) ([]dto.AgentSkillSearchItem, error) {
	trimmed := strings.TrimSpace(output)
	if trimmed == "" {
		return nil, nil
	}
	var list []dto.AgentSkillSearchItem
	if err := json.Unmarshal([]byte(trimmed), &list); err == nil {
		for i := range list {
			list[i].Source = "skillhub"
		}
		return list, nil
	}
	var payload skillhubSearchPayload
	if err := json.Unmarshal([]byte(trimmed), &payload); err != nil {
		return nil, err
	}
	items := payload.Skills
	if len(items) == 0 {
		items = payload.Results
	}
	for i := range items {
		items[i].Source = "skillhub"
	}
	return items, nil
}

func parseClawhubSearchResult(output, source string) []dto.AgentSkillSearchItem {
	lines := strings.Split(strings.TrimSpace(output), "\n")
	items := make([]dto.AgentSkillSearchItem, 0, len(lines))
	for _, line := range lines {
		matches := clawhubSearchLinePattern.FindStringSubmatch(strings.TrimSpace(line))
		if len(matches) != 4 {
			continue
		}
		items = append(items, dto.AgentSkillSearchItem{
			Slug:   matches[1],
			Name:   matches[2],
			Score:  matches[3],
			Source: source,
		})
	}
	return items
}

func installOpenclawSkill(mgr *cmd.CommandHelper, containerName, source, slug string) error {
	if err := mgr.Run("docker", "exec", containerName, "mkdir", "-p", openclawManagedSkillsDir); err != nil {
		return err
	}
	switch source {
	case "clawhub-global", "clawhub-cn":
		return mgr.Run(
			"docker",
			"exec",
			"-e",
			"CLAWHUB_REGISTRY="+resolveClawhubRegistry(source),
			containerName,
			"clawhub",
			"--workdir",
			"/home/node/.openclaw",
			"--dir",
			"skills",
			"install",
			slug,
		)
	default:
		return mgr.Run("docker", "exec", containerName, "skillhub", "--dir", openclawManagedSkillsDir, "install", slug)
	}
}

func resolveClawhubRegistry(source string) string {
	switch source {
	case "clawhub-cn":
		return clawhubChinaRegistry
	default:
		return clawhubGlobalRegistry
	}
}

func getOpenclawSkillKey(containerName, name string) (string, error) {
	output, err := cmd.RunDockerExecWithStdout(2*time.Minute, containerName, "openclaw", "skills", "info", name, "--json")
	if err != nil {
		return "", err
	}
	return parseOpenclawSkillKey(name, output)
}

func parseOpenclawSkillKey(name, output string) (string, error) {
	payloadBytes, err := extractEmbeddedJSON(output)
	if err != nil {
		return "", err
	}
	var payload openclawSkillInfo
	if err := json.Unmarshal(payloadBytes, &payload); err != nil {
		return "", err
	}
	if payload.SkillKey == "" {
		return name, nil
	}
	return payload.SkillKey, nil
}

func extractEmbeddedJSON(output string) ([]byte, error) {
	trimmed := strings.TrimSpace(ansiEscapePattern.ReplaceAllString(output, ""))
	if trimmed == "" {
		return nil, nil
	}
	for i := 0; i < len(trimmed); i++ {
		if trimmed[i] != '{' && trimmed[i] != '[' {
			continue
		}
		decoder := json.NewDecoder(strings.NewReader(trimmed[i:]))
		var raw json.RawMessage
		if err := decoder.Decode(&raw); err == nil {
			return raw, nil
		}
	}
	return nil, fmt.Errorf("json payload not found")
}

func setOpenclawSkillEnabled(conf map[string]interface{}, skillKey string, enabled bool) {
	skills := ensureChildMap(conf, "skills")
	entries := ensureChildMap(skills, "entries")
	entry := ensureChildMap(entries, skillKey)
	entry["enabled"] = enabled
}
