package runtimeconfig

import (
	"encoding/json"
	"fmt"
	"io"
	"log/slog"
	"os"
	"path/filepath"
	"sort"
	"strings"
)

var createRuntimeSkillSymlink = os.Symlink

// RuntimeSkillSource describes a single source directory whose skill
// subdirectories should be made available at a runtime's skill target.
//
// Sources are merged in Priority order (lower number wins). When two sources
// contribute a skill with the same name, the higher-priority source claims
// the slot and the lower-priority source silently skips it. Real local
// entries (a directory or file the user created inside the runtime skill
// target) always win over any source.
//
// Origin is a free-form label used only for log messages; suggested values
// are "profile" (manual ~/.agentteam/skills imports) and "managed:<repoID>"
// (remote skill vault clones under ~/.agentteam/managed-skills).
type RuntimeSkillSource struct {
	Dir      string
	Priority int
	Origin   string
}

// Authoritative priorities used by Desktop callers. Keeping them in one place
// lets future sources slot in without renumbering existing ones.
const (
	// RuntimeSkillPriorityProfile is the manual ~/.agentteam/skills directory.
	RuntimeSkillPriorityProfile = 100
	// RuntimeSkillPriorityBuiltin is a launch-scoped daemon capability skill.
	// User profile skills keep precedence on name collisions.
	RuntimeSkillPriorityBuiltin = 150
	// RuntimeSkillPriorityManaged is the remote-managed ~/.agentteam/managed-skills tree.
	RuntimeSkillPriorityManaged = 200
)

// Stable Origin labels. These are also load-bearing: the runtime skill
// materializer uses them to decide whether a source should be symlinked
// (profile) or copied as a launch-time snapshot (managed).
const (
	// RuntimeSkillOriginProfile marks a user-managed source whose contents
	// only change by direct user action.
	RuntimeSkillOriginProfile = "profile"
	// RuntimeSkillOriginManaged marks a source whose contents can change via
	// external sync without user action; the materializer must snapshot its
	// skill set at launch time.
	RuntimeSkillOriginManaged = "managed"
	// RuntimeSkillOriginBuiltin marks launch-scoped daemon capability skills.
	// They are copied and owned by the runtime skill manifest.
	RuntimeSkillOriginBuiltin = "builtin"
)

// MaterializeRuntimeSkillsFromSources makes every valid source's skills
// available at the runtime's workdir-specific skill target before spawn.
//
// The set of sources and their on-disk contents are snapshotted at the moment
// this function runs. It deliberately does not establish any filesystem
// watcher, so changes to a source after this call returns do not propagate
// to the already-materialized target until the next launch. This matches the
// product decision that skill binding changes apply on next agent start.
func MaterializeRuntimeSkillsFromSources(rt RuntimeType, workDir string, sources []RuntimeSkillSource) error {
	workDir = strings.TrimSpace(workDir)
	if workDir == "" {
		return nil
	}

	valid := filterRuntimeSkillSources(sources)
	for _, targetDir := range runtimeSkillTargets(rt, workDir) {
		if len(valid) == 0 {
			if err := cleanupRuntimeSkillSourcesTarget(targetDir); err != nil {
				return err
			}
			continue
		}
		if err := ensureRuntimeSkillSourcesTarget(valid, targetDir); err != nil {
			return err
		}
	}
	return nil
}

// PruneMissingManagedRuntimeSkills immediately removes manifest-owned runtime
// slots sourced beneath one of the managed repository roots just removed by
// the daemon. Unlike full materialization, it leaves profile, builtin, and all
// other managed placements untouched. Passing the removed roots explicitly
// avoids treating a transiently unavailable source as an access revocation.
func PruneMissingManagedRuntimeSkills(rt RuntimeType, workDir string, removedSourceRoots []string) error {
	workDir = strings.TrimSpace(workDir)
	if workDir == "" {
		return nil
	}
	removedRoots := normalizeRuntimeSkillPaths(removedSourceRoots)
	if len(removedRoots) == 0 {
		return nil
	}
	for _, targetDir := range runtimeSkillTargets(rt, workDir) {
		if err := pruneMissingManagedRuntimeSkillsTarget(targetDir, removedRoots); err != nil {
			return err
		}
	}
	return nil
}

// PruneRemovedManagedRuntimeSkillsUnderAgentRoot applies managed-source
// revocation to every persisted runtime workdir owned by one Agent: the
// current Agent root and legacy/special session workdirs. It checks every
// supported runtime target because historical
// workdirs may have been created by a different runtime than the Agent's
// current configuration.
func PruneRemovedManagedRuntimeSkillsUnderAgentRoot(agentRoot string, removedSourceRoots []string) error {
	agentRoot = strings.TrimSpace(agentRoot)
	if agentRoot == "" {
		return nil
	}
	removedRoots := normalizeRuntimeSkillPaths(removedSourceRoots)
	if len(removedRoots) == 0 {
		return nil
	}
	workDirs, err := runtimeSkillWorkDirsUnderAgentRoot(agentRoot)
	if err != nil {
		return err
	}
	for _, workDir := range workDirs {
		for _, targetDir := range allRuntimeSkillTargets(workDir) {
			if err := pruneMissingManagedRuntimeSkillsTarget(targetDir, removedRoots); err != nil {
				return err
			}
		}
	}
	return nil
}

func runtimeSkillWorkDirsUnderAgentRoot(agentRoot string) ([]string, error) {
	workDirs := []string{agentRoot}
	appendChildDirs := func(root string, add func(string) string) error {
		entries, err := os.ReadDir(root)
		if os.IsNotExist(err) {
			return nil
		}
		if err != nil {
			return fmt.Errorf("read runtime workdir root %s: %w", root, err)
		}
		for _, entry := range entries {
			if entry.IsDir() {
				workDirs = append(workDirs, add(entry.Name()))
			}
		}
		return nil
	}
	if err := appendChildDirs(filepath.Join(agentRoot, "sessions"), func(name string) string {
		return filepath.Join(agentRoot, "sessions", name)
	}); err != nil {
		return nil, err
	}
	return workDirs, nil
}

func allRuntimeSkillTargets(workDir string) []string {
	return []string{
		filepath.Join(workDir, ".agents", "skills"),
		filepath.Join(workDir, ".claude", "skills"),
		filepath.Join(ManagedOpenCodeConfigDir(workDir), "skills"),
	}
}

func pruneMissingManagedRuntimeSkillsTarget(targetDir string, removedRoots []string) error {
	info, err := os.Lstat(targetDir)
	if os.IsNotExist(err) {
		return nil
	}
	if err != nil {
		return fmt.Errorf("inspect skill target %s: %w", targetDir, err)
	}
	if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
		return nil
	}

	manifest := readRuntimeSkillManifest(targetDir)
	if len(manifest.Entries) == 0 {
		return nil
	}
	kept := make([]runtimeSkillManifestEntry, 0, len(manifest.Entries))
	changed := false
	for _, entry := range manifest.Entries {
		if entry.Origin != RuntimeSkillOriginManaged || !pathWithinAnyRoot(entry.Source, removedRoots) {
			kept = append(kept, entry)
			continue
		}
		slotPath := filepath.Join(targetDir, entry.Name)
		if err := os.RemoveAll(slotPath); err != nil {
			slog.Warn("failed to remove revoked managed runtime skill", "slot", slotPath, "source", entry.Source, "error", err)
			kept = append(kept, entry)
			continue
		}
		changed = true
	}
	if !changed {
		return nil
	}
	manifest.Entries = kept
	return writeRuntimeSkillManifest(targetDir, manifest)
}

func normalizeRuntimeSkillPaths(paths []string) []string {
	out := make([]string, 0, len(paths))
	seen := make(map[string]struct{}, len(paths))
	for _, path := range paths {
		path = resolvedPath(strings.TrimSpace(path))
		if path == "" {
			continue
		}
		if _, duplicate := seen[path]; duplicate {
			continue
		}
		seen[path] = struct{}{}
		out = append(out, path)
	}
	return out
}

func pathWithinAnyRoot(path string, roots []string) bool {
	path = resolvedPath(strings.TrimSpace(path))
	for _, root := range roots {
		relative, err := filepath.Rel(root, path)
		if err == nil && relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
			return true
		}
	}
	return false
}

// filterRuntimeSkillSources drops entries with empty directories, collapses
// duplicates (first occurrence wins), and sorts the survivors by Priority
// (stable, so equal-priority sources keep their input order).
func filterRuntimeSkillSources(sources []RuntimeSkillSource) []RuntimeSkillSource {
	valid := make([]RuntimeSkillSource, 0, len(sources))
	seen := make(map[string]bool, len(sources))
	for _, src := range sources {
		src.Dir = strings.TrimSpace(src.Dir)
		if src.Dir == "" {
			continue
		}
		if seen[src.Dir] {
			continue
		}
		if _, err := os.Stat(src.Dir); err != nil {
			slog.Debug("runtime skill source missing; skipping", "dir", src.Dir, "origin", src.Origin, "error", err)
			continue
		}
		seen[src.Dir] = true
		valid = append(valid, src)
	}
	sort.SliceStable(valid, func(i, j int) bool {
		return valid[i].Priority < valid[j].Priority
	})
	return valid
}

func runtimeSkillTargets(rt RuntimeType, workDir string) []string {
	if IsBuiltinRuntime(rt) {
		configDir := ManagedOpenCodeConfigDir(workDir)
		if configDir == "" {
			return nil
		}
		return []string{filepath.Join(configDir, "skills")}
	}
	switch EffectiveRuntimeType(rt) {
	case TypeClaudeCode:
		return []string{filepath.Join(workDir, ".claude", "skills")}
	case TypeCodex, TypeOpenCode, TypeKimi, TypePi:
		return []string{filepath.Join(workDir, ".agents", "skills")}
	default:
		return nil
	}
}

// ensureRuntimeSkillSourcesTarget guarantees that every skill from every source
// is reachable inside targetDir while preserving real local entries.
//
// Behaviour by target state:
//
//	absent             → mkdir target, inject each source per-skill
//	existing symlink   → unlink, mkdir target, inject each source per-skill
//	real directory     → inject each source per-skill, locals win
//	regular file       → error
//
// The target is deliberately never a whole-directory symlink to ~/.agentteam/skills.
// Managed skill repositories are merged into the same runtime target, and a
// whole-dir symlink would either write through into the profile directory or
// let later source changes bypass the launch-time authorization snapshot.
func ensureRuntimeSkillSourcesTarget(sources []RuntimeSkillSource, targetDir string) error {
	if err := os.MkdirAll(filepath.Dir(targetDir), 0o755); err != nil {
		return fmt.Errorf("create skill parent %s: %w", filepath.Dir(targetDir), err)
	}

	info, err := os.Lstat(targetDir)
	if os.IsNotExist(err) {
		if err := os.MkdirAll(targetDir, 0o755); err != nil {
			return fmt.Errorf("create skill target %s: %w", targetDir, err)
		}
		return syncSkillSourcesToLocal(sources, targetDir)
	}
	if err != nil {
		return fmt.Errorf("inspect skill target %s: %w", targetDir, err)
	}

	// Existing symlink.
	if info.Mode()&os.ModeSymlink != 0 {
		resolved, _ := resolveLinkTarget(targetDir)
		slog.Warn("replacing skill symlink with multi-source directory", "target", targetDir, "old", resolved)
		if err := os.Remove(targetDir); err != nil {
			return fmt.Errorf("remove stale symlink %s: %w", targetDir, err)
		}
		if err := os.MkdirAll(targetDir, 0o755); err != nil {
			return fmt.Errorf("create skill target %s: %w", targetDir, err)
		}
		return syncSkillSourcesToLocal(sources, targetDir)
	}

	// Regular file.
	if !info.IsDir() {
		return fmt.Errorf("skill target %s exists and is not a directory", targetDir)
	}

	// Real directory.
	return syncSkillSourcesToLocal(sources, targetDir)
}

// cleanupRuntimeSkillSourcesTarget removes manifest-owned placements when a
// launch has no currently valid sources. This covers the "last managed skill
// was deleted" case: there is nothing to inject, but prior materialized slots
// still need to be removed on next launch. Real local entries are preserved
// because syncSkillSourcesToLocal only removes entries named in the manifest.
func cleanupRuntimeSkillSourcesTarget(targetDir string) error {
	info, err := os.Lstat(targetDir)
	if os.IsNotExist(err) {
		return nil
	}
	if err != nil {
		return fmt.Errorf("inspect skill target %s: %w", targetDir, err)
	}
	if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
		return nil
	}

	prev := readRuntimeSkillManifest(targetDir)
	if len(prev.Entries) == 0 {
		return nil
	}
	return syncSkillSourcesToLocal(nil, targetDir)
}

// runtimeSkillManifestFilename is the sidecar file inside a runtime skill
// target that records which slots the materializer placed and how. It is the
// single source of truth for "is this slot ours to remove on the next launch?".
//
// Runtimes look for `<skillName>/SKILL.md`, so a top-level dotfile is ignored.
const runtimeSkillManifestFilename = ".agentteam-runtime-skills.json"

// runtimeSkillPlacementMode labels how a slot was materialized.
const (
	// runtimeSkillModeSymlink: slot is a symlink to a source skill dir. Used
	// for user-managed profile sources — live user edits in the source are
	// intentionally visible at the runtime target.
	runtimeSkillModeSymlink = "symlink"
	// runtimeSkillModeCopy: slot is a real directory copied from a source
	// skill dir. Used for externally synced managed sources — the copy is a
	// launch-time snapshot, so post-launch sync changes (new skills, content
	// updates, removals) only appear on the next materialize.
	runtimeSkillModeCopy = "copy"
)

// runtimeSkillManifestEntry records one slot the materializer placed.
type runtimeSkillManifestEntry struct {
	Name   string `json:"name"`
	Source string `json:"source"`
	Origin string `json:"origin"`
	Mode   string `json:"mode"`
}

type runtimeSkillSourceEntry struct {
	Name string
	Path string
}

// runtimeSkillManifest is the persisted list of placements. Version is bumped
// if the on-disk schema ever changes; readers reject unknown versions rather
// than guessing.
type runtimeSkillManifest struct {
	Version int                         `json:"version"`
	Entries []runtimeSkillManifestEntry `json:"entries"`
}

const runtimeSkillManifestVersion = 1

func readRuntimeSkillManifest(targetDir string) runtimeSkillManifest {
	path := filepath.Join(targetDir, runtimeSkillManifestFilename)
	data, err := os.ReadFile(path)
	if err != nil {
		return runtimeSkillManifest{}
	}
	var m runtimeSkillManifest
	if err := json.Unmarshal(data, &m); err != nil {
		slog.Debug("runtime skill manifest: parse failed; treating as empty", "target", targetDir, "error", err)
		return runtimeSkillManifest{}
	}
	if m.Version != runtimeSkillManifestVersion {
		slog.Debug("runtime skill manifest: unknown version; treating as empty", "target", targetDir, "version", m.Version)
		return runtimeSkillManifest{}
	}
	return m
}

func writeRuntimeSkillManifest(targetDir string, m runtimeSkillManifest) error {
	path := filepath.Join(targetDir, runtimeSkillManifestFilename)
	data, err := json.MarshalIndent(m, "", "  ")
	if err != nil {
		return fmt.Errorf("marshal runtime skill manifest: %w", err)
	}
	if err := os.WriteFile(path, data, 0o644); err != nil {
		return fmt.Errorf("write runtime skill manifest %s: %w", path, err)
	}
	return nil
}

// syncSkillSourcesToLocal injects skills from every source (priority order)
// into targetDir. The first source to claim a skill name wins; later sources
// silently skip it. Real local entries (slots the materializer did not place)
// are always preserved.
//
// Placement mode is selected by Origin:
//   - profile (user-managed): symlink to the source skill dir. Live user edits
//     in the source are visible at the target.
//   - managed (externally synced): real-directory copy of the source skill dir.
//     The copy is a launch-time snapshot, so post-launch sync changes only
//     appear on the next materialize (next-start semantics).
//
// A sidecar manifest at <targetDir>/.agentteam-runtime-skills.json records every
// placement. On each call the materializer removes its own previous placements
// (per the manifest) and re-injects fresh, so a skill removed from a source is
// cleaned up on the next launch. Slots not in the manifest are real local
// entries and are never touched.
func syncSkillSourcesToLocal(sources []RuntimeSkillSource, targetDir string) error {
	prev := readRuntimeSkillManifest(targetDir)
	for _, entry := range prev.Entries {
		slotPath := filepath.Join(targetDir, entry.Name)
		if err := os.RemoveAll(slotPath); err != nil {
			// Non-fatal: a stuck slot should not block agent startup. The
			// re-injection below will skip it because the slot is occupied.
			slog.Warn("failed to remove previous runtime skill placement", "slot", slotPath, "origin", entry.Origin, "error", err)
		}
	}

	newManifest := runtimeSkillManifest{Version: runtimeSkillManifestVersion}
	claimed := make(map[string]struct{})
	for _, src := range sources {
		mode := runtimeSkillPlacementMode(src.Origin)
		entries, err := discoverRuntimeSkillSourceEntries(src.Dir)
		if err != nil {
			slog.Warn("failed to read skill source", "source", src.Dir, "origin", src.Origin, "error", err)
			continue
		}
		for _, entry := range entries {
			skillName := entry.Name
			sourceSkillPath := entry.Path
			slotPath := filepath.Join(targetDir, skillName)

			if _, ok := claimed[skillName]; ok {
				continue
			}

			placed, err := ensureRuntimeSkillSlot(sourceSkillPath, slotPath, mode)
			if err != nil {
				slog.Warn("failed to place runtime skill", "skill", skillName, "origin", src.Origin, "mode", mode, "error", err)
				continue
			}
			if !placed {
				continue
			}
			claimed[skillName] = struct{}{}
			newManifest.Entries = append(newManifest.Entries, runtimeSkillManifestEntry{
				Name:   skillName,
				Source: sourceSkillPath,
				Origin: src.Origin,
				Mode:   mode,
			})
		}
	}

	if err := writeRuntimeSkillManifest(targetDir, newManifest); err != nil {
		// Non-fatal: a missing manifest means the next launch cannot clean up
		// stale placements, but the current launch's placements are correct.
		slog.Warn("failed to write runtime skill manifest", "target", targetDir, "error", err)
	}
	return nil
}

// ensureRuntimeSkillSlot places one source skill into a target slot. Real local
// directories/files win, but symlink slots are materializer-owned and may be
// replaced so stale links cannot tunnel unauthorized content.
func ensureRuntimeSkillSlot(sourceSkillPath, slotPath, mode string) (bool, error) {
	info, err := os.Lstat(slotPath)
	if os.IsNotExist(err) {
		return true, placeRuntimeSkill(sourceSkillPath, slotPath, mode)
	}
	if err != nil {
		return false, fmt.Errorf("inspect skill slot %s: %w", slotPath, err)
	}
	if info.Mode()&os.ModeSymlink == 0 {
		return false, nil
	}

	resolved, linkErr := resolveLinkTarget(slotPath)
	if linkErr == nil && mode == runtimeSkillModeSymlink && samePath(resolved, sourceSkillPath) {
		return true, nil
	}

	slog.Warn("replacing runtime skill symlink", "slot", slotPath, "old", resolved, "new", sourceSkillPath)
	if err := os.Remove(slotPath); err != nil {
		return false, fmt.Errorf("remove skill symlink %s: %w", slotPath, err)
	}
	return true, placeRuntimeSkill(sourceSkillPath, slotPath, mode)
}

// discoverRuntimeSkillSourceEntries returns the runtime-visible skill
// directories in one source. Managed .sk repositories may use any of the
// server-supported layouts:
//
//   - SKILL.md at repository root (single-skill compatibility layout)
//   - <dir>/SKILL.md (collection layout)
//   - skills/<slug>/SKILL.md (default Create template layout)
//
// Older tests and local profile imports may still use a directory-only fixture
// without SKILL.md. If no recognized SKILL.md layout is present, we fall back to
// the previous behavior and expose immediate child directories.
func discoverRuntimeSkillSourceEntries(sourceDir string) ([]runtimeSkillSourceEntry, error) {
	entries, err := os.ReadDir(sourceDir)
	if err != nil {
		return nil, err
	}
	out := make([]runtimeSkillSourceEntry, 0, len(entries))
	seen := make(map[string]struct{}, len(entries))
	add := func(name, path string) {
		name = runtimeSkillSlotName(name)
		if name == "" {
			return
		}
		if _, ok := seen[name]; ok {
			return
		}
		seen[name] = struct{}{}
		out = append(out, runtimeSkillSourceEntry{Name: name, Path: path})
	}
	if hasRegularFile(filepath.Join(sourceDir, "SKILL.md")) {
		add(filepath.Base(sourceDir), sourceDir)
	}
	for _, entry := range entries {
		if !entry.IsDir() || entry.Name() == ".git" {
			continue
		}
		path := filepath.Join(sourceDir, entry.Name())
		if hasRegularFile(filepath.Join(path, "SKILL.md")) {
			add(entry.Name(), path)
		}
	}
	skillsDir := filepath.Join(sourceDir, "skills")
	if skillsEntries, err := os.ReadDir(skillsDir); err == nil {
		for _, entry := range skillsEntries {
			if !entry.IsDir() {
				continue
			}
			path := filepath.Join(skillsDir, entry.Name())
			if hasRegularFile(filepath.Join(path, "SKILL.md")) {
				add(entry.Name(), path)
			}
		}
	}
	if len(out) > 0 {
		sort.SliceStable(out, func(i, j int) bool { return out[i].Name < out[j].Name })
		return out, nil
	}
	for _, entry := range entries {
		if entry.IsDir() && entry.Name() != ".git" {
			add(entry.Name(), filepath.Join(sourceDir, entry.Name()))
		}
	}
	sort.SliceStable(out, func(i, j int) bool { return out[i].Name < out[j].Name })
	return out, nil
}

func hasRegularFile(path string) bool {
	info, err := os.Lstat(path)
	return err == nil && info.Mode().IsRegular()
}

func runtimeSkillSlotName(name string) string {
	name = strings.TrimSpace(name)
	if name == "." || name == string(filepath.Separator) {
		return ""
	}
	name = strings.Trim(name, string(filepath.Separator))
	name = strings.ReplaceAll(name, string(filepath.Separator), "-")
	name = strings.ReplaceAll(name, "/", "-")
	name = strings.Trim(name, ". ")
	if name == "" {
		return "skill"
	}
	return name
}

// runtimeSkillPlacementMode selects how a source's skills are materialized.
// User-managed profile sources symlink (live user edits are intentional);
// externally synced managed sources copy (snapshot at launch time).
func runtimeSkillPlacementMode(origin string) string {
	if origin == RuntimeSkillOriginProfile {
		return runtimeSkillModeSymlink
	}
	return runtimeSkillModeCopy
}

// placeRuntimeSkill creates slotPath from sourceSkillPath using the requested
// mode. For symlink mode it falls back to copy when symlink creation fails
// (e.g. on filesystems that do not support symlinks).
func placeRuntimeSkill(sourceSkillPath, slotPath, mode string) error {
	switch mode {
	case runtimeSkillModeSymlink:
		return linkOrCopyRuntimeSkillDir(sourceSkillPath, slotPath)
	case runtimeSkillModeCopy:
		return copyRuntimeSkillDir(sourceSkillPath, slotPath)
	default:
		return fmt.Errorf("unknown runtime skill placement mode %q", mode)
	}
}

func resolveLinkTarget(path string) (string, error) {
	target, err := os.Readlink(path)
	if err != nil {
		return "", err
	}
	if !filepath.IsAbs(target) {
		target = filepath.Join(filepath.Dir(path), target)
	}
	return filepath.Abs(target)
}

func samePath(left, right string) bool {
	return resolvedPath(left) == resolvedPath(right)
}

func resolvedPath(path string) string {
	abs, err := filepath.Abs(path)
	if err != nil {
		abs = filepath.Clean(path)
	}
	resolved, err := filepath.EvalSymlinks(abs)
	if err == nil {
		abs = resolved
	}
	return filepath.Clean(abs)
}

func linkOrCopyRuntimeSkillDir(sourceDir, targetDir string) error {
	if err := createRuntimeSkillSymlink(sourceDir, targetDir); err == nil {
		return nil
	} else if copyErr := copyRuntimeSkillDir(sourceDir, targetDir); copyErr != nil {
		return fmt.Errorf("link %s -> %s: %w; copy fallback failed: %v", targetDir, sourceDir, err, copyErr)
	} else {
		slog.Warn("runtime skill symlink unavailable; copied skill directory", "target", targetDir, "source", sourceDir, "error", err)
	}
	return nil
}

func copyRuntimeSkillDir(sourceDir, targetDir string) error {
	sourceInfo, err := os.Stat(sourceDir)
	if err != nil {
		return fmt.Errorf("stat source %s: %w", sourceDir, err)
	}
	if !sourceInfo.IsDir() {
		return fmt.Errorf("source %s is not a directory", sourceDir)
	}

	parentDir := filepath.Dir(targetDir)
	if err := os.MkdirAll(parentDir, 0o755); err != nil {
		return fmt.Errorf("create skill parent %s: %w", parentDir, err)
	}

	tempDir, err := os.MkdirTemp(parentDir, "."+filepath.Base(targetDir)+".tmp-")
	if err != nil {
		return fmt.Errorf("create temporary skill copy: %w", err)
	}
	cleanup := true
	defer func() {
		if cleanup {
			if err := os.RemoveAll(tempDir); err != nil {
				slog.Warn("failed to remove temporary skill copy", "path", tempDir, "error", err)
			}
		}
	}()

	if err := copyDirContents(sourceDir, tempDir); err != nil {
		return err
	}
	if err := os.Rename(tempDir, targetDir); err != nil {
		return fmt.Errorf("rename temporary skill copy %s -> %s: %w", tempDir, targetDir, err)
	}
	cleanup = false
	return nil
}

func copyDirContents(sourceDir, targetDir string) error {
	entries, err := os.ReadDir(sourceDir)
	if err != nil {
		return fmt.Errorf("read source dir %s: %w", sourceDir, err)
	}
	for _, entry := range entries {
		if entry.Name() == ".git" {
			continue
		}
		sourcePath := filepath.Join(sourceDir, entry.Name())
		targetPath := filepath.Join(targetDir, entry.Name())
		info, err := os.Lstat(sourcePath)
		if err != nil {
			return fmt.Errorf("inspect source path %s: %w", sourcePath, err)
		}

		switch {
		case info.Mode()&os.ModeSymlink != 0:
			slog.Warn("skipping symlink in runtime skill copy", "path", sourcePath)
		case info.IsDir():
			if err := os.MkdirAll(targetPath, 0o755); err != nil {
				return fmt.Errorf("create copied skill dir %s: %w", targetPath, err)
			}
			if err := copyDirContents(sourcePath, targetPath); err != nil {
				return err
			}
		case info.Mode().IsRegular():
			if err := copyRegularFile(sourcePath, targetPath, info.Mode().Perm()); err != nil {
				return err
			}
		default:
			slog.Warn("skipping unsupported path in runtime skill copy", "path", sourcePath, "mode", info.Mode().String())
		}
	}
	return nil
}

func copyRegularFile(sourcePath, targetPath string, mode os.FileMode) error {
	sourceFile, err := os.Open(sourcePath)
	if err != nil {
		return fmt.Errorf("open source file %s: %w", sourcePath, err)
	}
	defer sourceFile.Close()

	targetFile, err := os.OpenFile(targetPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, mode)
	if err != nil {
		return fmt.Errorf("create copied skill file %s: %w", targetPath, err)
	}
	if _, err := io.Copy(targetFile, sourceFile); err != nil {
		_ = targetFile.Close()
		return fmt.Errorf("copy skill file %s -> %s: %w", sourcePath, targetPath, err)
	}
	if err := targetFile.Close(); err != nil {
		return fmt.Errorf("close copied skill file %s: %w", targetPath, err)
	}
	return nil
}
