package profile

import (
	"archive/tar"
	"archive/zip"
	"bytes"
	"compress/gzip"
	"errors"
	"os"
	"path/filepath"
	"strings"
	"testing"
)

// ---------------------------------------------------------------------------
// DiscoverSkillsInZip tests
// ---------------------------------------------------------------------------

func TestDiscoverSkillsInZip_SingleSkill(t *testing.T) {
	t.Parallel()

	zipPath := createMultiSkillZip(t, map[string]string{
		"SKILL.md": "---\nid: root-skill\nname: Root Skill\n---\n# Root\n",
	})
	discoveries, err := discoverSkillsInZip(zipPath)
	if err != nil {
		t.Fatalf("discoverSkillsInZip: %v", err)
	}
	if len(discoveries) != 1 {
		t.Fatalf("expected 1 skill, got %d", len(discoveries))
	}
	if discoveries[0].RelRoot != "" {
		t.Fatalf("expected root dir, got %q", discoveries[0].RelRoot)
	}
	if _, ok := discoveries[0].Files["SKILL.md"]; !ok {
		t.Fatal("SKILL.md not found in discovered files")
	}
}

func TestDiscoverSkillsInZip_NestedSkills(t *testing.T) {
	t.Parallel()

	zipPath := createMultiSkillZip(t, map[string]string{
		"superpowers-main/skills/brainstorming/SKILL.md":       "---\nid: brainstorming\nname: Brainstorming\n---\n# Brainstorming\n",
		"superpowers-main/skills/brainstorming/scripts/run.js": "// script",
		"superpowers-main/skills/debugging/SKILL.md":           "---\nid: debugging\nname: Debugging\n---\n# Debugging\n",
		"superpowers-main/skills/debugging/root-cause.md":      "Root cause analysis guide",
		"superpowers-main/README.md":                           "Not a skill",
	})

	discoveries, err := discoverSkillsInZip(zipPath)
	if err != nil {
		t.Fatalf("discoverSkillsInZip: %v", err)
	}
	if len(discoveries) != 2 {
		t.Fatalf("expected 2 skills, got %d", len(discoveries))
	}

	// Verify each skill has its files.
	found := make(map[string]bool)
	for _, d := range discoveries {
		found[d.RelRoot] = true
		if _, ok := d.Files["SKILL.md"]; !ok {
			t.Errorf("SKILL.md not found in %q", d.RelRoot)
		}
	}

	if !found["superpowers-main/skills/brainstorming"] {
		t.Error("brainstorming skill not found")
	}
	if !found["superpowers-main/skills/debugging"] {
		t.Error("debugging skill not found")
	}
}

func TestDiscoverSkillsInZip_NestedWithExtraFiles(t *testing.T) {
	t.Parallel()

	zipPath := createMultiSkillZip(t, map[string]string{
		"skills/myskill/SKILL.md":       "---\nid: myskill\nname: My Skill\n---\n# My Skill\n",
		"skills/myskill/scripts/run.sh": "#!/bin/bash\necho hello",
		"skills/myskill/docs/guide.md":  "# Guide\nUsage guide.",
	})

	discoveries, err := discoverSkillsInZip(zipPath)
	if err != nil {
		t.Fatalf("discoverSkillsInZip: %v", err)
	}
	if len(discoveries) != 1 {
		t.Fatalf("expected 1 skill, got %d", len(discoveries))
	}

	d := discoveries[0]
	if d.RelRoot != "skills/myskill" {
		t.Fatalf("expected RelRoot %q, got %q", "skills/myskill", d.RelRoot)
	}

	// Check that extra files are preserved with relative paths.
	if _, ok := d.Files["scripts/run.sh"]; !ok {
		t.Error("scripts/run.sh not found in discovered files")
	}
	if _, ok := d.Files["docs/guide.md"]; !ok {
		t.Error("docs/guide.md not found in discovered files")
	}
}

func TestDiscoverSkillsInZip_NoSkills(t *testing.T) {
	t.Parallel()

	zipPath := createMultiSkillZip(t, map[string]string{
		"README.md":     "No skills here",
		"docs/guide.md": "Just docs",
	})

	_, err := discoverSkillsInZip(zipPath)
	if err == nil {
		t.Fatal("expected error for ZIP without SKILL.md")
	}
}

// ---------------------------------------------------------------------------
// DiscoverSkillsInTarGz tests
// ---------------------------------------------------------------------------

func TestDiscoverSkillsInTarGz_NestedSkills(t *testing.T) {
	t.Parallel()

	tgzPath := createMultiSkillTarGz(t, map[string]string{
		"skills/alpha/SKILL.md":  "---\nid: alpha\nname: Alpha\n---\n# Alpha\n",
		"skills/beta/SKILL.md":   "---\nid: beta\nname: Beta\n---\n# Beta\n",
		"skills/beta/helper.txt": "Helper file",
	})

	discoveries, err := discoverSkillsInTarGz(tgzPath)
	if err != nil {
		t.Fatalf("discoverSkillsInTarGz: %v", err)
	}
	if len(discoveries) != 2 {
		t.Fatalf("expected 2 skills, got %d", len(discoveries))
	}
}

func TestDiscoverSkillsInTarGz_NoSkills(t *testing.T) {
	t.Parallel()

	tgzPath := createMultiSkillTarGz(t, map[string]string{
		"readme.txt": "Nothing here",
	})

	_, err := discoverSkillsInTarGz(tgzPath)
	if err == nil {
		t.Fatal("expected error for tar.gz without SKILL.md")
	}
}

// ---------------------------------------------------------------------------
// DiscoverSkillsInDirectory tests
// ---------------------------------------------------------------------------

func TestDiscoverSkillsInDirectory_NestedSkills(t *testing.T) {
	t.Parallel()

	root := t.TempDir()
	writeSkillTreeFile(t, root, "skills/alpha/SKILL.md", "---\nid: alpha\nname: Alpha\n---\n# Alpha\n")
	writeSkillTreeFile(t, root, "skills/alpha/scripts/run.sh", "#!/bin/sh\necho alpha\n")
	writeSkillTreeFile(t, root, "skills/beta/SKILL.md", "---\nid: beta\nname: Beta\n---\n# Beta\n")
	writeSkillTreeFile(t, root, "README.md", "not a skill")

	discoveries, err := discoverSkillsInDirectory(root)
	if err != nil {
		t.Fatalf("discoverSkillsInDirectory: %v", err)
	}
	if len(discoveries) != 2 {
		t.Fatalf("expected 2 skills, got %d", len(discoveries))
	}

	found := make(map[string]DiscoveredSkill)
	for _, d := range discoveries {
		found[d.RelRoot] = d
	}
	alpha, ok := found["skills/alpha"]
	if !ok {
		t.Fatalf("alpha skill not found in %#v", found)
	}
	if _, ok := alpha.Files["SKILL.md"]; !ok {
		t.Fatal("alpha SKILL.md not found")
	}
	if _, ok := alpha.Files["scripts/run.sh"]; !ok {
		t.Fatal("alpha extra file not preserved")
	}
	if _, ok := found["skills/beta"]; !ok {
		t.Fatal("beta skill not found")
	}
}

func TestSkillManager_ImportAutoFromDirectory(t *testing.T) {
	t.Parallel()

	cfgDir := t.TempDir()
	m := New(cfgDir)
	sourceDir := t.TempDir()
	writeSkillTreeFile(t, sourceDir, "skills/alpha/SKILL.md", "---\nid: alpha\nname: Alpha\n---\n# Alpha\n")
	writeSkillTreeFile(t, sourceDir, "skills/alpha/docs/guide.md", "# Alpha Guide\n")
	writeSkillTreeFile(t, sourceDir, "skills/beta/SKILL.md", "---\nid: beta\nname: Beta\n---\n# Beta\n")

	result, err := m.Skills().ImportAutoFromFile(sourceDir, nil, false)
	if err != nil {
		t.Fatalf("ImportAutoFromFile directory: %v", err)
	}
	if result.Batch == nil {
		t.Fatalf("expected batch result, got %#v", result)
	}
	if result.Batch.Created != 2 {
		t.Fatalf("expected 2 created skills, got %d", result.Batch.Created)
	}

	if _, ok := m.Skills().Get("alpha"); !ok {
		t.Fatal("alpha skill not imported")
	}
	if _, ok := m.Skills().Get("beta"); !ok {
		t.Fatal("beta skill not imported")
	}
	if _, err := os.Stat(filepath.Join(cfgDir, "skills", "alpha", "docs", "guide.md")); err != nil {
		t.Fatalf("alpha extra file not imported: %v", err)
	}
}

// ---------------------------------------------------------------------------
// Skill ID generation tests
// ---------------------------------------------------------------------------

func TestSkillIDFromDirectory(t *testing.T) {
	t.Parallel()

	tests := []struct {
		relRoot string
		want    string
	}{
		{"superpowers-main/skills/brainstorming", "brainstorming"},
		{"skills/debugging", "debugging"},
		{"my-skill", "my-skill"},
		{"", "skill"},
		{"skills/My Cool Skill", "My-Cool-Skill"},
		{"skills/skill@v2", "skill-v2"},
	}

	for _, tt := range tests {
		t.Run(tt.relRoot, func(t *testing.T) {
			got := skillIDFromDirectory(tt.relRoot)
			if got != tt.want {
				t.Errorf("skillIDFromDirectory(%q) = %q, want %q", tt.relRoot, got, tt.want)
			}
		})
	}
}

func TestSkillIDWithHash(t *testing.T) {
	t.Parallel()

	id := skillIDWithHash("debugging", []byte("some content"))
	if len(id) < 10 {
		t.Fatalf("hash ID too short: %q", id)
	}
	// Should be "debugging-" + 7 hex chars.
	prefix := "debugging-"
	if id[:len(prefix)] != prefix {
		t.Fatalf("expected prefix %q, got %q", prefix, id[:len(prefix)])
	}
	hashPart := id[len(prefix):]
	if len(hashPart) != 7 {
		t.Fatalf("expected 7-char hash, got %d chars: %q", len(hashPart), hashPart)
	}
}

// ---------------------------------------------------------------------------
// ResolveArchiveURL tests
// ---------------------------------------------------------------------------

func TestResolveArchiveURL_GitHubRepo(t *testing.T) {
	t.Parallel()

	result, err := ResolveArchiveURL("https://github.com/user/repo")
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	want := "https://github.com/user/repo/archive/refs/heads/main.zip"
	if result != want {
		t.Errorf("got %q, want %q", result, want)
	}
}

func TestResolveArchiveURL_GitHubBranch(t *testing.T) {
	t.Parallel()

	result, err := ResolveArchiveURL("https://github.com/user/repo/tree/v2")
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	want := "https://github.com/user/repo/archive/refs/heads/v2.zip"
	if result != want {
		t.Errorf("got %q, want %q", result, want)
	}
}

func TestResolveArchiveURL_GitHubGitSuffix(t *testing.T) {
	t.Parallel()

	result, err := ResolveArchiveURL("https://github.com/user/repo.git")
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	want := "https://github.com/user/repo/archive/refs/heads/main.zip"
	if result != want {
		t.Errorf("got %q, want %q", result, want)
	}
}

func TestResolveArchiveURL_GitLabRepo(t *testing.T) {
	t.Parallel()

	result, err := ResolveArchiveURL("https://gitlab.com/user/repo")
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	want := "https://gitlab.com/user/repo/-/archive/main/repo-main.zip"
	if result != want {
		t.Errorf("got %q, want %q", result, want)
	}
}

func TestResolveArchiveURL_GitLabBranch(t *testing.T) {
	t.Parallel()

	result, err := ResolveArchiveURL("https://gitlab.com/user/repo/-/tree/develop")
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	want := "https://gitlab.com/user/repo/-/archive/develop/repo-develop.zip"
	if result != want {
		t.Errorf("got %q, want %q", result, want)
	}
}

func TestResolveArchiveURL_DirectZip(t *testing.T) {
	t.Parallel()

	input := "https://example.com/skills/foo.zip"
	result, err := ResolveArchiveURL(input)
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if result != input {
		t.Errorf("got %q, want %q (unchanged)", result, input)
	}
}

func TestResolveArchiveURL_DirectMarkdown(t *testing.T) {
	t.Parallel()

	input := "https://example.com/skills/review.md"
	result, err := ResolveArchiveURL(input)
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if result != input {
		t.Errorf("got %q, want %q (unchanged)", result, input)
	}
}

func TestResolveArchiveURL_UnsupportedHostRepoURL(t *testing.T) {
	t.Parallel()

	_, err := ResolveArchiveURL("https://bitbucket.org/user/repo")
	if err == nil {
		t.Fatal("expected error for unsupported host, got nil")
	}
	var uhe *UnsupportedHostError
	if !errors.As(err, &uhe) {
		t.Fatalf("expected UnsupportedHostError, got %T: %v", err, err)
	}
	if uhe.Host != "bitbucket.org" {
		t.Errorf("got host %q, want %q", uhe.Host, "bitbucket.org")
	}
}

func TestResolveArchiveURL_GitSSH(t *testing.T) {
	t.Parallel()

	result, err := ResolveArchiveURL("git@github.com:user/repo.git")
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	want := "https://github.com/user/repo/archive/refs/heads/main.zip"
	if result != want {
		t.Errorf("got %q, want %q", result, want)
	}
}

func TestResolveArchiveURL_GitSSHGitLab(t *testing.T) {
	t.Parallel()

	result, err := ResolveArchiveURL("git@gitlab.com:user/repo.git")
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	want := "https://gitlab.com/user/repo/-/archive/main/repo-main.zip"
	if result != want {
		t.Errorf("got %q, want %q", result, want)
	}
}

func TestResolveArchiveURL_GitSSHUnsupportedHost(t *testing.T) {
	t.Parallel()

	_, err := ResolveArchiveURL("git@bitbucket.org:user/repo.git")
	if err == nil {
		t.Fatal("expected error for unsupported git host")
	}
}

func TestResolveArchiveURL_GiteeRepo(t *testing.T) {
	t.Parallel()

	result, err := ResolveArchiveURL("https://gitee.com/user/repo")
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	want := "https://gitee.com/user/repo/repository/archive/main.zip"
	if result != want {
		t.Errorf("got %q, want %q", result, want)
	}
}

func TestResolveArchiveURL_GiteeBranch(t *testing.T) {
	t.Parallel()

	result, err := ResolveArchiveURL("https://gitee.com/user/repo/tree/v2")
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	want := "https://gitee.com/user/repo/repository/archive/v2.zip"
	if result != want {
		t.Errorf("got %q, want %q", result, want)
	}
}

func TestResolveArchiveURL_GiteeSSH(t *testing.T) {
	t.Parallel()

	result, err := ResolveArchiveURL("git@gitee.com:user/repo.git")
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	want := "https://gitee.com/user/repo/repository/archive/main.zip"
	if result != want {
		t.Errorf("got %q, want %q", result, want)
	}
}

func TestResolveArchiveURL_GitCodeRepo(t *testing.T) {
	t.Parallel()

	result, err := ResolveArchiveURL("https://gitcode.com/user/repo")
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	want := "https://gitcode.com/user/repo/-/archive/main/repo-main.zip"
	if result != want {
		t.Errorf("got %q, want %q", result, want)
	}
}

func TestResolveArchiveURL_GitCodeBranch(t *testing.T) {
	t.Parallel()

	result, err := ResolveArchiveURL("https://gitcode.com/user/repo/-/tree/develop")
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	want := "https://gitcode.com/user/repo/-/archive/develop/repo-develop.zip"
	if result != want {
		t.Errorf("got %q, want %q", result, want)
	}
}

func TestResolveArchiveURL_GitCodeSSH(t *testing.T) {
	t.Parallel()

	result, err := ResolveArchiveURL("git@gitcode.com:user/repo.git")
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	want := "https://gitcode.com/user/repo/-/archive/main/repo-main.zip"
	if result != want {
		t.Errorf("got %q, want %q", result, want)
	}
}

func TestResolveArchiveURL_UnsupportedScheme(t *testing.T) {
	t.Parallel()

	_, err := ResolveArchiveURL("ftp://example.com/file.zip")
	if err == nil {
		t.Fatal("expected error for unsupported scheme")
	}
}

// ---------------------------------------------------------------------------
// ImportBatchFromFile tests
// ---------------------------------------------------------------------------

func TestImportBatchFromFile_Zip(t *testing.T) {
	t.Parallel()

	dir := t.TempDir()
	m := New(dir)

	zipPath := createMultiSkillZip(t, map[string]string{
		"skills/alpha/SKILL.md": "---\nid: alpha\nname: Alpha Skill\n---\n# Alpha\n",
		"skills/beta/SKILL.md":  "---\nid: beta\nname: Beta Skill\n---\n# Beta\n",
	})

	resp, err := m.Skills().ImportBatchFromFile(zipPath, false)
	if err != nil {
		t.Fatalf("ImportBatchFromFile: %v", err)
	}
	if resp.Created != 2 {
		t.Fatalf("expected 2 created, got %d", resp.Created)
	}
	if resp.Skipped != 0 {
		t.Fatalf("expected 0 skipped, got %d", resp.Skipped)
	}

	// Verify skills are on disk.
	if _, ok := m.Skills().Get("alpha"); !ok {
		t.Error("alpha skill not found")
	}
	if _, ok := m.Skills().Get("beta"); !ok {
		t.Error("beta skill not found")
	}

	// Verify files on disk.
	alphaDir := filepath.Join(dir, "skills", "alpha")
	if _, err := os.Stat(filepath.Join(alphaDir, "SKILL.md")); err != nil {
		t.Fatalf("alpha SKILL.md not on disk: %v", err)
	}
	betaDir := filepath.Join(dir, "skills", "beta")
	if _, err := os.Stat(filepath.Join(betaDir, "SKILL.md")); err != nil {
		t.Fatalf("beta SKILL.md not on disk: %v", err)
	}
}

func TestImportBatchFromFile_SkipExisting(t *testing.T) {
	t.Parallel()

	dir := t.TempDir()
	m := New(dir)

	// Pre-create a skill.
	s := Skill{ID: "alpha", Name: "Existing Alpha", FileName: "alpha/SKILL.md"}
	skillDir := filepath.Join(dir, "skills", "alpha")
	if err := os.MkdirAll(skillDir, 0755); err != nil {
		t.Fatalf("MkdirAll: %v", err)
	}
	if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("# Old"), 0644); err != nil {
		t.Fatalf("WriteFile: %v", err)
	}
	if err := m.Skills().Add(s); err != nil {
		t.Fatal(err)
	}

	zipPath := createMultiSkillZip(t, map[string]string{
		"skills/alpha/SKILL.md": "---\nid: alpha\nname: New Alpha\n---\n# New Alpha\n",
		"skills/beta/SKILL.md":  "---\nid: beta\nname: Beta\n---\n# Beta\n",
	})

	resp, err := m.Skills().ImportBatchFromFile(zipPath, false)
	if err != nil {
		t.Fatalf("ImportBatchFromFile: %v", err)
	}
	if resp.Created != 1 {
		t.Fatalf("expected 1 created (beta), got %d", resp.Created)
	}
	if resp.Skipped != 1 {
		t.Fatalf("expected 1 skipped (alpha), got %d", resp.Skipped)
	}

	// Alpha should still have old name.
	existing, _ := m.Skills().Get("alpha")
	if existing.Name != "Existing Alpha" {
		t.Fatalf("alpha should be unchanged, got name %q", existing.Name)
	}
}

func TestImportBatchFromFile_ForceOverwrite(t *testing.T) {
	t.Parallel()

	dir := t.TempDir()
	m := New(dir)

	// Pre-create a skill.
	s := Skill{ID: "alpha", Name: "Old Alpha", FileName: "alpha/SKILL.md"}
	skillDir := filepath.Join(dir, "skills", "alpha")
	if err := os.MkdirAll(skillDir, 0755); err != nil {
		t.Fatalf("MkdirAll: %v", err)
	}
	if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("# Old"), 0644); err != nil {
		t.Fatalf("WriteFile: %v", err)
	}
	if err := m.Skills().Add(s); err != nil {
		t.Fatal(err)
	}

	zipPath := createMultiSkillZip(t, map[string]string{
		"skills/alpha/SKILL.md": "---\nid: alpha\nname: New Alpha\n---\n# New Alpha\n",
	})

	resp, err := m.Skills().ImportBatchFromFile(zipPath, true)
	if err != nil {
		t.Fatalf("ImportBatchFromFile: %v", err)
	}
	if resp.Created != 1 {
		t.Fatalf("expected 1 overwritten, got %d", resp.Created)
	}
	if len(resp.Imported) != 1 || resp.Imported[0].Status != "overwritten" {
		t.Fatalf("expected overwritten status, got %+v", resp.Imported)
	}

	updated, _ := m.Skills().Get("alpha")
	if updated.Name != "New Alpha" {
		t.Fatalf("alpha should be updated, got name %q", updated.Name)
	}
}

func TestImportBatchFromFile_TarGz(t *testing.T) {
	t.Parallel()

	dir := t.TempDir()
	m := New(dir)

	tgzPath := createMultiSkillTarGz(t, map[string]string{
		"skills/first/SKILL.md":  "---\nid: first\nname: First\n---\n# First\n",
		"skills/second/SKILL.md": "---\nid: second\nname: Second\n---\n# Second\n",
	})

	resp, err := m.Skills().ImportBatchFromFile(tgzPath, false)
	if err != nil {
		t.Fatalf("ImportBatchFromFile tar.gz: %v", err)
	}
	if resp.Created != 2 {
		t.Fatalf("expected 2 created, got %d", resp.Created)
	}
}

func TestImportBatchFromFile_UnsupportedExt(t *testing.T) {
	t.Parallel()

	dir := t.TempDir()
	m := New(dir)

	_, err := m.Skills().ImportBatchFromFile("/some/file.md", false)
	if err == nil {
		t.Fatal("expected error for unsupported extension")
	}
}

func TestImportBatchFromFile_AutoIDFromDirectory(t *testing.T) {
	t.Parallel()

	dir := t.TempDir()
	m := New(dir)

	// SKILL.md without id/name front-matter — ID should come from directory.
	zipPath := createMultiSkillZip(t, map[string]string{
		"skills/my-awesome-skill/SKILL.md": "# My Awesome Skill\n\nThis skill has no front-matter.\n",
	})

	resp, err := m.Skills().ImportBatchFromFile(zipPath, false)
	if err != nil {
		t.Fatalf("ImportBatchFromFile: %v", err)
	}
	if resp.Created != 1 {
		t.Fatalf("expected 1 created, got %d", resp.Created)
	}
	if resp.Imported[0].ID != "my-awesome-skill" {
		t.Fatalf("expected ID from directory %q, got %q", "my-awesome-skill", resp.Imported[0].ID)
	}
	if resp.Imported[0].Name != "My Awesome Skill" {
		t.Fatalf("expected name from heading %q, got %q", "My Awesome Skill", resp.Imported[0].Name)
	}
}

func TestImportBatchFromFile_PreservesExtraFiles(t *testing.T) {
	t.Parallel()

	dir := t.TempDir()
	m := New(dir)

	zipPath := createMultiSkillZip(t, map[string]string{
		"skills/complex/SKILL.md":       "---\nid: complex\nname: Complex\n---\n# Complex\n",
		"skills/complex/scripts/run.sh": "#!/bin/bash\necho run",
		"skills/complex/docs/guide.md":  "# Guide\nUsage.",
	})

	resp, err := m.Skills().ImportBatchFromFile(zipPath, false)
	if err != nil {
		t.Fatalf("ImportBatchFromFile: %v", err)
	}
	if resp.Created != 1 {
		t.Fatalf("expected 1 created, got %d", resp.Created)
	}

	// Verify extra files on disk with relative paths.
	skillDir := filepath.Join(dir, "skills", "complex")
	if _, err := os.Stat(filepath.Join(skillDir, "scripts", "run.sh")); err != nil {
		t.Fatalf("scripts/run.sh not found: %v", err)
	}
	if _, err := os.Stat(filepath.Join(skillDir, "docs", "guide.md")); err != nil {
		t.Fatalf("docs/guide.md not found: %v", err)
	}
}

func TestImportBatchFromFile_ZipRejectsTraversalAndWritesNothingOutside(t *testing.T) {
	t.Parallel()

	dir := t.TempDir()
	m := New(dir)
	zipPath := createMultiSkillZip(t, map[string]string{
		"skills/alpha/SKILL.md":         "---\nid: alpha\nname: Alpha\n---\n# Alpha\n",
		"skills/alpha/../../escape.txt": "outside",
	})

	_, err := m.Skills().ImportBatchFromFile(zipPath, false)
	if err == nil {
		t.Fatal("expected traversal archive to be rejected")
	}
	if _, statErr := os.Stat(filepath.Join(dir, "escape.txt")); !os.IsNotExist(statErr) {
		t.Fatalf("escape.txt stat error = %v, want not exist", statErr)
	}
	if _, statErr := os.Stat(filepath.Join(dir, "skills", "alpha")); !os.IsNotExist(statErr) {
		t.Fatalf("alpha skill dir stat error = %v, want not exist", statErr)
	}
}

func TestImportBatchFromFile_ZipRejectsWindowsPaths(t *testing.T) {
	t.Parallel()

	dir := t.TempDir()
	m := New(dir)
	zipPath := createMultiSkillZip(t, map[string]string{
		`skills\alpha\SKILL.md`: "---\nid: alpha\nname: Alpha\n---\n# Alpha\n",
	})

	_, err := m.Skills().ImportBatchFromFile(zipPath, false)
	if err == nil {
		t.Fatal("expected Windows separator archive to be rejected")
	}
}

func TestZipEntryReaderRejectsEntryCountLimit(t *testing.T) {
	t.Parallel()

	zipPath := createMultiSkillZip(t, map[string]string{
		"skills/alpha/SKILL.md": "---\nid: alpha\nname: Alpha\n---\n# Alpha\n",
		"skills/alpha/a.txt":    "a",
		"skills/alpha/b.txt":    "b",
	})
	reader, err := zip.OpenReader(zipPath)
	if err != nil {
		t.Fatal(err)
	}
	defer reader.Close()

	err = readEntryReaderError(zipEntryReaderWithLimits(reader.File, skillArchiveLimits{maxEntries: 2}))
	if err == nil {
		t.Fatal("expected entry count limit error")
	}
}

func TestZipEntryReaderRejectsEntrySizeLimit(t *testing.T) {
	t.Parallel()

	zipPath := createMultiSkillZip(t, map[string]string{
		"skills/alpha/SKILL.md":  "---\nid: alpha\nname: Alpha\n---\n# Alpha\n",
		"skills/alpha/large.bin": string(bytes.Repeat([]byte("x"), 65)),
	})
	reader, err := zip.OpenReader(zipPath)
	if err != nil {
		t.Fatal(err)
	}
	defer reader.Close()

	err = readEntryReaderError(zipEntryReaderWithLimits(reader.File, skillArchiveLimits{maxEntries: 10, maxEntryBytes: 64, maxExtractedBytes: 100}))
	if err == nil {
		t.Fatal("expected entry size limit error")
	}
}

func TestTarEntryReaderRejectsAggregateSizeLimit(t *testing.T) {
	t.Parallel()

	skill := "---\nid: alpha\nname: Alpha\n---\n# Alpha\n"
	tgzPath := createMultiSkillTarGz(t, map[string]string{
		"skills/alpha/SKILL.md": skill,
		"skills/alpha/data.bin": "1234567890",
	})
	reader := openTestTarGz(t, tgzPath)

	err := readEntryReaderError(tarEntryReaderWithLimits(reader, skillArchiveLimits{
		maxEntries:        10,
		maxEntryBytes:     100,
		maxExtractedBytes: int64(len(skill) + 5),
	}))
	if err == nil {
		t.Fatal("expected aggregate size limit error")
	}
}

func TestDirectoryEntryReaderRejectsEntrySizeLimit(t *testing.T) {
	t.Parallel()

	root := t.TempDir()
	writeSkillTreeFile(t, root, "skills/alpha/SKILL.md", "---\nid: alpha\nname: Alpha\n---\n# Alpha\n")
	writeSkillTreeFile(t, root, "skills/alpha/large.bin", string(bytes.Repeat([]byte("x"), 65)))

	err := readEntryReaderError(directoryEntryReaderWithLimits(root, skillArchiveLimits{
		maxEntries:        10,
		maxEntryBytes:     64,
		maxExtractedBytes: 100,
	}))
	if err == nil {
		t.Fatal("expected directory entry size limit error")
	}
}

func TestDirectoryEntryReaderRejectsSymlinkWithTarget(t *testing.T) {
	t.Parallel()

	root := t.TempDir()
	writeSkillTreeFile(t, root, "skills/alpha/SKILL.md", "---\nid: alpha\nname: Alpha\n---\n# Alpha\n")
	linkPath := filepath.Join(root, "skills", "alpha", "data")
	if err := os.Symlink("../../../src/ui-ux-pro-max/data", linkPath); err != nil {
		t.Skipf("symlink unavailable: %v", err)
	}

	err := readEntryReaderError(directoryEntryReaderWithLimits(root, skillArchiveLimits{
		maxEntries:        10,
		maxEntryBytes:     64,
		maxExtractedBytes: 100,
	}))
	if err == nil {
		t.Fatal("expected directory symlink error")
	}
	message := err.Error()
	for _, want := range []string{
		"unsafe archive entry",
		linkPath,
		"symlink entries are not allowed",
		`target "../../../src/ui-ux-pro-max/data"`,
		"Repack the skill with linked content as regular files/directories, or remove special files before importing",
	} {
		if !strings.Contains(message, want) {
			t.Fatalf("expected error to contain %q, got %q", want, message)
		}
	}
}

func TestImportBatchFromFile_InvalidSkillIDDoesNotWrite(t *testing.T) {
	t.Parallel()

	dir := t.TempDir()
	m := New(dir)
	zipPath := createMultiSkillZip(t, map[string]string{
		"skills/alpha/SKILL.md": "---\nid: ../alpha\nname: Alpha\n---\n# Alpha\n",
	})

	resp, err := m.Skills().ImportBatchFromFile(zipPath, false)
	if err != nil {
		t.Fatalf("ImportBatchFromFile: %v", err)
	}
	if len(resp.Errors) != 1 {
		t.Fatalf("expected 1 import error, got %+v", resp)
	}
	if resp.Created != 0 {
		t.Fatalf("expected 0 created, got %d", resp.Created)
	}
	if _, statErr := os.Stat(filepath.Join(dir, "skills")); !os.IsNotExist(statErr) {
		t.Fatalf("skills dir stat error = %v, want not exist", statErr)
	}
}

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

func createMultiSkillZip(t *testing.T, files map[string]string) string {
	t.Helper()
	var buf bytes.Buffer
	w := zip.NewWriter(&buf)
	for name, content := range files {
		fw, err := w.Create(name)
		if err != nil {
			t.Fatal(err)
		}
		if _, err := fw.Write([]byte(content)); err != nil {
			t.Fatal(err)
		}
	}
	if err := w.Close(); err != nil {
		t.Fatal(err)
	}

	tmpDir := t.TempDir()
	zipPath := filepath.Join(tmpDir, "multi-skill.zip")
	mustWriteFile(t, zipPath, buf.Bytes())
	return zipPath
}

func createMultiSkillTarGz(t *testing.T, files map[string]string) string {
	t.Helper()
	var buf bytes.Buffer
	gw := gzip.NewWriter(&buf)
	tw := tar.NewWriter(gw)
	for name, content := range files {
		if err := tw.WriteHeader(&tar.Header{
			Name: name,
			Mode: 0644,
			Size: int64(len(content)),
		}); err != nil {
			t.Fatal(err)
		}
		if _, err := tw.Write([]byte(content)); err != nil {
			t.Fatal(err)
		}
	}
	if err := tw.Close(); err != nil {
		t.Fatal(err)
	}
	if err := gw.Close(); err != nil {
		t.Fatal(err)
	}

	tmpDir := t.TempDir()
	tgzPath := filepath.Join(tmpDir, "multi-skill.tar.gz")
	mustWriteFile(t, tgzPath, buf.Bytes())
	return tgzPath
}

func openTestTarGz(t *testing.T, path string) *tar.Reader {
	t.Helper()
	file, err := os.Open(path)
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { _ = file.Close() })
	gzipReader, err := gzip.NewReader(file)
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { _ = gzipReader.Close() })
	return tar.NewReader(gzipReader)
}

func readEntryReaderError(readEntries entryReaderFunc) error {
	var readErr error
	readEntries(func(_ archiveEntry, err error) bool {
		if err != nil {
			readErr = err
			return false
		}
		return true
	})
	return readErr
}

func writeSkillTreeFile(t *testing.T, root, rel, content string) {
	t.Helper()
	path := filepath.Join(root, filepath.FromSlash(rel))
	if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
		t.Fatal(err)
	}
	mustWriteFile(t, path, []byte(content))
}
