package profile

import (
	"archive/tar"
	"archive/zip"
	"bytes"
	"compress/gzip"
	"context"
	"errors"
	"net/http"
	"net/http/httptest"
	"os"
	"path/filepath"
	"strconv"
	"strings"
	"testing"

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

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

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

	md := t.TempDir() + "/test.md"
	mustWriteFile(t, md, []byte(`---
id: code-review
name: Code Review
description: Review code quality
version: "1.0"
author: team
tags: [review, quality]
---

# Code Review

Check for bugs and style issues.
`))

	skill, needsMeta, err := m.Skills().ImportFromFile(md, nil, false)
	if err != nil {
		t.Fatalf("ImportFromFile: %v", err)
	}
	if needsMeta {
		t.Fatal("unexpected needsMeta=true")
	}
	if skill.ID != "code-review" {
		t.Fatalf("expected id %q, got %q", "code-review", skill.ID)
	}
	if skill.Name != "Code Review" {
		t.Fatalf("expected name %q, got %q", "Code Review", skill.Name)
	}
	if skill.Description != "Review code quality" {
		t.Fatalf("expected description %q, got %q", "Review code quality", skill.Description)
	}
	if skill.Version != "1.0" {
		t.Fatalf("expected version %q, got %q", "1.0", skill.Version)
	}
	if skill.Author != "team" {
		t.Fatalf("expected author %q, got %q", "team", skill.Author)
	}
	if len(skill.Tags) != 2 || skill.Tags[0] != "review" || skill.Tags[1] != "quality" {
		t.Fatalf("expected tags [review, quality], got %v", skill.Tags)
	}

	skillPath := filepath.Join(dir, "skills", "code-review", "SKILL.md")
	if _, err := os.Stat(skillPath); err != nil {
		t.Fatalf("SKILL.md not found at %s: %v", skillPath, err)
	}
}

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

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

	md := t.TempDir() + "/no-meta.md"
	mustWriteFile(t, md, []byte("# Just a heading\n\nNo front-matter here.\n"))

	skill, needsMeta, err := m.Skills().ImportFromFile(md, nil, false)
	if err != nil {
		t.Fatalf("ImportFromFile: %v", err)
	}
	if needsMeta {
		t.Fatal("unexpected needsMeta=true")
	}
	if skill.ID != "just-a-heading" {
		t.Fatalf("expected inferred id %q, got %q", "just-a-heading", skill.ID)
	}
	if skill.Name != "Just a heading" {
		t.Fatalf("expected inferred name %q, got %q", "Just a heading", skill.Name)
	}
	if skill.Description != "No front-matter here." {
		t.Fatalf("expected inferred description %q, got %q", "No front-matter here.", skill.Description)
	}
}

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

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

	md := t.TempDir() + "/partial.md"
	mustWriteFile(t, md, []byte(`---
id: my-skill
---

# Content
`))

	meta := &SkillMeta{Name: "Overridden Name", Description: "Added description"}
	skill, needsMeta, err := m.Skills().ImportFromFile(md, meta, false)
	if err != nil {
		t.Fatalf("ImportFromFile: %v", err)
	}
	if needsMeta {
		t.Fatal("unexpected needsMeta=true")
	}
	if skill.ID != "my-skill" {
		t.Fatalf("expected id %q, got %q", "my-skill", skill.ID)
	}
	if skill.Name != "Overridden Name" {
		t.Fatalf("expected name %q, got %q", "Overridden Name", skill.Name)
	}
	if skill.Description != "Added description" {
		t.Fatalf("expected description %q, got %q", "Added description", skill.Description)
	}
}

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

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

	f := t.TempDir() + "/data.txt"
	mustWriteFile(t, f, []byte("not a skill"))

	_, _, err := m.Skills().ImportFromFile(f, nil, false)
	if err == nil {
		t.Fatal("expected error for unsupported extension")
	}
}

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

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

	md := filepath.Join(t.TempDir(), "bad.md")
	mustWriteFile(t, md, []byte("---\nid: ../bad\nname: Bad\n---\n# Bad\n"))

	_, _, err := m.Skills().ImportFromFile(md, nil, false)
	require.ErrorContains(t, err, "invalid skill ID")
	if _, statErr := os.Stat(filepath.Join(dir, "bad")); !os.IsNotExist(statErr) {
		t.Fatalf("escaped skill dir stat error = %v, want not exist", statErr)
	}
}

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

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

	md := t.TempDir() + "/test.md"
	mustWriteFile(t, md, []byte("---\nid: dup\ntitle: Dup\n---\n# Dup\n"))

	// fix: front-matter needs 'name' not 'title'
	mustWriteFile(t, md, []byte("---\nid: dup\nname: Dup\n---\n# Dup\n"))

	_, needsMeta, err := m.Skills().ImportFromFile(md, nil, false)
	if err != nil {
		t.Fatalf("first import: %v", err)
	}
	if needsMeta {
		t.Fatal("first import: unexpected needsMeta")
	}

	_, _, err = m.Skills().ImportFromFile(md, nil, false)
	if err == nil {
		t.Fatal("expected ErrSkillExists on duplicate import")
	}
	if !errors.Is(err, ErrSkillExists) {
		t.Fatalf("expected ErrSkillExists, got %v", err)
	}
}

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

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

	md := t.TempDir() + "/test.md"
	mustWriteFile(t, md, []byte("---\nid: over\ntitle: Over\n---\n# Over\n"))
	mustWriteFile(t, md, []byte("---\nid: over\nname: Original\n---\n# Original\n"))

	_, _, err := m.Skills().ImportFromFile(md, nil, false)
	if err != nil {
		t.Fatalf("first import: %v", err)
	}

	mustWriteFile(t, md, []byte("---\nid: over\nname: Replaced\n---\n# Replaced\n"))

	skill, _, err := m.Skills().ImportFromFile(md, nil, true)
	if err != nil {
		t.Fatalf("force import: %v", err)
	}
	if skill.Name != "Replaced" {
		t.Fatalf("expected name %q after force, got %q", "Replaced", skill.Name)
	}

	got, ok := m.Skills().Get("over")
	if !ok {
		t.Fatal("skill not found after force import")
	}
	if got.Name != "Replaced" {
		t.Fatalf("get: expected name %q, got %q", "Replaced", got.Name)
	}
}

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

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

	zipPath := t.TempDir() + "/skill.zip"
	createTestZip(t, zipPath, map[string]string{
		"SKILL.md":           "---\nid: zip-skill\nname: Zip Skill\nversion: \"2.0\"\n---\n# Zip Skill\n",
		"examples/eg1.md":    "Example 1",
		"templates/tmpl.txt": "Template",
	})

	skill, needsMeta, err := m.Skills().ImportFromFile(zipPath, nil, false)
	if err != nil {
		t.Fatalf("ImportFromFile zip: %v", err)
	}
	if needsMeta {
		t.Fatal("unexpected needsMeta")
	}
	if skill.ID != "zip-skill" {
		t.Fatalf("expected id %q, got %q", "zip-skill", skill.ID)
	}
	if skill.Version != "2.0" {
		t.Fatalf("expected version %q, got %q", "2.0", skill.Version)
	}

	skillDir := filepath.Join(dir, "skills", "zip-skill")
	if _, err := os.Stat(filepath.Join(skillDir, "SKILL.md")); err != nil {
		t.Fatalf("SKILL.md not found: %v", err)
	}
	if _, err := os.Stat(filepath.Join(skillDir, "eg1.md")); err != nil {
		t.Fatalf("extra file eg1.md not found: %v", err)
	}
	if _, err := os.Stat(filepath.Join(skillDir, "tmpl.txt")); err != nil {
		t.Fatalf("extra file tmpl.txt not found: %v", err)
	}
}

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

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

	zipPath := t.TempDir() + "/bad.zip"
	createTestZip(t, zipPath, map[string]string{
		"readme.md": "No SKILL.md here",
	})

	_, _, err := m.Skills().ImportFromFile(zipPath, nil, false)
	if err == nil {
		t.Fatal("expected error for zip without SKILL.md")
	}
}

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

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

	zipPath := filepath.Join(t.TempDir(), "symlink.zip")
	createTestZipWithSymlink(t, zipPath)

	_, _, err := m.Skills().ImportFromFile(zipPath, nil, false)
	require.ErrorContains(t, err, `unsafe archive entry "bundle/link"`)
	require.ErrorContains(t, err, "symlink entries are not allowed")
	require.ErrorContains(t, err, `target "target"`)
	require.ErrorContains(t, err, "Repack the skill with linked content as regular files/directories, or remove special files before importing")
}

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

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

	tgzPath := t.TempDir() + "/skill.tar.gz"
	createTestTarGz(t, tgzPath, map[string]string{
		"SKILL.md":        "---\nid: tar-skill\nname: Tar Skill\n---\n# Tar\n",
		"examples/eg.txt": "Example",
	})

	skill, needsMeta, err := m.Skills().ImportFromFile(tgzPath, nil, false)
	if err != nil {
		t.Fatalf("ImportFromFile tar.gz: %v", err)
	}
	if needsMeta {
		t.Fatal("unexpected needsMeta")
	}
	if skill.ID != "tar-skill" {
		t.Fatalf("expected id %q, got %q", "tar-skill", skill.ID)
	}

	skillDir := filepath.Join(dir, "skills", "tar-skill")
	if _, err := os.Stat(filepath.Join(skillDir, "SKILL.md")); err != nil {
		t.Fatalf("SKILL.md not found: %v", err)
	}
	if _, err := os.Stat(filepath.Join(skillDir, "eg.txt")); err != nil {
		t.Fatalf("extra file not found: %v", err)
	}
}

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

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

	tgzPath := t.TempDir() + "/bad.tar.gz"
	createTestTarGz(t, tgzPath, map[string]string{
		"readme.txt": "Nothing useful",
	})

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

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

	tests := []struct {
		name       string
		typeflag   byte
		contains   []string
		notContain []string
	}{
		{
			name:     "symlink",
			typeflag: tar.TypeSymlink,
			contains: []string{`unsafe archive entry "bundle/special"`, "symlink entries are not allowed", `target "target"`, "Repack the skill with linked content as regular files/directories, or remove special files before importing"},
		},
		{
			name:       "hardlink",
			typeflag:   tar.TypeLink,
			contains:   []string{`unsafe archive entry "bundle/special"`, "special file entry (hard link) is not allowed", "Repack the skill with linked content as regular files/directories, or remove special files before importing"},
			notContain: []string{"symlink entries are not allowed"},
		},
		{
			name:       "device",
			typeflag:   tar.TypeChar,
			contains:   []string{`unsafe archive entry "bundle/special"`, "special file entry (device) is not allowed", "Repack the skill with linked content as regular files/directories, or remove special files before importing"},
			notContain: []string{"symlink entries are not allowed"},
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()

			dir := t.TempDir()
			m := New(dir)
			tgzPath := filepath.Join(t.TempDir(), tt.name+".tar.gz")
			createTestTarGzWithSpecialEntry(t, tgzPath, tt.typeflag)

			_, _, err := m.Skills().ImportFromFile(tgzPath, nil, false)
			for _, want := range tt.contains {
				require.ErrorContains(t, err, want)
			}
			for _, notWant := range tt.notContain {
				require.NotContains(t, err.Error(), notWant)
			}
		})
	}
}

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

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

	skills := m.Skills().All()
	if len(skills) != 0 {
		t.Fatalf("expected 0 skills, got %d", len(skills))
	}

	_, ok := m.Skills().Get("test-skill")
	if ok {
		t.Fatal("expected skill not found")
	}

	s := Skill{ID: "test-skill", Name: "Test Skill", FileName: "test-skill/SKILL.md"}
	if err := m.Skills().Add(s); err != nil {
		t.Fatalf("Add: %v", err)
	}

	got, ok := m.Skills().Get("test-skill")
	if !ok {
		t.Fatal("expected skill found after Add")
	}
	if got.Name != "Test Skill" {
		t.Fatalf("expected name %q, got %q", "Test Skill", got.Name)
	}

	if err := m.Skills().Add(s); err == nil {
		t.Fatal("expected duplicate error")
	}

	s.Name = "Updated Skill"
	if err := m.Skills().Update(s); err != nil {
		t.Fatalf("Update: %v", err)
	}
	got, _ = m.Skills().Get("test-skill")
	if got.Name != "Updated Skill" {
		t.Fatalf("expected updated name %q, got %q", "Updated Skill", got.Name)
	}

	if err := m.Skills().Delete("test-skill"); err != nil {
		t.Fatalf("Delete: %v", err)
	}
	_, ok = m.Skills().Get("test-skill")
	if ok {
		t.Fatal("expected skill not found after Delete")
	}

	if err := m.Skills().Delete("test-skill"); err == nil {
		t.Fatal("expected not-found error on double delete")
	}
}

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

	dir := t.TempDir()

	m := New(dir)
	md := t.TempDir() + "/persist.md"
	mustWriteFile(t, md, []byte("---\nid: persist\ntitle: Persist\n---\n# Persist\n"))
	mustWriteFile(t, md, []byte("---\nid: persist\nname: Persist\n---\n# Persist\n"))

	_, _, err := m.Skills().ImportFromFile(md, nil, false)
	if err != nil {
		t.Fatalf("import: %v", err)
	}

	m2 := New(dir)
	got, ok := m2.Skills().Get("persist")
	if !ok {
		t.Fatal("skill not found after reload from disk")
	}
	if got.Name != "Persist" {
		t.Fatalf("expected name %q, got %q", "Persist", got.Name)
	}
	if got.CreatedAt == nil {
		t.Fatal("expected created_at to persist across reload")
	}

	skillsYAML, err := os.ReadFile(filepath.Join(dir, "skills.yaml"))
	if err != nil {
		t.Fatalf("read skills.yaml: %v", err)
	}
	if !bytes.Contains(skillsYAML, []byte("created_at:")) {
		t.Fatalf("expected skills.yaml to contain created_at, got %s", skillsYAML)
	}
	if bytes.Contains(skillsYAML, []byte("enabled:")) {
		t.Fatalf("expected skills.yaml to omit enabled, got %s", skillsYAML)
	}
}

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

	dir := t.TempDir()
	mustWriteFile(t, filepath.Join(dir, "skills.yaml"), []byte(`skills:
  - id: legacy-skill
    name: Legacy Skill
    description: Loaded from legacy YAML
    enabled: false
    file_name: legacy-skill/SKILL.md
`))

	m := New(dir)
	skill, ok := m.Skills().Get("legacy-skill")
	require.True(t, ok)
	require.Equal(t, "Legacy Skill", skill.Name)
	require.Equal(t, "Loaded from legacy YAML", skill.Description)
	require.Equal(t, filepath.Join("legacy-skill", "SKILL.md"), skill.FileName)
}

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

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

	md := t.TempDir() + "/content.md"
	mustWriteFile(t, md, []byte("---\nid: cont\nname: Content\n---\n# Hello\nBody text.\n"))

	_, _, err := m.Skills().ImportFromFile(md, nil, false)
	if err != nil {
		t.Fatalf("import: %v", err)
	}

	content, err := m.Skills().Content("cont")
	if err != nil {
		t.Fatalf("Content: %v", err)
	}
	if !bytes.Contains([]byte(content), []byte("# Hello")) {
		t.Fatalf("content should contain '# Hello', got %q", content)
	}

	_, err = m.Skills().Content("nonexistent")
	if err == nil {
		t.Fatal("expected error for nonexistent skill")
	}
}

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

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

	md := filepath.Join(t.TempDir(), "large-content.md")
	body := bytes.Repeat([]byte("x"), int(MaxSkillContentBytes)+32)
	mustWriteFile(t, md, append([]byte("---\nid: large-content\nname: Large Content\n---\n# Large\n"), body...))

	_, _, err := m.Skills().ImportFromFile(md, nil, false)
	require.NoError(t, err)

	content, truncated, err := m.Skills().ContentWithLimit("large-content", MaxSkillContentBytes)
	require.NoError(t, err)
	require.True(t, truncated)
	require.LessOrEqual(t, int64(len([]byte(content))), MaxSkillContentBytes)
}

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

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

	md := filepath.Join(t.TempDir(), "oversized.md")
	f, err := os.Create(md)
	require.NoError(t, err)
	_, err = f.WriteString("---\nid: oversized\nname: Oversized\n---\n")
	require.NoError(t, err)
	require.NoError(t, f.Truncate(MaxSkillMarkdownBytes+1))
	require.NoError(t, f.Close())

	_, _, err = m.Skills().ImportFromFile(md, nil, false)
	require.ErrorContains(t, err, "content exceeds")
}

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

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

	md := t.TempDir() + "/del.md"
	mustWriteFile(t, md, []byte("---\nid: del-me\nname: Delete Me\n---\n# Delete\n"))

	_, _, err := m.Skills().ImportFromFile(md, nil, false)
	if err != nil {
		t.Fatalf("import: %v", err)
	}

	skillDir := filepath.Join(dir, "skills", "del-me")
	if _, err := os.Stat(skillDir); err != nil {
		t.Fatalf("skill dir not found: %v", err)
	}

	if err := m.Skills().Delete("del-me"); err != nil {
		t.Fatalf("Delete: %v", err)
	}

	if _, err := os.Stat(skillDir); !os.IsNotExist(err) {
		t.Fatal("expected skill directory to be removed after delete")
	}
}

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

	tests := []struct {
		name   string
		input  string
		wantID string
		wantNm string
	}{
		{"with front-matter", "---\nid: x\nname: X\n---\n# X\n", "x", "X"},
		{"no front-matter", "# Just heading\n", "", ""},
		{"partial front-matter", "---\nid: x\n---\n# X\n", "x", ""},
		{"unclosed front-matter", "---\nid: x\nname: X\n# X\n", "", ""},
		{"extra fields", "---\nid: a\nname: A\nversion: \"1.0\"\nauthor: bob\ntags: [t1, t2]\n---\n# A\n", "a", "A"},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			meta := parseSkillFrontMatter([]byte(tt.input))
			if meta.ID != tt.wantID {
				t.Errorf("ID = %q, want %q", meta.ID, tt.wantID)
			}
			if meta.Name != tt.wantNm {
				t.Errorf("Name = %q, want %q", meta.Name, tt.wantNm)
			}
		})
	}
}

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

	parsed := &SkillMeta{ID: "parsed", Name: "Parsed", Description: "old"}
	override := &SkillMeta{Name: "Override", Description: "new", Author: "bob"}

	result := mergeMeta(parsed, override)
	if result.ID != "parsed" {
		t.Errorf("ID should stay parsed, got %q", result.ID)
	}
	if result.Name != "Override" {
		t.Errorf("Name should be Override, got %q", result.Name)
	}
	if result.Description != "new" {
		t.Errorf("Description should be new, got %q", result.Description)
	}
	if result.Author != "bob" {
		t.Errorf("Author should be bob, got %q", result.Author)
	}

	nilOverride := mergeMeta(parsed, nil)
	if nilOverride.ID != "parsed" {
		t.Errorf("nil override: ID = %q, want parsed", nilOverride.ID)
	}
}

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

	meta := completeSkillMeta([]byte("Use this workflow when reviewing code.\n"), &SkillMeta{}, nil, "/tmp/code-review.md")

	if meta.ID != "code-review" {
		t.Fatalf("ID = %q, want %q", meta.ID, "code-review")
	}
	if meta.Name != "Code Review" {
		t.Fatalf("Name = %q, want %q", meta.Name, "Code Review")
	}
	if meta.Description != "Use this workflow when reviewing code." {
		t.Fatalf("Description = %q, want %q", meta.Description, "Use this workflow when reviewing code.")
	}
}

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

	input := []byte("---\nid: x\nname: X\n---\n\n# Body\n\nContent here.\n")
	body := StripFrontMatter(input)
	if bytes.Contains(body, []byte("id: x")) {
		t.Fatal("front-matter should be stripped")
	}
	if !bytes.Contains(body, []byte("# Body")) {
		t.Fatal("body should be preserved")
	}

	noFM := []byte("# No front matter\n")
	got := StripFrontMatter(noFM)
	if !bytes.Equal(got, noFM) {
		t.Fatal("content without front-matter should be unchanged")
	}
}

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

	tests := []struct {
		name string
		m    SkillMeta
		want bool
	}{
		{"both set", SkillMeta{ID: "x", Name: "X"}, true},
		{"missing id", SkillMeta{Name: "X"}, false},
		{"missing name", SkillMeta{ID: "x"}, false},
		{"whitespace id", SkillMeta{ID: "  ", Name: "X"}, false},
		{"whitespace name", SkillMeta{ID: "x", Name: "  "}, false},
		{"both empty", SkillMeta{}, false},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			if got := tt.m.HasRequiredFields(); got != tt.want {
				t.Errorf("HasRequiredFields() = %v, want %v", got, tt.want)
			}
		})
	}
}

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

	tests := []struct {
		name    string
		s       Skill
		wantErr bool
	}{
		{"valid", Skill{ID: "x", Name: "X", FileName: "x/SKILL.md"}, false},
		{"empty id", Skill{Name: "X", FileName: "x/SKILL.md"}, true},
		{"path traversal id", Skill{ID: "../x", Name: "X", FileName: "x/SKILL.md"}, true},
		{"windows drive id", Skill{ID: "C:x", Name: "X", FileName: "x/SKILL.md"}, true},
		{"empty name", Skill{ID: "x", FileName: "x/SKILL.md"}, true},
		{"empty filename", Skill{ID: "x", Name: "X"}, true},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			if err := tt.s.Validate(); (err != nil) != tt.wantErr {
				t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr)
			}
		})
	}
}

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

	_, err := DownloadToFile("not-a-url")
	if err == nil {
		t.Fatal("expected error for invalid URL")
	}
}

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

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

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

	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "text/plain")
		if flusher, ok := w.(http.Flusher); ok {
			flusher.Flush()
		}
		_, _ = w.Write(bytes.Repeat([]byte("x"), 6))
	}))
	defer server.Close()

	_, err := DownloadToFileWithLimit(context.Background(), server.URL+"/skill.md", 5)
	require.ErrorContains(t, err, "downloaded content exceeds")
}

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

	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Length", strconv.FormatInt(MaxSkillImportBytes+1, 10))
		w.WriteHeader(http.StatusOK)
	}))
	defer server.Close()

	_, err := DownloadToFile(server.URL + "/skill.zip")
	require.ErrorContains(t, err, "downloaded content exceeds")
}

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

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

	_, _, err := m.Skills().ImportFromURL("not-a-url", nil, false)
	if err == nil {
		t.Fatal("expected error for invalid URL")
	}
}

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

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

	zipPath := filepath.Join(t.TempDir(), "single.zip")
	createTestZip(t, zipPath, map[string]string{
		"bundle/SKILL.md":            "---\nid: auto-zip\nname: Auto Zip\n---\n# Auto Zip\n",
		"bundle/examples/example.md": "Example content",
		"bundle/scripts/run.sh":      "#!/bin/sh\necho ok\n",
	})

	result, err := mgr.Skills().ImportAutoFromFile(zipPath, nil, false)
	require.NoError(t, err)
	require.NotNil(t, result)
	require.False(t, result.NeedsMeta)
	require.NotNil(t, result.Skill)
	require.Nil(t, result.Batch)
	require.Equal(t, "auto-zip", result.Skill.ID)

	_, err = os.Stat(filepath.Join(dir, "skills", "auto-zip", "examples", "example.md"))
	require.NoError(t, err)
	_, err = os.Stat(filepath.Join(dir, "skills", "auto-zip", "scripts", "run.sh"))
	require.NoError(t, err)
}

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

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

	zipPath := filepath.Join(t.TempDir(), "inferred-meta.zip")
	createTestZip(t, zipPath, map[string]string{
		"bundle/SKILL.md": "# Missing metadata\n",
	})

	result, err := mgr.Skills().ImportAutoFromFile(zipPath, nil, false)
	require.NoError(t, err)
	require.NotNil(t, result)
	require.False(t, result.NeedsMeta)
	require.NotNil(t, result.Skill)
	require.Equal(t, "missing-metadata", result.Skill.ID)
	require.Equal(t, "Missing metadata", result.Skill.Name)
	require.Nil(t, result.Batch)
}

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

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

	zipPath := filepath.Join(t.TempDir(), "batch.zip")
	createTestZip(t, zipPath, map[string]string{
		"skills/alpha/SKILL.md":       "---\nid: alpha\nname: Alpha\n---\n# Alpha\n",
		"skills/alpha/docs/readme.md": "Alpha docs",
		"skills/beta/SKILL.md":        "---\nid: beta\nname: Beta\n---\n# Beta\n",
	})

	result, err := mgr.Skills().ImportAutoFromFile(zipPath, nil, false)
	require.NoError(t, err)
	require.NotNil(t, result)
	require.Nil(t, result.Skill)
	require.NotNil(t, result.Batch)
	require.Len(t, result.Batch.Imported, 2)
	require.Equal(t, 2, result.Batch.Created)

	_, err = os.Stat(filepath.Join(dir, "skills", "alpha", "docs", "readme.md"))
	require.NoError(t, err)
}

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

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

	zipPath := filepath.Join(t.TempDir(), "batch.zip")
	createTestZip(t, zipPath, 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",
	})

	result, err := mgr.Skills().ImportAutoFromFile(zipPath, &SkillMeta{Name: "Override"}, false)
	require.Error(t, err)
	require.Nil(t, result)
	require.Contains(t, err.Error(), "metadata overrides are only supported")
}

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

	resolved, err := ResolveArchiveURL("https://github.com/openai/superpowers/archive/refs/heads/main.zip")
	require.NoError(t, err)
	require.Equal(t, "https://github.com/openai/superpowers/archive/refs/heads/main.zip", resolved)
	require.False(t, strings.HasSuffix(resolved, "?"))
}

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

	resolved, err := ResolveArchiveURL("https://github.com/garrytan/gstack/")
	require.NoError(t, err)
	require.Equal(t, "https://github.com/garrytan/gstack/archive/refs/heads/main.zip", resolved)
}

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

	resolved, err := ResolveArchiveURL("https://github.com/garrytan/gstack/tree/dev/")
	require.NoError(t, err)
	require.Equal(t, "https://github.com/garrytan/gstack/archive/refs/heads/dev.zip", resolved)
}

func createTestZip(t *testing.T, path string, files map[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)
	}
	mustWriteFile(t, path, buf.Bytes())
}

func createTestZipWithSymlink(t *testing.T, path string) {
	t.Helper()
	var buf bytes.Buffer
	w := zip.NewWriter(&buf)
	fw, err := w.Create("bundle/SKILL.md")
	if err != nil {
		t.Fatal(err)
	}
	if _, err := fw.Write([]byte("---\nid: zip-link\nname: Zip Link\n---\n# Zip Link\n")); err != nil {
		t.Fatal(err)
	}
	hdr := &zip.FileHeader{Name: "bundle/link"}
	hdr.SetMode(os.ModeSymlink | 0777)
	fw, err = w.CreateHeader(hdr)
	if err != nil {
		t.Fatal(err)
	}
	if _, err := fw.Write([]byte("target")); err != nil {
		t.Fatal(err)
	}
	if err := w.Close(); err != nil {
		t.Fatal(err)
	}
	mustWriteFile(t, path, buf.Bytes())
}

func createTestTarGz(t *testing.T, path string, files map[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)
	}
	mustWriteFile(t, path, buf.Bytes())
}

func createTestTarGzWithSpecialEntry(t *testing.T, path string, typeflag byte) {
	t.Helper()
	var buf bytes.Buffer
	gw := gzip.NewWriter(&buf)
	tw := tar.NewWriter(gw)
	skill := []byte("---\nid: tar-special\nname: Tar Special\n---\n# Tar Special\n")
	if err := tw.WriteHeader(&tar.Header{Name: "bundle/SKILL.md", Mode: 0644, Size: int64(len(skill))}); err != nil {
		t.Fatal(err)
	}
	if _, err := tw.Write(skill); err != nil {
		t.Fatal(err)
	}
	if err := tw.WriteHeader(&tar.Header{Name: "bundle/special", Typeflag: typeflag, Mode: 0644, Linkname: "target"}); err != nil {
		t.Fatal(err)
	}
	if err := tw.Close(); err != nil {
		t.Fatal(err)
	}
	if err := gw.Close(); err != nil {
		t.Fatal(err)
	}
	mustWriteFile(t, path, buf.Bytes())
}
