package runtimeconfig

import (
	"errors"
	"os"
	"path/filepath"
	"runtime"
	"testing"

	"github.com/stretchr/testify/require"
)

func TestMaterializeRuntimeSkillsFromSources_BuiltinUsesManagedOpenCodeConfigSkills(t *testing.T) {
	skipIfWindowsNoSymlink(t)

	sourceDir := filepath.Join(t.TempDir(), "agentteam-skills")
	require.NoError(t, os.MkdirAll(filepath.Join(sourceDir, "review"), 0o755))

	workDir := t.TempDir()
	err := MaterializeRuntimeSkillsFromSources(TypeBuiltin, workDir, []RuntimeSkillSource{
		{Dir: sourceDir, Priority: RuntimeSkillPriorityProfile, Origin: RuntimeSkillOriginProfile},
	})
	require.NoError(t, err)

	target := filepath.Join(ManagedOpenCodeConfigDir(workDir), "skills")
	assertRealSkillTarget(t, target)
	assertSymlinkTarget(t, filepath.Join(target, "review"), filepath.Join(sourceDir, "review"))
	_, err = os.Stat(filepath.Join(workDir, ".agents", "skills"))
	require.True(t, os.IsNotExist(err))
}

func TestMaterializeRuntimeSkillsFromSources_CodexUsesAgentsSkillsDirectory(t *testing.T) {
	skipIfWindowsNoSymlink(t)

	sourceDir := filepath.Join(t.TempDir(), "agentteam-skills")
	require.NoError(t, os.MkdirAll(filepath.Join(sourceDir, "review"), 0o755))

	workDir := t.TempDir()
	err := MaterializeRuntimeSkillsFromSources(TypeCodex, workDir, []RuntimeSkillSource{
		{Dir: sourceDir, Priority: RuntimeSkillPriorityProfile, Origin: RuntimeSkillOriginProfile},
	})
	require.NoError(t, err)

	target := filepath.Join(workDir, ".agents", "skills")
	assertRealSkillTarget(t, target)
	assertSymlinkTarget(t, filepath.Join(target, "review"), filepath.Join(sourceDir, "review"))
}

func TestMaterializeRuntimeSkillsFromSources_KimiUsesAgentsSkillsDirectory(t *testing.T) {
	skipIfWindowsNoSymlink(t)

	sourceDir := filepath.Join(t.TempDir(), "agentteam-skills")
	require.NoError(t, os.MkdirAll(filepath.Join(sourceDir, "review"), 0o755))

	workDir := t.TempDir()
	require.NoError(t, MaterializeRuntimeSkillsFromSources(TypeKimi, workDir, []RuntimeSkillSource{
		{Dir: sourceDir, Priority: RuntimeSkillPriorityProfile, Origin: RuntimeSkillOriginProfile},
	}))

	target := filepath.Join(workDir, ".agents", "skills")
	assertRealSkillTarget(t, target)
	assertSymlinkTarget(t, filepath.Join(target, "review"), filepath.Join(sourceDir, "review"))
}

func TestMaterializeRuntimeSkillsFromSources_FallsBackWhenSymlinkFails(t *testing.T) {
	sourceDir := filepath.Join(t.TempDir(), "agentteam-skills")
	require.NoError(t, os.MkdirAll(filepath.Join(sourceDir, "review"), 0o755))
	require.NoError(t, os.WriteFile(filepath.Join(sourceDir, "review", "SKILL.md"), []byte("# Review\n"), 0o644))

	originalSymlink := createRuntimeSkillSymlink
	createRuntimeSkillSymlink = func(oldname, newname string) error {
		return &os.LinkError{
			Op:  "symlink",
			Old: oldname,
			New: newname,
			Err: errors.New("A required privilege is not held by the client"),
		}
	}
	t.Cleanup(func() {
		createRuntimeSkillSymlink = originalSymlink
	})

	workDir := t.TempDir()
	err := MaterializeRuntimeSkillsFromSources(TypeOpenCode, workDir, []RuntimeSkillSource{
		{Dir: sourceDir, Priority: RuntimeSkillPriorityProfile, Origin: RuntimeSkillOriginProfile},
	})
	require.NoError(t, err)

	target := filepath.Join(workDir, ".agents", "skills")
	assertRealSkillTarget(t, target)
	info, err := os.Lstat(filepath.Join(target, "review"))
	require.NoError(t, err)
	require.False(t, info.Mode()&os.ModeSymlink != 0, "skill slot should fall back to a real directory")

	content, err := os.ReadFile(filepath.Join(target, "review", "SKILL.md"))
	require.NoError(t, err)
	require.Equal(t, "# Review\n", string(content))
}

func TestMaterializeRuntimeSkillsFromSources_ClaudeUsesClaudeSkillsDirectory(t *testing.T) {
	skipIfWindowsNoSymlink(t)

	sourceDir := filepath.Join(t.TempDir(), "agentteam-skills")
	require.NoError(t, os.MkdirAll(filepath.Join(sourceDir, "review"), 0o755))

	workDir := t.TempDir()
	err := MaterializeRuntimeSkillsFromSources(TypeClaudeCode, workDir, []RuntimeSkillSource{
		{Dir: sourceDir, Priority: RuntimeSkillPriorityProfile, Origin: RuntimeSkillOriginProfile},
	})
	require.NoError(t, err)

	target := filepath.Join(workDir, ".claude", "skills")
	assertRealSkillTarget(t, target)
	assertSymlinkTarget(t, filepath.Join(target, "review"), filepath.Join(sourceDir, "review"))
}

func TestMaterializeRuntimeSkillsFromSources_UnsupportedRuntimeNoop(t *testing.T) {
	sourceDir := filepath.Join(t.TempDir(), "agentteam-skills")
	require.NoError(t, os.MkdirAll(filepath.Join(sourceDir, "review"), 0o755))

	workDir := t.TempDir()
	err := MaterializeRuntimeSkillsFromSources(RuntimeType(""), workDir, []RuntimeSkillSource{
		{Dir: sourceDir, Priority: RuntimeSkillPriorityProfile, Origin: RuntimeSkillOriginProfile},
	})
	require.NoError(t, err)
	_, err = os.Stat(filepath.Join(workDir, ".agents", "skills"))
	require.True(t, os.IsNotExist(err))
}

func skipIfWindowsNoSymlink(t *testing.T) {
	t.Helper()
	if runtime.GOOS == "windows" {
		t.Skip("symlink behavior depends on Windows privileges")
	}
}

func assertSymlinkTarget(t *testing.T, path, wantTarget string) {
	t.Helper()

	info, err := os.Lstat(path)
	require.NoError(t, err)
	require.True(t, info.Mode()&os.ModeSymlink != 0)

	resolved, err := resolveLinkTarget(path)
	require.NoError(t, err)
	require.True(t, samePath(resolved, wantTarget), "expected %s to point to %s, got %s", path, wantTarget, resolved)
}

func assertRealSkillTarget(t *testing.T, path string) {
	t.Helper()

	info, err := os.Lstat(path)
	require.NoError(t, err)
	require.True(t, info.IsDir(), "target %s must be a directory", path)
	require.Equal(t, os.FileMode(0)&os.ModeSymlink, info.Mode()&os.ModeSymlink,
		"target %s must be a real directory, not a symlink", path)
}

// assertCopiedSkillDir asserts that slotPath is a real directory (not a
// symlink) whose contents were copied from sourceSkillPath. Used to pin the
// managed-source snapshot semantics: the runtime target must hold an
// immutable copy, not a live symlink into the vault.
func assertCopiedSkillDir(t *testing.T, slotPath, sourceSkillPath string) {
	t.Helper()

	info, err := os.Lstat(slotPath)
	require.NoError(t, err)
	require.False(t, info.Mode()&os.ModeSymlink != 0, "slot %s must be a real directory, not a symlink", slotPath)
	require.True(t, info.IsDir(), "slot %s must be a directory", slotPath)

	// Verify contents match the source by walking both trees and comparing
	// relative file paths and bytes.
	sourceFiles := readDirTree(t, sourceSkillPath)
	slotFiles := readDirTree(t, slotPath)
	require.Equal(t, sourceFiles, slotFiles,
		"slot %s contents must mirror source %s", slotPath, sourceSkillPath)
}

// readDirTree returns a map of relative file path → file contents for every
// regular file under root. Symlinks are reported by their link target so a
// copied tree (which drops symlinks per copyDirContents) is comparable.
func readDirTree(t *testing.T, root string) map[string]string {
	t.Helper()
	out := make(map[string]string)
	err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
		if err != nil {
			return err
		}
		if path == root {
			return nil
		}
		rel, err := filepath.Rel(root, path)
		if err != nil {
			return err
		}
		if info.IsDir() {
			return nil
		}
		if info.Mode()&os.ModeSymlink != 0 {
			target, lerr := os.Readlink(path)
			if lerr != nil {
				return lerr
			}
			out[rel] = "symlink:" + target
			return nil
		}
		data, ferr := os.ReadFile(path)
		if ferr != nil {
			return ferr
		}
		out[rel] = string(data)
		return nil
	})
	require.NoError(t, err, "walk %s", root)
	return out
}

// ---------------------------------------------------------------------------
// Multi-source materialization (MaterializeRuntimeSkillsFromSources)
// ---------------------------------------------------------------------------

func TestMaterializeRuntimeSkillsFromSources_SingleProfileSourceUsesPerSkillPlacement(t *testing.T) {
	skipIfWindowsNoSymlink(t)

	sourceDir := filepath.Join(t.TempDir(), "agentteam-skills")
	require.NoError(t, os.MkdirAll(filepath.Join(sourceDir, "review"), 0o755))

	workDir := t.TempDir()
	err := MaterializeRuntimeSkillsFromSources(TypeCodex, workDir, []RuntimeSkillSource{
		{Dir: sourceDir, Priority: RuntimeSkillPriorityProfile, Origin: "profile"},
	})
	require.NoError(t, err)

	target := filepath.Join(workDir, ".agents", "skills")
	assertRealSkillTarget(t, target)
	assertSymlinkTarget(t, filepath.Join(target, "review"), filepath.Join(sourceDir, "review"))
}

func TestMaterializeRuntimeSkillsFromSources_LowerPriorityWinsCollision(t *testing.T) {
	skipIfWindowsNoSymlink(t)

	profileDir := filepath.Join(t.TempDir(), "profile-skills")
	require.NoError(t, os.MkdirAll(filepath.Join(profileDir, "shared"), 0o755))
	require.NoError(t, os.WriteFile(filepath.Join(profileDir, "shared", "SKILL.md"),
		[]byte("# Profile variant\n"), 0o644))
	require.NoError(t, os.MkdirAll(filepath.Join(profileDir, "only-profile"), 0o755))
	require.NoError(t, os.WriteFile(filepath.Join(profileDir, "only-profile", "SKILL.md"),
		[]byte("# Profile only\n"), 0o644))

	managedDir := filepath.Join(t.TempDir(), "managed-skills")
	require.NoError(t, os.MkdirAll(filepath.Join(managedDir, "shared"), 0o755))
	require.NoError(t, os.WriteFile(filepath.Join(managedDir, "shared", "SKILL.md"),
		[]byte("# Managed variant\n"), 0o644))
	require.NoError(t, os.MkdirAll(filepath.Join(managedDir, "only-managed"), 0o755))
	require.NoError(t, os.WriteFile(filepath.Join(managedDir, "only-managed", "SKILL.md"),
		[]byte("# Managed only\n"), 0o644))

	workDir := t.TempDir()
	err := MaterializeRuntimeSkillsFromSources(TypeCodex, workDir, []RuntimeSkillSource{
		{Dir: profileDir, Priority: RuntimeSkillPriorityProfile, Origin: "profile"},
		{Dir: managedDir, Priority: RuntimeSkillPriorityManaged, Origin: "managed:sk_x"},
	})
	require.NoError(t, err)

	target := filepath.Join(workDir, ".agents", "skills")

	// "shared" exists in both — profile (lower priority number) wins.
	assertSymlinkTarget(t, filepath.Join(target, "shared"), filepath.Join(profileDir, "shared"))
	content, err := os.ReadFile(filepath.Join(target, "shared", "SKILL.md"))
	require.NoError(t, err)
	require.Equal(t, "# Profile variant\n", string(content))

	// Both unique entries are present.
	assertSymlinkTarget(t, filepath.Join(target, "only-profile"), filepath.Join(profileDir, "only-profile"))
	// managed entries are copied (snapshot), not symlinked.
	assertCopiedSkillDir(t, filepath.Join(target, "only-managed"), filepath.Join(managedDir, "only-managed"))
}

func TestMaterializeRuntimeSkillsFromSources_ReplacesLegacyWholeDirSymlinkBeforeManaged(t *testing.T) {
	skipIfWindowsNoSymlink(t)

	profileDir := filepath.Join(t.TempDir(), "profile-skills")
	require.NoError(t, os.MkdirAll(filepath.Join(profileDir, "manual"), 0o755))
	require.NoError(t, os.WriteFile(filepath.Join(profileDir, "manual", "SKILL.md"),
		[]byte("# Manual\n"), 0o644))

	managedDir := filepath.Join(t.TempDir(), "managed-skills")
	require.NoError(t, os.MkdirAll(filepath.Join(managedDir, "approved"), 0o755))
	require.NoError(t, os.WriteFile(filepath.Join(managedDir, "approved", "SKILL.md"),
		[]byte("# Approved\n"), 0o644))

	workDir := t.TempDir()
	target := filepath.Join(workDir, ".agents", "skills")
	require.NoError(t, os.MkdirAll(filepath.Dir(target), 0o755))
	require.NoError(t, os.Symlink(profileDir, target))

	require.NoError(t, MaterializeRuntimeSkillsFromSources(TypeCodex, workDir, []RuntimeSkillSource{
		{Dir: profileDir, Priority: RuntimeSkillPriorityProfile, Origin: RuntimeSkillOriginProfile},
		{Dir: managedDir, Priority: RuntimeSkillPriorityManaged, Origin: RuntimeSkillOriginManaged},
	}))

	info, err := os.Lstat(target)
	require.NoError(t, err)
	require.True(t, info.IsDir(), "target should become a real directory")
	require.Equal(t, os.FileMode(0)&os.ModeSymlink, info.Mode()&os.ModeSymlink,
		"legacy whole-dir profile symlink must be unlinked before managed skills are placed")

	assertSymlinkTarget(t, filepath.Join(target, "manual"), filepath.Join(profileDir, "manual"))
	assertCopiedSkillDir(t, filepath.Join(target, "approved"), filepath.Join(managedDir, "approved"))
	_, err = os.Lstat(filepath.Join(profileDir, "approved"))
	require.True(t, os.IsNotExist(err),
		"managed skill placement must not write through the old target symlink into the profile skills dir")
}

func TestMaterializeRuntimeSkillsFromSources_RealLocalEntryAlwaysWins(t *testing.T) {
	skipIfWindowsNoSymlink(t)

	profileDir := filepath.Join(t.TempDir(), "profile-skills")
	require.NoError(t, os.MkdirAll(filepath.Join(profileDir, "review"), 0o755))

	workDir := t.TempDir()
	// Pre-create a real local skill at the target — must not be replaced.
	target := filepath.Join(workDir, ".agents", "skills")
	require.NoError(t, os.MkdirAll(filepath.Join(target, "review"), 0o755))
	require.NoError(t, os.WriteFile(filepath.Join(target, "review", "local.txt"),
		[]byte("local"), 0o644))

	err := MaterializeRuntimeSkillsFromSources(TypeCodex, workDir, []RuntimeSkillSource{
		{Dir: profileDir, Priority: RuntimeSkillPriorityProfile, Origin: "profile"},
	})
	require.NoError(t, err)

	info, err := os.Lstat(filepath.Join(target, "review"))
	require.NoError(t, err)
	require.False(t, info.Mode()&os.ModeSymlink != 0, "real local skill must not be replaced")

	content, err := os.ReadFile(filepath.Join(target, "review", "local.txt"))
	require.NoError(t, err)
	require.Equal(t, "local", string(content))
}

func TestMaterializeRuntimeSkillsFromSources_NextStartSnapshot(t *testing.T) {
	skipIfWindowsNoSymlink(t)

	profileDir := filepath.Join(t.TempDir(), "profile-skills")
	require.NoError(t, os.MkdirAll(filepath.Join(profileDir, "first-skill"), 0o755))
	managedDir := filepath.Join(t.TempDir(), "managed-skills")
	require.NoError(t, os.MkdirAll(filepath.Join(managedDir, "managed-skill"), 0o755))

	workDir := t.TempDir()
	require.NoError(t, MaterializeRuntimeSkillsFromSources(TypeCodex, workDir, []RuntimeSkillSource{
		{Dir: profileDir, Priority: RuntimeSkillPriorityProfile, Origin: "profile"},
		{Dir: managedDir, Priority: RuntimeSkillPriorityManaged, Origin: "managed"},
	}))

	target := filepath.Join(workDir, ".agents", "skills")
	// Snapshot: first-skill and managed-skill are available.
	_, err := os.Lstat(filepath.Join(target, "first-skill"))
	require.NoError(t, err)
	_, err = os.Lstat(filepath.Join(target, "managed-skill"))
	require.NoError(t, err)

	// Source changes after materialization: add a new skill to profileDir.
	require.NoError(t, os.MkdirAll(filepath.Join(profileDir, "second-skill"), 0o755))

	// Without re-materializing, second-skill is NOT visible: no hot-reload.
	_, err = os.Lstat(filepath.Join(target, "second-skill"))
	require.True(t, os.IsNotExist(err), "newly added source skill must not appear without re-materialization")
}

func TestMaterializeRuntimeSkillsFromSources_ProfileSourceSymlinkEntryDoesNotTunnelManagedSkill(t *testing.T) {
	skipIfWindowsNoSymlink(t)

	profileDir := filepath.Join(t.TempDir(), "profile-skills")
	require.NoError(t, os.MkdirAll(filepath.Join(profileDir, "manual"), 0o755))
	require.NoError(t, os.WriteFile(filepath.Join(profileDir, "manual", "SKILL.md"),
		[]byte("# Manual\n"), 0o644))

	linkedVaultSkill := filepath.Join(t.TempDir(), "managed-vault", "linked-managed")
	require.NoError(t, os.MkdirAll(linkedVaultSkill, 0o755))
	require.NoError(t, os.WriteFile(filepath.Join(linkedVaultSkill, "SKILL.md"),
		[]byte("# Linked managed\n"), 0o644))
	require.NoError(t, os.Symlink(linkedVaultSkill, filepath.Join(profileDir, "linked-managed")))

	workDir := t.TempDir()
	require.NoError(t, MaterializeRuntimeSkillsFromSources(TypeCodex, workDir, []RuntimeSkillSource{
		{Dir: profileDir, Priority: RuntimeSkillPriorityProfile, Origin: RuntimeSkillOriginProfile},
	}))

	target := filepath.Join(workDir, ".agents", "skills")
	assertRealSkillTarget(t, target)

	assertSymlinkTarget(t, filepath.Join(target, "manual"), filepath.Join(profileDir, "manual"))
	_, err := os.Lstat(filepath.Join(target, "linked-managed"))
	require.True(t, os.IsNotExist(err),
		"profile-level symlinked skills must not be exposed through a whole-directory profile link")

	require.NoError(t, os.MkdirAll(filepath.Join(profileDir, "post-launch"), 0o755))
	_, err = os.Lstat(filepath.Join(target, "post-launch"))
	require.True(t, os.IsNotExist(err),
		"new profile entries must not hot-appear after launch-time materialization")
}

func TestMaterializeRuntimeSkillsFromSources_FiltersMissingAndDuplicateSources(t *testing.T) {
	skipIfWindowsNoSymlink(t)

	profileDir := filepath.Join(t.TempDir(), "profile-skills")
	require.NoError(t, os.MkdirAll(filepath.Join(profileDir, "alpha"), 0o755))
	managedDir := filepath.Join(t.TempDir(), "managed-skills")
	require.NoError(t, os.MkdirAll(filepath.Join(managedDir, "beta"), 0o755))

	missingDir := filepath.Join(t.TempDir(), "does-not-exist")

	workDir := t.TempDir()
	err := MaterializeRuntimeSkillsFromSources(TypeCodex, workDir, []RuntimeSkillSource{
		{Dir: profileDir, Priority: RuntimeSkillPriorityProfile, Origin: "profile"},
		{Dir: missingDir, Priority: RuntimeSkillPriorityManaged, Origin: "missing"},
		// Duplicate of profileDir — must be collapsed, not processed twice.
		{Dir: profileDir, Priority: RuntimeSkillPriorityProfile, Origin: "profile-dupe"},
		{Dir: managedDir, Priority: RuntimeSkillPriorityManaged, Origin: "managed"},
	})
	require.NoError(t, err)

	target := filepath.Join(workDir, ".agents", "skills")
	// Both valid sources contributed; missing dir skipped; duplicate collapsed.
	assertSymlinkTarget(t, filepath.Join(target, "alpha"), filepath.Join(profileDir, "alpha"))
	assertCopiedSkillDir(t, filepath.Join(target, "beta"), filepath.Join(managedDir, "beta"))
}

func TestFilterRuntimeSkillSources_DropsEmptyMissingAndDuplicate(t *testing.T) {
	profileDir := filepath.Join(t.TempDir(), "profile")
	require.NoError(t, os.MkdirAll(profileDir, 0o755))
	managedDir := filepath.Join(t.TempDir(), "managed")
	require.NoError(t, os.MkdirAll(managedDir, 0o755))

	got := filterRuntimeSkillSources([]RuntimeSkillSource{
		{Dir: "", Priority: 50, Origin: "empty"},
		{Dir: profileDir, Priority: 200, Origin: "profile"},
		{Dir: "/no/such/path", Priority: 30, Origin: "missing"},
		{Dir: managedDir, Priority: 100, Origin: "managed"},
		{Dir: profileDir, Priority: 100, Origin: "profile-dupe"},
	})

	require.Len(t, got, 2)
	// Lower priority first.
	require.Equal(t, managedDir, got[0].Dir)
	require.Equal(t, 100, got[0].Priority)
	require.Equal(t, profileDir, got[1].Dir)
	require.Equal(t, 200, got[1].Priority)
}

// TestMaterializeRuntimeSkillsFromSources_ManagedOnlySourceDoesNotHotReload
// pins the next-start semantics for the managed-only case: when the single
// source is a managed vault (Origin == "managed"), the materializer must keep
// the target as a real directory and copy each skill rather than symlink into
// the vault. A whole-dir symlink or a per-skill symlink
// would transparently expose post-launch sync changes (new skills, content
// edits, removals), violating the "skill binding changes apply on next agent
// start" guarantee. The per-skill copy path snapshots each skill at launch.
func TestMaterializeRuntimeSkillsFromSources_ManagedOnlySourceDoesNotHotReload(t *testing.T) {
	skipIfWindowsNoSymlink(t)

	managedDir := filepath.Join(t.TempDir(), "managed-skills")
	require.NoError(t, os.MkdirAll(filepath.Join(managedDir, "alpha"), 0o755))
	require.NoError(t, os.WriteFile(filepath.Join(managedDir, "alpha", "SKILL.md"),
		[]byte("# v1\n"), 0o644))

	workDir := t.TempDir()
	require.NoError(t, MaterializeRuntimeSkillsFromSources(TypeCodex, workDir, []RuntimeSkillSource{
		{Dir: managedDir, Priority: RuntimeSkillPriorityManaged, Origin: RuntimeSkillOriginManaged},
	}))

	target := filepath.Join(workDir, ".agents", "skills")
	// The target must be a real directory, not a symlink to the managed dir.
	info, err := os.Lstat(target)
	require.NoError(t, err)
	require.True(t, info.IsDir(), "managed-only target must be a real directory, not a whole-dir symlink")
	require.Equal(t, os.FileMode(0)&os.ModeSymlink, info.Mode()&os.ModeSymlink,
		"managed-only target must not be a symlink")

	// alpha is materialized as a per-skill COPY at launch time.
	assertCopiedSkillDir(t, filepath.Join(target, "alpha"), filepath.Join(managedDir, "alpha"))

	// Simulate a post-launch sync adding a new skill to the managed vault.
	require.NoError(t, os.MkdirAll(filepath.Join(managedDir, "beta"), 0o755))
	require.NoError(t, os.WriteFile(filepath.Join(managedDir, "beta", "SKILL.md"),
		[]byte("# beta\n"), 0o644))

	// beta must NOT be visible without re-materializing — no hot reload.
	_, err = os.Lstat(filepath.Join(target, "beta"))
	require.True(t, os.IsNotExist(err),
		"newly synced managed skill must not appear until next agent start")

	// Re-materializing picks up the new skill (next-start semantics).
	require.NoError(t, MaterializeRuntimeSkillsFromSources(TypeCodex, workDir, []RuntimeSkillSource{
		{Dir: managedDir, Priority: RuntimeSkillPriorityManaged, Origin: RuntimeSkillOriginManaged},
	}))
	assertCopiedSkillDir(t, filepath.Join(target, "beta"), filepath.Join(managedDir, "beta"))
}

func TestMaterializeRuntimeSkillsFromSources_ManagedSkillRepositoryLayouts(t *testing.T) {
	skipIfWindowsNoSymlink(t)

	managedDir := filepath.Join(t.TempDir(), "sk_repo")
	require.NoError(t, os.MkdirAll(managedDir, 0o755))
	require.NoError(t, os.WriteFile(filepath.Join(managedDir, "SKILL.md"), []byte("# root\n"), 0o644))
	require.NoError(t, os.MkdirAll(filepath.Join(managedDir, "collection"), 0o755))
	require.NoError(t, os.WriteFile(filepath.Join(managedDir, "collection", "SKILL.md"), []byte("# collection\n"), 0o644))
	require.NoError(t, os.MkdirAll(filepath.Join(managedDir, "skills", "template"), 0o755))
	require.NoError(t, os.WriteFile(filepath.Join(managedDir, "skills", "template", "SKILL.md"), []byte("# template\n"), 0o644))
	require.NoError(t, os.MkdirAll(filepath.Join(managedDir, "deep", "nested"), 0o755))
	require.NoError(t, os.WriteFile(filepath.Join(managedDir, "deep", "nested", "SKILL.md"), []byte("# ignored\n"), 0o644))

	workDir := t.TempDir()
	require.NoError(t, MaterializeRuntimeSkillsFromSources(TypeCodex, workDir, []RuntimeSkillSource{
		{Dir: managedDir, Priority: RuntimeSkillPriorityManaged, Origin: RuntimeSkillOriginManaged},
	}))

	target := filepath.Join(workDir, ".agents", "skills")
	assertCopiedSkillDir(t, filepath.Join(target, "sk_repo"), managedDir)
	assertCopiedSkillDir(t, filepath.Join(target, "collection"), filepath.Join(managedDir, "collection"))
	assertCopiedSkillDir(t, filepath.Join(target, "template"), filepath.Join(managedDir, "skills", "template"))
	_, err := os.Lstat(filepath.Join(target, "skills"))
	require.True(t, os.IsNotExist(err), "template parent directory must not be exposed as a skill slot")
	_, err = os.Lstat(filepath.Join(target, "deep"))
	require.True(t, os.IsNotExist(err), "arbitrary nested directories must not be exposed when supported layouts exist")
}

func TestMaterializeRuntimeSkillsFromSources_BuiltinCapabilityCopyCleansOnNextLaunch(t *testing.T) {
	sourceDir := filepath.Join(t.TempDir(), "builtin")
	skillDir := filepath.Join(sourceDir, "skills", "memory-maintenance")
	require.NoError(t, os.MkdirAll(skillDir, 0o755))
	require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"),
		[]byte("---\nname: memory-maintenance\n---\n"), 0o644))
	workDir := t.TempDir()
	target := filepath.Join(workDir, ".agents", "skills", "memory-maintenance")

	require.NoError(t, MaterializeRuntimeSkillsFromSources(TypeCodex, workDir, []RuntimeSkillSource{{
		Dir: sourceDir, Priority: RuntimeSkillPriorityBuiltin, Origin: RuntimeSkillOriginBuiltin,
	}}))
	assertCopiedSkillDir(t, target, skillDir)

	require.NoError(t, MaterializeRuntimeSkillsFromSources(TypeCodex, workDir, nil))
	require.NoDirExists(t, target)
}

func TestMaterializeRuntimeSkillsFromSources_BuiltinDoesNotOverwriteLocalSlot(t *testing.T) {
	sourceDir := filepath.Join(t.TempDir(), "builtin")
	skillDir := filepath.Join(sourceDir, "skills", "memory-maintenance")
	require.NoError(t, os.MkdirAll(skillDir, 0o755))
	require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"),
		[]byte("builtin\n"), 0o644))
	workDir := t.TempDir()
	target := filepath.Join(workDir, ".agents", "skills", "memory-maintenance")
	require.NoError(t, os.MkdirAll(target, 0o755))
	require.NoError(t, os.WriteFile(filepath.Join(target, "SKILL.md"),
		[]byte("local\n"), 0o644))

	require.NoError(t, MaterializeRuntimeSkillsFromSources(TypeCodex, workDir, []RuntimeSkillSource{{
		Dir: sourceDir, Priority: RuntimeSkillPriorityBuiltin, Origin: RuntimeSkillOriginBuiltin,
	}}))
	content, err := os.ReadFile(filepath.Join(target, "SKILL.md"))
	require.NoError(t, err)
	require.Equal(t, "local\n", string(content))
}

// TestMaterializeRuntimeSkillsFromSources_ManagedContentUpdatesDoNotHotReload
// pins the content-snapshot half of next-start: a managed skill already
// materialized at launch must NOT reflect post-launch edits to its source
// SKILL.md until the next materialize. This catches the case where a
// per-skill symlink (rather than a copy) would leak content changes.
func TestMaterializeRuntimeSkillsFromSources_ManagedContentUpdatesDoNotHotReload(t *testing.T) {
	skipIfWindowsNoSymlink(t)

	managedDir := filepath.Join(t.TempDir(), "managed-skills")
	require.NoError(t, os.MkdirAll(filepath.Join(managedDir, "alpha"), 0o755))
	require.NoError(t, os.WriteFile(filepath.Join(managedDir, "alpha", "SKILL.md"),
		[]byte("# v1\n"), 0o644))

	workDir := t.TempDir()
	require.NoError(t, MaterializeRuntimeSkillsFromSources(TypeCodex, workDir, []RuntimeSkillSource{
		{Dir: managedDir, Priority: RuntimeSkillPriorityManaged, Origin: RuntimeSkillOriginManaged},
	}))

	target := filepath.Join(workDir, ".agents", "skills")
	slotSkill := filepath.Join(target, "alpha", "SKILL.md")
	content, err := os.ReadFile(slotSkill)
	require.NoError(t, err)
	require.Equal(t, "# v1\n", string(content))

	// Simulate a post-launch sync updating alpha/SKILL.md to v2.
	require.NoError(t, os.WriteFile(filepath.Join(managedDir, "alpha", "SKILL.md"),
		[]byte("# v2\n"), 0o644))

	// Without re-materializing, the runtime target still reads v1 — no hot reload.
	content, err = os.ReadFile(slotSkill)
	require.NoError(t, err)
	require.Equal(t, "# v1\n", string(content),
		"managed skill content updates must not appear until next agent start")

	// Re-materializing picks up the v2 content.
	require.NoError(t, MaterializeRuntimeSkillsFromSources(TypeCodex, workDir, []RuntimeSkillSource{
		{Dir: managedDir, Priority: RuntimeSkillPriorityManaged, Origin: RuntimeSkillOriginManaged},
	}))
	content, err = os.ReadFile(slotSkill)
	require.NoError(t, err)
	require.Equal(t, "# v2\n", string(content))
}

// TestMaterializeRuntimeSkillsFromSources_ManagedSkillRemovedFromVaultIsCleanedUp
// pins the cleanup half of next-start: when a managed skill is removed from
// the vault between launches, the next materialize must remove its slot from
// the runtime target. The manifest is what tells the materializer "this slot
// is ours to remove" — without it, the copied dir would persist forever and
// be indistinguishable from a real local entry.
func TestMaterializeRuntimeSkillsFromSources_ManagedSkillRemovedFromVaultIsCleanedUp(t *testing.T) {
	skipIfWindowsNoSymlink(t)

	managedDir := filepath.Join(t.TempDir(), "managed-skills")
	require.NoError(t, os.MkdirAll(filepath.Join(managedDir, "alpha"), 0o755))
	require.NoError(t, os.MkdirAll(filepath.Join(managedDir, "beta"), 0o755))

	workDir := t.TempDir()
	require.NoError(t, MaterializeRuntimeSkillsFromSources(TypeCodex, workDir, []RuntimeSkillSource{
		{Dir: managedDir, Priority: RuntimeSkillPriorityManaged, Origin: RuntimeSkillOriginManaged},
	}))
	target := filepath.Join(workDir, ".agents", "skills")
	_, err := os.Lstat(filepath.Join(target, "alpha"))
	require.NoError(t, err)
	_, err = os.Lstat(filepath.Join(target, "beta"))
	require.NoError(t, err)

	// Simulate beta being removed from the vault between launches.
	require.NoError(t, os.RemoveAll(filepath.Join(managedDir, "beta")))

	// Re-materialize — beta must be cleaned up, alpha must remain.
	require.NoError(t, MaterializeRuntimeSkillsFromSources(TypeCodex, workDir, []RuntimeSkillSource{
		{Dir: managedDir, Priority: RuntimeSkillPriorityManaged, Origin: RuntimeSkillOriginManaged},
	}))
	_, err = os.Lstat(filepath.Join(target, "alpha"))
	require.NoError(t, err, "alpha still in vault must remain in target")
	_, err = os.Lstat(filepath.Join(target, "beta"))
	require.True(t, os.IsNotExist(err), "beta removed from vault must be cleaned up on next materialize")
}

// TestMaterializeRuntimeSkillsFromSources_AllManagedSourcesRemovedCleansManifestSlots
// pins the "last remote skill was deleted" path. When every managed source has
// disappeared, filterRuntimeSkillSources returns empty; the materializer must
// still read the prior manifest and remove slots it previously owned instead
// of returning early and leaving stale runtime-visible skills behind.
func TestMaterializeRuntimeSkillsFromSources_AllManagedSourcesRemovedCleansManifestSlots(t *testing.T) {
	skipIfWindowsNoSymlink(t)

	managedDir := filepath.Join(t.TempDir(), "managed-skills")
	require.NoError(t, os.MkdirAll(filepath.Join(managedDir, "alpha"), 0o755))
	require.NoError(t, os.WriteFile(filepath.Join(managedDir, "alpha", "SKILL.md"), []byte("# alpha\n"), 0o644))

	workDir := t.TempDir()
	require.NoError(t, MaterializeRuntimeSkillsFromSources(TypeCodex, workDir, []RuntimeSkillSource{
		{Dir: managedDir, Priority: RuntimeSkillPriorityManaged, Origin: RuntimeSkillOriginManaged},
	}))
	target := filepath.Join(workDir, ".agents", "skills")
	_, err := os.Lstat(filepath.Join(target, "alpha"))
	require.NoError(t, err)
	require.NoError(t, os.MkdirAll(filepath.Join(target, "manual-local"), 0o755))
	require.NoError(t, os.WriteFile(filepath.Join(target, "manual-local", "SKILL.md"), []byte("# manual\n"), 0o644))

	// Simulate the last bound managed skill repository being removed locally
	// after server-side deletion/revocation.
	require.NoError(t, os.RemoveAll(managedDir))

	require.NoError(t, MaterializeRuntimeSkillsFromSources(TypeCodex, workDir, []RuntimeSkillSource{
		{Dir: managedDir, Priority: RuntimeSkillPriorityManaged, Origin: RuntimeSkillOriginManaged},
	}))
	_, err = os.Lstat(filepath.Join(target, "alpha"))
	require.True(t, os.IsNotExist(err), "alpha removed with the last managed source must be cleaned up")
	_, err = os.Lstat(filepath.Join(target, "manual-local"))
	require.NoError(t, err, "real local skill entries must be preserved")
}

func TestPruneMissingManagedRuntimeSkillsRemovesOnlyRevokedManagedSlots(t *testing.T) {
	managedRoot := t.TempDir()
	keepSource := filepath.Join(managedRoot, "keep", "skills", "keep-managed")
	removeSource := filepath.Join(managedRoot, "remove", "skills", "remove-managed")
	builtinSource := filepath.Join(t.TempDir(), "builtin", "builtin-capability")
	for _, source := range []string{keepSource, removeSource, builtinSource} {
		require.NoError(t, os.MkdirAll(source, 0o755))
		require.NoError(t, os.WriteFile(filepath.Join(source, "SKILL.md"), []byte("# skill\n"), 0o644))
	}
	workDir := t.TempDir()
	require.NoError(t, MaterializeRuntimeSkillsFromSources(TypeCodex, workDir, []RuntimeSkillSource{
		{Dir: filepath.Join(managedRoot, "keep"), Priority: RuntimeSkillPriorityManaged, Origin: RuntimeSkillOriginManaged},
		{Dir: filepath.Join(managedRoot, "remove"), Priority: RuntimeSkillPriorityManaged, Origin: RuntimeSkillOriginManaged},
		{Dir: filepath.Dir(builtinSource), Priority: RuntimeSkillPriorityBuiltin, Origin: RuntimeSkillOriginBuiltin},
	}))
	target := filepath.Join(workDir, ".agents", "skills")
	manualSlot := filepath.Join(target, "manual-local")
	require.NoError(t, os.MkdirAll(manualSlot, 0o755))
	require.NoError(t, os.WriteFile(filepath.Join(manualSlot, "SKILL.md"), []byte("# manual\n"), 0o644))

	require.NoError(t, os.RemoveAll(filepath.Join(managedRoot, "remove")))
	require.NoError(t, PruneMissingManagedRuntimeSkills(TypeCodex, workDir, []string{filepath.Join(managedRoot, "remove")}))

	require.DirExists(t, filepath.Join(target, "keep-managed"))
	require.NoDirExists(t, filepath.Join(target, "remove-managed"))
	require.DirExists(t, filepath.Join(target, "builtin-capability"))
	require.DirExists(t, manualSlot)
	manifest := readRuntimeSkillManifest(target)
	names := make([]string, 0, len(manifest.Entries))
	for _, entry := range manifest.Entries {
		names = append(names, entry.Name)
	}
	require.ElementsMatch(t, []string{"keep-managed", "builtin-capability"}, names)
}

func TestPruneRemovedManagedRuntimeSkillsUnderAgentRootCleansAllPersistedWorkdirs(t *testing.T) {
	managedRoot := filepath.Join(t.TempDir(), "removed-repository")
	skillDir := filepath.Join(managedRoot, "skills", "retired-helper")
	require.NoError(t, os.MkdirAll(skillDir, 0o755))
	require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("# retired\n"), 0o644))

	agentRoot := t.TempDir()
	workDirs := []string{
		agentRoot,
		filepath.Join(agentRoot, "sessions", "primary"),
		filepath.Join(agentRoot, "sessions", "secondary"),
	}
	runtimes := []RuntimeType{TypeCodex, TypeClaudeCode, TypeBuiltin}
	for index, workDir := range workDirs {
		require.NoError(t, MaterializeRuntimeSkillsFromSources(runtimes[index], workDir, []RuntimeSkillSource{{
			Dir: managedRoot, Priority: RuntimeSkillPriorityManaged, Origin: RuntimeSkillOriginManaged,
		}}))
		for _, target := range runtimeSkillTargets(runtimes[index], workDir) {
			require.DirExists(t, filepath.Join(target, "retired-helper"))
		}
	}

	require.NoError(t, os.RemoveAll(managedRoot))
	require.NoError(t, PruneRemovedManagedRuntimeSkillsUnderAgentRoot(agentRoot, []string{managedRoot}))

	for index, workDir := range workDirs {
		for _, target := range runtimeSkillTargets(runtimes[index], workDir) {
			require.NoDirExists(t, filepath.Join(target, "retired-helper"))
		}
	}
}
