package skills

import (
	"os"
	"path/filepath"
	"strings"
	"testing"
)

// Happy path: a skill with full frontmatter + body parses into all
// four fields. Verifies the directory name becomes the canonical Name
// and the closing --- correctly demarcates the body.
func TestLoadSet_Basic(t *testing.T) {
	root := t.TempDir()
	skillDir := filepath.Join(root, "voice-applier")
	if err := os.MkdirAll(skillDir, 0o755); err != nil {
		t.Fatal(err)
	}
	body := "# voice-applier\n\nApply the voice rules.\n"
	content := "---\n" +
		"name: voice-applier\n" +
		"description: Apply voice rules to prose.\n" +
		"allowed-tools:\n" +
		"  - Read\n" +
		"  - Write\n" +
		"  - Edit\n" +
		"---\n\n" + body
	if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o600); err != nil {
		t.Fatal(err)
	}

	set, err := LoadSet(root)
	if err != nil {
		t.Fatalf("LoadSet: %v", err)
	}
	sk, ok := set.Get("voice-applier")
	if !ok {
		t.Fatalf("voice-applier not loaded; names=%v", set.Names())
	}
	if sk.Name != "voice-applier" {
		t.Errorf("Name = %q", sk.Name)
	}
	if sk.Description != "Apply voice rules to prose." {
		t.Errorf("Description = %q", sk.Description)
	}
	if len(sk.Tools) != 3 || sk.Tools[0] != "Read" {
		t.Errorf("Tools = %v", sk.Tools)
	}
	if !strings.HasPrefix(sk.Body, "\n# voice-applier") {
		t.Errorf("Body = %q (expected to start with body markdown after closing ---)", sk.Body)
	}
}

// Regression: a SKILL.md that declares its tool requirement with the
// canonical `tools` key (as loomcycle's own bundle/example skills do
// since the allowed_tools→tools rename) must be honored. Before the fix
// the loader read ONLY the kebab `allowed-tools`, so `tools:` was
// silently dropped and the skill loaded with no requirement.
func TestLoadSet_CanonicalToolsKey(t *testing.T) {
	root := t.TempDir()
	skillDir := filepath.Join(root, "edge-linking")
	if err := os.MkdirAll(skillDir, 0o755); err != nil {
		t.Fatal(err)
	}
	content := "---\n" +
		"name: edge-linking\n" +
		"description: Curate graph edges.\n" +
		"tools: [Document]\n" +
		"---\nbody\n"
	if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o600); err != nil {
		t.Fatal(err)
	}
	set, err := LoadSet(root)
	if err != nil {
		t.Fatalf("LoadSet: %v", err)
	}
	sk, ok := set.Get("edge-linking")
	if !ok {
		t.Fatalf("edge-linking not loaded; names=%v", set.Names())
	}
	if len(sk.Tools) != 1 || sk.Tools[0] != "Document" {
		t.Errorf("Tools = %v, want [Document] (canonical `tools` key ignored)", sk.Tools)
	}
}

// The canonical `tools` key wins over the Claude Code `allowed-tools`
// import alias when both are present; with only the kebab alias, it is
// still read (import compatibility).
func TestLoadSet_ToolsWinsOverAllowedTools(t *testing.T) {
	root := t.TempDir()
	dir := filepath.Join(root, "dual")
	if err := os.MkdirAll(dir, 0o755); err != nil {
		t.Fatal(err)
	}
	content := "---\n" +
		"name: dual\n" +
		"tools: [Document]\n" +
		"allowed-tools: [Read, Grep]\n" +
		"---\nbody\n"
	if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(content), 0o600); err != nil {
		t.Fatal(err)
	}
	set, err := LoadSet(root)
	if err != nil {
		t.Fatalf("LoadSet: %v", err)
	}
	sk, _ := set.Get("dual")
	if len(sk.Tools) != 1 || sk.Tools[0] != "Document" {
		t.Errorf("Tools = %v, want [Document] (canonical `tools` should win over kebab `allowed-tools`)", sk.Tools)
	}
}

// Empty SkillsRoot returns an empty (non-nil) Set so callers can Get()
// without crashing. LOOMCYCLE_SKILLS_ROOT is optional; agents that
// don't list skills should keep working.
func TestLoadSet_EmptyRoot(t *testing.T) {
	set, err := LoadSet("")
	if err != nil {
		t.Fatalf("LoadSet(\"\"): %v", err)
	}
	if set == nil {
		t.Fatal("expected non-nil Set on empty root")
	}
	if _, ok := set.Get("anything"); ok {
		t.Errorf("unexpected hit on empty Set")
	}
	if names := set.Names(); len(names) != 0 {
		t.Errorf("Names() = %v, want []", names)
	}
}

// Missing root directory is an error — almost certainly a misconfigured
// LOOMCYCLE_SKILLS_ROOT, which we surface loudly.
func TestLoadSet_MissingRoot(t *testing.T) {
	_, err := LoadSet(filepath.Join(t.TempDir(), "no-such-dir"))
	if err == nil {
		t.Fatal("expected error for missing root")
	}
}

// Drift detection: frontmatter `name:` disagreeing with the directory
// name is the kind of silent breakage that bites operators. Refuse to
// load, with a message that names both the file and the conflict.
func TestLoadSet_NameMismatch(t *testing.T) {
	root := t.TempDir()
	dir := filepath.Join(root, "voice-applier")
	os.MkdirAll(dir, 0o755)
	content := "---\nname: cv-voice-applier\ndescription: Wrong name.\n---\nbody\n"
	os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(content), 0o600)

	_, err := LoadSet(root)
	if err == nil || !strings.Contains(err.Error(), "frontmatter name") {
		t.Errorf("expected frontmatter-name error, got %v", err)
	}
}

// A SKILL.md with no frontmatter is body-only. Name falls back to the
// directory name. Tools defaults to nil (skill needs no tools).
func TestLoadSet_NoFrontmatter(t *testing.T) {
	root := t.TempDir()
	dir := filepath.Join(root, "raw-skill")
	os.MkdirAll(dir, 0o755)
	body := "Just a plain markdown body.\n"
	os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(body), 0o600)

	set, err := LoadSet(root)
	if err != nil {
		t.Fatalf("LoadSet: %v", err)
	}
	sk, ok := set.Get("raw-skill")
	if !ok {
		t.Fatal("raw-skill not loaded")
	}
	if sk.Body != body {
		t.Errorf("Body = %q", sk.Body)
	}
	if sk.Tools != nil {
		t.Errorf("Tools = %v, want nil", sk.Tools)
	}
}

// An opening "---\n" with no closing "---" is malformed. Refuse to load
// rather than treating the rest of the file as body — silent acceptance
// would hide a YAML-frontmatter syntax bug.
func TestLoadSet_UnclosedFrontmatter(t *testing.T) {
	root := t.TempDir()
	dir := filepath.Join(root, "broken")
	os.MkdirAll(dir, 0o755)
	content := "---\nname: broken\nno closing line below\nbody continues...\n"
	os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(content), 0o600)

	_, err := LoadSet(root)
	if err == nil || !strings.Contains(err.Error(), "no closing") {
		t.Errorf("expected unclosed-frontmatter error, got %v", err)
	}
}

// Subdirectories without a SKILL.md are skipped silently. A skill may
// stage auxiliary content (a references/ folder, a fixtures dir, etc.)
// alongside SKILL.md without breaking discovery.
func TestLoadSet_SkipsDirsWithoutSkillMd(t *testing.T) {
	root := t.TempDir()
	// Real skill
	good := filepath.Join(root, "good-skill")
	os.MkdirAll(good, 0o755)
	os.WriteFile(filepath.Join(good, "SKILL.md"), []byte("body\n"), 0o600)
	// Aux directory (no SKILL.md)
	aux := filepath.Join(root, "auxiliary")
	os.MkdirAll(aux, 0o755)
	os.WriteFile(filepath.Join(aux, "README.md"), []byte("aux\n"), 0o600)

	set, err := LoadSet(root)
	if err != nil {
		t.Fatalf("LoadSet: %v", err)
	}
	if _, ok := set.Get("good-skill"); !ok {
		t.Error("good-skill not loaded")
	}
	if _, ok := set.Get("auxiliary"); ok {
		t.Error("auxiliary should not be loaded as a skill")
	}
}

// CRLF line endings in a SKILL.md — produced when authors edit on
// Windows or paste into editors that default to CRLF — must parse the
// same as LF.
func TestLoadSet_CRLFLineEndings(t *testing.T) {
	root := t.TempDir()
	dir := filepath.Join(root, "windows-skill")
	os.MkdirAll(dir, 0o755)
	content := "---\r\nname: windows-skill\r\nallowed-tools:\r\n  - Read\r\n---\r\nbody\r\n"
	os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(content), 0o600)

	set, err := LoadSet(root)
	if err != nil {
		t.Fatalf("LoadSet: %v", err)
	}
	sk, ok := set.Get("windows-skill")
	if !ok {
		t.Fatal("windows-skill not loaded")
	}
	if len(sk.Tools) != 1 || sk.Tools[0] != "Read" {
		t.Errorf("Tools = %v", sk.Tools)
	}
	if !strings.Contains(sk.Body, "body") {
		t.Errorf("Body = %q", sk.Body)
	}
}

// RFC BA: a SKILL.md nested under a group directory loads with its
// `/`-grouped name = the path relative to root. A flat sibling still loads
// with its bare name, so grouped + flat coexist.
func TestLoadSet_NestedGroupedName(t *testing.T) {
	root := t.TempDir()
	nested := filepath.Join(root, "doc", "redactor")
	if err := os.MkdirAll(nested, 0o755); err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(filepath.Join(nested, "SKILL.md"), []byte("redactor body\n"), 0o600); err != nil {
		t.Fatal(err)
	}
	flat := filepath.Join(root, "seo")
	os.MkdirAll(flat, 0o755)
	os.WriteFile(filepath.Join(flat, "SKILL.md"), []byte("seo body\n"), 0o600)

	set, err := LoadSet(root)
	if err != nil {
		t.Fatalf("LoadSet: %v", err)
	}
	if sk, ok := set.Get("doc/redactor"); !ok {
		t.Error("doc/redactor not loaded under its grouped name")
	} else if !strings.Contains(sk.Body, "redactor body") {
		t.Errorf("doc/redactor body = %q", sk.Body)
	}
	if _, ok := set.Get("seo"); !ok {
		t.Error("flat seo not loaded alongside the grouped skill")
	}
	// The intermediate group dir itself has no SKILL.md → not a skill.
	if _, ok := set.Get("doc"); ok {
		t.Error("group dir doc must not be a skill (no SKILL.md)")
	}
}

// RFC BA: a nested SKILL.md whose frontmatter name disagrees with its
// relative directory path is a loud drift error, now compared against the
// full `/`-grouped path (not just the leaf dir).
func TestLoadSet_NestedFrontmatterNameMismatch(t *testing.T) {
	root := t.TempDir()
	nested := filepath.Join(root, "doc", "redactor")
	os.MkdirAll(nested, 0o755)
	// Frontmatter says "redactor" but the path is "doc/redactor".
	content := "---\nname: redactor\n---\nbody\n"
	os.WriteFile(filepath.Join(nested, "SKILL.md"), []byte(content), 0o600)

	if _, err := LoadSet(root); err == nil {
		t.Fatal("expected frontmatter-name != directory-path error")
	} else if !strings.Contains(err.Error(), "doc/redactor") {
		t.Errorf("error should cite the grouped path, got %v", err)
	}
}

// Get() and Names() are safe on a nil receiver. The config layer holds
// a *Set that may be nil when LOOMCYCLE_SKILLS_ROOT is unset; callers
// shouldn't have to check before every lookup.
func TestSet_NilSafe(t *testing.T) {
	var s *Set
	if _, ok := s.Get("anything"); ok {
		t.Error("nil Get should miss")
	}
	if names := s.Names(); names != nil {
		t.Errorf("nil Names = %v", names)
	}
}
