package profile

import (
	"archive/tar"
	"archive/zip"
	"bytes"
	"compress/gzip"
	"context"
	"crypto/sha256"
	"encoding/hex"
	"errors"
	"fmt"
	"io"
	"log/slog"
	"net/http"
	"net/url"
	"os"
	"path"
	"path/filepath"
	"regexp"
	"sort"
	"strings"
	"time"
	"unicode"

	"go.yaml.in/yaml/v3"
)

var ErrSkillExists = errors.New("skill already exists")

const (
	// MaxSkillImportBytes caps downloaded and uploaded skill import payloads.
	MaxSkillImportBytes int64 = 64 << 20
	// MaxSkillMarkdownBytes caps single local Markdown skill imports.
	MaxSkillMarkdownBytes int64 = 16 << 20
	// MaxSkillContentBytes caps skill content returned through read APIs.
	MaxSkillContentBytes int64 = 64 << 10

	maxSkillArchiveEntries              = 4096
	maxSkillArchiveEntryBytes     int64 = 16 << 20
	maxSkillArchiveExtractedBytes int64 = 64 << 20
)

type skillArchiveLimits struct {
	maxEntries        int
	maxEntryBytes     int64
	maxExtractedBytes int64
}

var defaultSkillArchiveLimits = skillArchiveLimits{
	maxEntries:        maxSkillArchiveEntries,
	maxEntryBytes:     maxSkillArchiveEntryBytes,
	maxExtractedBytes: maxSkillArchiveExtractedBytes,
}

func (limits skillArchiveLimits) withDefaults() skillArchiveLimits {
	if limits.maxEntries <= 0 {
		limits.maxEntries = defaultSkillArchiveLimits.maxEntries
	}
	if limits.maxEntryBytes <= 0 {
		limits.maxEntryBytes = defaultSkillArchiveLimits.maxEntryBytes
	}
	if limits.maxExtractedBytes <= 0 {
		limits.maxExtractedBytes = defaultSkillArchiveLimits.maxExtractedBytes
	}
	return limits
}

var (
	skillIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`)
)

// UnsupportedHostError is returned when the URL host is not a supported git
// hosting platform (GitHub, GitLab, Gitee, or GitCode) and the URL does not
// look like a direct download link.
type UnsupportedHostError struct {
	Host string
	URL  string
}

// Error explains that the skill archive URL cannot be resolved automatically.
func (e *UnsupportedHostError) Error() string {
	return fmt.Sprintf("online import is not supported for %q; only GitHub, GitLab, Gitee, and GitCode repository URLs are supported. Please download the ZIP package manually and import it from a local file", e.Host)
}

// SkillManager provides CRUD operations for skill configurations.
type SkillManager struct {
	mgr *Manager
}

// skillsFile is the YAML wrapper for skills.
type skillsFile struct {
	Skills []Skill `yaml:"skills"`
}

// Dir returns the absolute path of the skills storage directory.
func (sm *SkillManager) Dir() string {
	return filepath.Join(sm.mgr.configDir, "skills")
}

// Get returns the skill with the given ID.
func (sm *SkillManager) Get(id string) (Skill, bool) {
	sm.mgr.mu.RLock()
	defer sm.mgr.mu.RUnlock()
	s, ok := sm.mgr.skills[id]
	if !ok {
		return Skill{}, false
	}
	return *s, true
}

// All returns all configured skills.
func (sm *SkillManager) All() []Skill {
	sm.mgr.mu.RLock()
	defer sm.mgr.mu.RUnlock()
	result := make([]Skill, 0, len(sm.mgr.skills))
	for _, s := range sm.mgr.skills {
		result = append(result, *s)
	}
	return result
}

// Add adds a new skill configuration.
func (sm *SkillManager) Add(s Skill) error {
	if err := s.Validate(); err != nil {
		return err
	}
	sm.mgr.mu.Lock()
	defer sm.mgr.mu.Unlock()
	if _, exists := sm.mgr.skills[s.ID]; exists {
		return fmt.Errorf("skill %q already exists; use a different ID or delete the existing skill first", s.ID)
	}
	sm.mgr.skills[s.ID] = &s
	return sm.saveSkillsLocked()
}

// Update updates an existing skill configuration.
func (sm *SkillManager) Update(s Skill) error {
	if err := s.Validate(); err != nil {
		return err
	}
	sm.mgr.mu.Lock()
	defer sm.mgr.mu.Unlock()
	if _, exists := sm.mgr.skills[s.ID]; !exists {
		return fmt.Errorf("skill %q not found", s.ID)
	}
	sm.mgr.skills[s.ID] = &s
	return sm.saveSkillsLocked()
}

// Delete removes a skill by ID and its content file from disk.
func (sm *SkillManager) Delete(id string) error {
	sm.mgr.mu.Lock()
	defer sm.mgr.mu.Unlock()
	s, exists := sm.mgr.skills[id]
	if !exists {
		return fmt.Errorf("skill %q not found", id)
	}

	contentPath := filepath.Join(sm.Dir(), s.FileName)
	os.RemoveAll(filepath.Dir(contentPath))

	delete(sm.mgr.skills, id)
	return sm.saveSkillsLocked()
}

// Content returns a budgeted prefix of the markdown content of a skill.
func (sm *SkillManager) Content(id string) (string, error) {
	content, _, err := sm.ContentWithLimit(id, MaxSkillContentBytes)
	return content, err
}

// ContentWithLimit returns at most maxBytes of the markdown content of a skill.
// The boolean return is true when the on-disk content has additional bytes.
func (sm *SkillManager) ContentWithLimit(id string, maxBytes int64) (string, bool, error) {
	sm.mgr.mu.RLock()
	s, ok := sm.mgr.skills[id]
	if !ok {
		sm.mgr.mu.RUnlock()
		return "", false, fmt.Errorf("skill %q not found", id)
	}
	fileName := s.FileName
	sm.mgr.mu.RUnlock()

	if maxBytes <= 0 {
		maxBytes = MaxSkillContentBytes
	}
	data, truncated, err := readFilePrefix(filepath.Join(sm.Dir(), fileName), maxBytes)
	if err != nil {
		return "", false, fmt.Errorf("failed to read skill content: %w", err)
	}
	return strings.ToValidUTF8(string(data), ""), truncated, nil
}

// ImportFromFile imports a skill from a markdown file or archive.
// If the file has front-matter metadata, that metadata is used. Missing
// metadata is inferred from the Markdown heading and source path so callers do
// not need to prompt for ordinary Markdown files.
// When force is true and a skill with the same ID already exists, the old
// skill is replaced (its content file and metadata are deleted first).
// When force is false and a skill with the same ID exists, ErrSkillExists
// is returned (wrapped with the skill ID).
func (sm *SkillManager) ImportFromFile(filePath string, meta *SkillMeta, force bool) (skill Skill, needsMeta bool, err error) {
	ext := strings.ToLower(filepath.Ext(filePath))
	switch ext {
	case ".md", ".markdown":
		return sm.importMarkdown(filePath, meta, force)
	case ".zip":
		return sm.importZip(filePath, meta, force)
	case ".gz", ".tgz":
		return sm.importTarGz(filePath, meta, force)
	case ".tar":
		return sm.importTar(filePath, meta, force)
	default:
		return Skill{}, false, fmt.Errorf(
			"unsupported file type %q; supported formats:\n"+
				"  - Markdown files: .md, .markdown\n"+
				"  - Archives:       .zip, .tar, .tar.gz, .tgz\n"+
				"Archives must contain a SKILL.md file at the top level",
			ext,
		)
	}
}

// ImportFromURL downloads a file from the given URL to a temp file and then
// imports it using the same flow as ImportFromFile. The temp file is cleaned
// up after import (success or failure).
func (sm *SkillManager) ImportFromURL(rawURL string, meta *SkillMeta, force bool) (skill Skill, needsMeta bool, err error) {
	parsed, err := url.Parse(rawURL)
	if err != nil {
		return Skill{}, false, fmt.Errorf("invalid URL %q: %w", rawURL, err)
	}
	if parsed.Scheme != "http" && parsed.Scheme != "https" {
		return Skill{}, false, fmt.Errorf("unsupported URL scheme %q; only http and https are supported", parsed.Scheme)
	}

	tmpPath, err := DownloadToFile(rawURL)
	if err != nil {
		return Skill{}, false, err
	}
	defer os.Remove(tmpPath)

	return sm.ImportFromFile(tmpPath, meta, force)
}

// AutoImportResult captures the user-facing outcome of an import request.
// Exactly one of Skill or Batch is set on success.
type AutoImportResult struct {
	Skill     *Skill
	Batch     *BatchImportResponse
	NeedsMeta bool
}

// ImportAutoFromFile imports either a single markdown skill or an archive.
// Archive sources are auto-discovered: one discovered skill is imported as a
// single skill, while multiple discovered skills are imported as a batch.
func (sm *SkillManager) ImportAutoFromFile(filePath string, meta *SkillMeta, force bool) (*AutoImportResult, error) {
	info, err := os.Stat(filePath)
	if err != nil {
		return nil, fmt.Errorf("failed to inspect skill source %q: %w", filePath, err)
	}
	if info.IsDir() {
		return sm.importAutoDirectory(filePath, meta, force)
	}

	if !isArchiveFilePath(filePath) {
		skill, needsMeta, err := sm.ImportFromFile(filePath, meta, force)
		if err != nil {
			return nil, err
		}
		if needsMeta {
			return &AutoImportResult{NeedsMeta: true}, nil
		}
		return &AutoImportResult{Skill: &skill}, nil
	}

	return sm.importAutoArchive(filePath, meta, force)
}

// ImportAutoFromURL downloads and imports either a markdown skill or an
// archive. GitHub/GitLab repository URLs are resolved to archive downloads.
func (sm *SkillManager) ImportAutoFromURL(rawURL string, meta *SkillMeta, force bool) (*AutoImportResult, error) {
	return sm.ImportAutoFromURLTo(rawURL, meta, force, io.Discard)
}

// ImportAutoFromURLTo is like ImportAutoFromURL but writes progress messages to w.
func (sm *SkillManager) ImportAutoFromURLTo(rawURL string, meta *SkillMeta, force bool, w io.Writer) (*AutoImportResult, error) {
	fmt.Fprintf(w, "Resolving URL: %s\n", rawURL)
	downloadURL, err := ResolveArchiveURL(rawURL)
	if err != nil {
		return nil, err
	}
	if downloadURL != rawURL {
		fmt.Fprintf(w, "Resolved to: %s\n", downloadURL)
	}

	fmt.Fprintf(w, "Downloading...\n")
	tmpPath, err := DownloadToFile(downloadURL)
	if err != nil {
		return nil, err
	}
	defer os.Remove(tmpPath)
	fmt.Fprintf(w, "Download complete: %s\n", filepath.Base(tmpPath))

	fmt.Fprintf(w, "Importing skill...\n")
	result, err := sm.ImportAutoFromFile(tmpPath, meta, force)
	if err != nil {
		return nil, err
	}

	if result.NeedsMeta {
		fmt.Fprintf(w, "Source requires additional metadata\n")
	} else if result.Batch != nil {
		fmt.Fprintf(w, "Batch import complete: %d imported, %d skipped\n", result.Batch.Created, result.Batch.Skipped)
	} else if result.Skill != nil {
		fmt.Fprintf(w, "Skill imported: %s\n", result.Skill.Name)
	}
	return result, nil
}

func isArchiveFilePath(filePath string) bool {
	switch strings.ToLower(filepath.Ext(filePath)) {
	case ".zip", ".tar", ".gz", ".tgz":
		return true
	default:
		return false
	}
}

func hasSkillMeta(meta *SkillMeta) bool {
	if meta == nil {
		return false
	}
	return strings.TrimSpace(meta.ID) != "" ||
		strings.TrimSpace(meta.Name) != "" ||
		strings.TrimSpace(meta.Description) != "" ||
		strings.TrimSpace(meta.Version) != "" ||
		strings.TrimSpace(meta.Author) != "" ||
		len(meta.Tags) > 0
}

func validateSkillID(id string) error {
	id = strings.TrimSpace(id)
	if id == "" {
		return fmt.Errorf("skill ID is required")
	}
	if !skillIDPattern.MatchString(id) {
		return fmt.Errorf("invalid skill ID %q; use letters, numbers, dots, underscores, or hyphens, starting with a letter or number", id)
	}
	return nil
}

func validateArchivePath(name string) (string, error) {
	if strings.TrimSpace(name) == "" {
		return "", fmt.Errorf("empty archive entry path")
	}
	if strings.Contains(name, `\`) {
		return "", fmt.Errorf("unsafe archive entry %q: Windows path separators are not allowed", name)
	}
	if strings.Contains(name, ":") {
		return "", fmt.Errorf("unsafe archive entry %q: drive-letter paths are not allowed", name)
	}
	if path.IsAbs(name) {
		return "", fmt.Errorf("unsafe archive entry %q: absolute paths are not allowed", name)
	}

	for _, part := range strings.Split(name, "/") {
		if part == ".." {
			return "", fmt.Errorf("unsafe archive entry %q: path traversal is not allowed", name)
		}
	}
	clean := path.Clean(name)
	if clean == "." {
		return "", fmt.Errorf("unsafe archive entry %q: path traversal is not allowed", name)
	}
	return clean, nil
}

func skillPathWithinDir(skillDir, rel string) (string, error) {
	clean, err := validateArchivePath(rel)
	if err != nil {
		return "", err
	}

	root, err := filepath.Abs(skillDir)
	if err != nil {
		return "", fmt.Errorf("resolve skill directory: %w", err)
	}
	target := filepath.Join(root, filepath.FromSlash(clean))
	relToRoot, err := filepath.Rel(root, target)
	if err != nil {
		return "", fmt.Errorf("resolve skill file path: %w", err)
	}
	if relToRoot == ".." || strings.HasPrefix(relToRoot, ".."+string(filepath.Separator)) || filepath.IsAbs(relToRoot) {
		return "", fmt.Errorf("unsafe archive entry %q: path escapes skill directory", rel)
	}
	return target, nil
}

func (sm *SkillManager) importAutoArchive(filePath string, meta *SkillMeta, force bool) (*AutoImportResult, error) {
	discoveries, err := discoverSkillsInArchive(filePath)
	if err != nil {
		return nil, err
	}

	return sm.importAutoDiscovered(discoveries, meta, force)
}

func (sm *SkillManager) importAutoDirectory(dirPath string, meta *SkillMeta, force bool) (*AutoImportResult, error) {
	discoveries, err := discoverSkillsInDirectory(dirPath)
	if err != nil {
		return nil, err
	}

	return sm.importAutoDiscovered(discoveries, meta, force)
}

func (sm *SkillManager) importAutoDiscovered(discoveries []DiscoveredSkill, meta *SkillMeta, force bool) (*AutoImportResult, error) {
	if len(discoveries) == 1 {
		skill, needsMeta, err := sm.importDiscoveredSingle(discoveries[0], meta, force)
		if err != nil {
			return nil, err
		}
		if needsMeta {
			return &AutoImportResult{NeedsMeta: true}, nil
		}
		return &AutoImportResult{Skill: &skill}, nil
	}

	if hasSkillMeta(meta) {
		return nil, fmt.Errorf("metadata overrides are only supported when the source contains a single skill")
	}

	resp, err := sm.importDiscovered(discoveries, force)
	if err != nil {
		return nil, err
	}
	return &AutoImportResult{Batch: resp}, nil
}

func (sm *SkillManager) importDiscoveredSingle(discovery DiscoveredSkill, overrideMeta *SkillMeta, force bool) (Skill, bool, error) {
	skillMDData, extraFiles, err := discoveredSkillFiles(discovery)
	if err != nil {
		return Skill{}, false, err
	}

	parsed := parseSkillFrontMatter(skillMDData)
	meta := completeSkillMeta(skillMDData, parsed, overrideMeta, discovery.RelRoot)
	if !meta.HasRequiredFields() {
		return Skill{}, true, nil
	}

	return sm.saveImportedSkill(meta, extraFiles, skillMDData, force)
}

// DownloadToFile downloads rawURL into a temporary file and returns its path.
// The caller owns removal of the returned file.
func DownloadToFile(rawURL string) (string, error) {
	return DownloadToFileWithLimit(context.Background(), rawURL, MaxSkillImportBytes)
}

// DownloadToFileWithLimit downloads rawURL into a temporary file and returns
// its path. If maxBytes is positive, downloads larger than maxBytes fail and
// the temporary file is removed. The caller owns removal of the returned file.
func DownloadToFileWithLimit(ctx context.Context, rawURL string, maxBytes int64) (string, error) {
	ctx, cancel := context.WithTimeout(ctx, 120*time.Second)
	defer cancel()

	req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, http.NoBody)
	if err != nil {
		return "", fmt.Errorf("failed to create request: %w", err)
	}

	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		return "", fmt.Errorf("failed to download %q: %w", rawURL, err)
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		return "", fmt.Errorf("download failed: HTTP %d for %q", resp.StatusCode, rawURL)
	}
	if maxBytes > 0 && resp.ContentLength > maxBytes {
		return "", fmt.Errorf("downloaded content exceeds %d bytes", maxBytes)
	}

	ext := filepath.Ext(req.URL.Path)
	if ext == "" {
		ct := resp.Header.Get("Content-Type")
		switch {
		case strings.Contains(ct, "zip"):
			ext = ".zip"
		case strings.Contains(ct, "gzip") || strings.Contains(ct, "tar"):
			ext = ".tar.gz"
		case strings.Contains(ct, "markdown") || strings.Contains(ct, "text/plain"):
			ext = ".md"
		default:
			ext = ".md"
		}
	}

	tmpFile, err := os.CreateTemp(os.TempDir(), "agentteam-skill-download-*"+ext)
	if err != nil {
		return "", fmt.Errorf("failed to create temp file: %w", err)
	}
	tmpPath := tmpFile.Name()

	reader := io.Reader(resp.Body)
	if maxBytes > 0 {
		reader = io.LimitReader(resp.Body, maxBytes+1)
	}
	written, err := io.Copy(tmpFile, reader)
	if err != nil {
		tmpFile.Close()
		os.Remove(tmpPath)
		return "", fmt.Errorf("failed to save downloaded content: %w", err)
	}
	if maxBytes > 0 && written > maxBytes {
		tmpFile.Close()
		os.Remove(tmpPath)
		return "", fmt.Errorf("downloaded content exceeds %d bytes", maxBytes)
	}
	if err := tmpFile.Close(); err != nil {
		os.Remove(tmpPath)
		return "", fmt.Errorf("failed to finalize downloaded content: %w", err)
	}

	slog.Info("skill: downloaded file", "url", rawURL, "path", tmpPath)
	return tmpPath, nil
}

func (sm *SkillManager) importMarkdown(filePath string, overrideMeta *SkillMeta, force bool) (Skill, bool, error) {
	data, err := readFileLimited(filePath, MaxSkillMarkdownBytes)
	if err != nil {
		return Skill{}, false, fmt.Errorf("failed to read file %q: %w", filePath, err)
	}

	parsed := parseSkillFrontMatter(data)
	meta := completeSkillMeta(data, parsed, overrideMeta, filePath)

	if !meta.HasRequiredFields() {
		return Skill{}, true, nil
	}

	return sm.saveImportedSkill(meta, nil, data, force)
}

func (sm *SkillManager) importZip(filePath string, overrideMeta *SkillMeta, force bool) (Skill, bool, error) {
	r, err := zip.OpenReader(filePath)
	if err != nil {
		return Skill{}, false, fmt.Errorf("failed to open ZIP archive %q: %w\nMake sure the file is a valid ZIP archive", filePath, err)
	}
	defer r.Close()

	return sm.importArchiveEntries(zipEntryReader(r.File), filepath.Base(filePath), "ZIP", overrideMeta, force)
}

func (sm *SkillManager) importTarGz(filePath string, overrideMeta *SkillMeta, force bool) (Skill, bool, error) {
	f, err := os.Open(filePath)
	if err != nil {
		return Skill{}, false, fmt.Errorf("failed to open archive %q: %w", filePath, err)
	}
	defer f.Close()

	gz, err := gzip.NewReader(f)
	if err != nil {
		return Skill{}, false, fmt.Errorf("failed to decompress gzip archive %q: %w\nMake sure the file is a valid .tar.gz or .tgz archive", filePath, err)
	}
	defer gz.Close()

	return sm.importTarReader(tar.NewReader(gz), filePath, overrideMeta, force)
}

func (sm *SkillManager) importTar(filePath string, overrideMeta *SkillMeta, force bool) (Skill, bool, error) {
	f, err := os.Open(filePath)
	if err != nil {
		return Skill{}, false, fmt.Errorf("failed to open archive %q: %w", filePath, err)
	}
	defer f.Close()

	return sm.importTarReader(tar.NewReader(f), filePath, overrideMeta, force)
}

func (sm *SkillManager) importTarReader(tr *tar.Reader, archivePath string, overrideMeta *SkillMeta, force bool) (Skill, bool, error) {
	return sm.importArchiveEntries(tarEntryReader(tr), filepath.Base(archivePath), "archive", overrideMeta, force)
}

func (sm *SkillManager) importArchiveEntries(readEntries entryReaderFunc, archiveName, archiveType string, overrideMeta *SkillMeta, force bool) (Skill, bool, error) {
	var skillMDData []byte
	var extraFiles []archiveEntry

	var readErr error
	readEntries(func(e archiveEntry, err error) bool {
		if err != nil {
			readErr = err
			return false
		}
		base := path.Base(e.name)
		if strings.EqualFold(base, "SKILL.md") {
			skillMDData = e.data
			return true
		}
		extraFiles = append(extraFiles, archiveEntry{name: base, data: e.data})
		return true
	})
	if readErr != nil {
		return Skill{}, false, readErr
	}

	if skillMDData == nil {
		return Skill{}, false, fmt.Errorf(
			"no SKILL.md found in %s %q\n"+
				"Archives must contain a SKILL.md file (case-insensitive) at any level.\n"+
				"Example archive structure:\n"+
				"  my-skill/\n"+
				"    SKILL.md       <- required\n"+
				"    examples/\n"+
				"      ...",
			archiveType,
			archiveName,
		)
	}

	parsed := parseSkillFrontMatter(skillMDData)
	meta := completeSkillMeta(skillMDData, parsed, overrideMeta, archiveName)
	if !meta.HasRequiredFields() {
		return Skill{}, true, nil
	}

	return sm.saveImportedSkill(meta, extraFiles, skillMDData, force)
}

// saveImportedSkill writes the skill content to disk and registers it.
// The layout is:
//
//	skills/{id}/SKILL.md          <- main skill content
//	skills/{id}/<extra files...>  <- additional files from archives
//
// When force is true and a skill with the same ID already exists, the old
// skill (including its content directory) is removed before saving.
// When force is false and a skill with the same ID exists, ErrSkillExists
// is returned.
func (sm *SkillManager) saveImportedSkill(meta *SkillMeta, extraFiles []archiveEntry, content []byte, force bool) (Skill, bool, error) {
	meta.ID = strings.TrimSpace(meta.ID)
	meta.Name = strings.TrimSpace(meta.Name)
	if err := validateSkillID(meta.ID); err != nil {
		return Skill{}, false, err
	}

	sm.mgr.mu.RLock()
	_, exists := sm.mgr.skills[meta.ID]
	sm.mgr.mu.RUnlock()

	if exists && !force {
		return Skill{}, false, fmt.Errorf("%w: %q", ErrSkillExists, meta.ID)
	}

	if exists && force {
		if err := sm.forceDelete(meta.ID); err != nil {
			return Skill{}, false, fmt.Errorf("failed to remove existing skill %q: %w", meta.ID, err)
		}
	}

	skillDir := filepath.Join(sm.Dir(), meta.ID)
	if err := os.MkdirAll(skillDir, 0755); err != nil {
		return Skill{}, false, fmt.Errorf("failed to create skill directory: %w", err)
	}

	contentPath := filepath.Join(skillDir, "SKILL.md")
	if err := os.WriteFile(contentPath, content, 0644); err != nil {
		return Skill{}, false, fmt.Errorf("failed to write SKILL.md: %w", err)
	}

	for _, ef := range extraFiles {
		efPath, err := skillPathWithinDir(skillDir, ef.name)
		if err != nil {
			os.RemoveAll(skillDir)
			return Skill{}, false, err
		}
		if err := os.MkdirAll(filepath.Dir(efPath), 0755); err != nil {
			os.RemoveAll(skillDir)
			return Skill{}, false, fmt.Errorf("failed to create extra file directory %q: %w", ef.name, err)
		}
		if err := os.WriteFile(efPath, ef.data, 0644); err != nil {
			os.RemoveAll(skillDir)
			return Skill{}, false, fmt.Errorf("failed to write extra file %q: %w", ef.name, err)
		}
	}

	now := time.Now()
	s := Skill{
		ID:          meta.ID,
		Name:        meta.Name,
		Description: meta.Description,
		Version:     meta.Version,
		Author:      meta.Author,
		Tags:        meta.Tags,
		FileName:    filepath.Join(meta.ID, "SKILL.md"),
		CreatedAt:   &now,
	}

	if err := sm.Add(s); err != nil {
		os.RemoveAll(skillDir)
		return Skill{}, false, err
	}

	return s, false, nil
}

// forceDelete removes a skill and its content directory without holding the lock.
func (sm *SkillManager) forceDelete(id string) error {
	sm.mgr.mu.Lock()
	_, exists := sm.mgr.skills[id]
	if !exists {
		sm.mgr.mu.Unlock()
		return nil
	}
	delete(sm.mgr.skills, id)
	if err := sm.saveSkillsLocked(); err != nil {
		sm.mgr.mu.Unlock()
		return err
	}
	sm.mgr.mu.Unlock()

	skillDir := filepath.Join(sm.Dir(), id)
	return os.RemoveAll(skillDir)
}

func (sm *SkillManager) saveSkillsLocked() error {
	sf := skillsFile{
		Skills: make([]Skill, 0, len(sm.mgr.skills)),
	}
	for _, s := range sm.mgr.skills {
		sf.Skills = append(sf.Skills, *s)
	}
	return saveYAMLFile(sf, sm.mgr.filePath("skills.yaml"), sm.mgr)
}

// reloadSkills loads skills.yaml into the Manager's skills map.
func reloadSkills(m *Manager) {
	sf := &skillsFile{}
	if err := loadYAMLFile(m.filePath("skills.yaml"), sf); err != nil {
		slog.Debug("profile: load skills", "error", err)
	}
	sm := make(map[string]*Skill, len(sf.Skills))
	for i := range sf.Skills {
		s := sf.Skills[i]
		sm[s.ID] = &s
	}
	m.skills = sm
}

// ReloadSkills reloads only skills.yaml from disk.
func (m *Manager) ReloadSkills() {
	m.mu.Lock()
	defer m.mu.Unlock()
	reloadSkills(m)
	slog.Info("profile watcher: skills reloaded", "count", len(m.skills))
}

// ---------------------------------------------------------------------------
// Front-matter parsing
// ---------------------------------------------------------------------------

// parseSkillFrontMatter extracts YAML front-matter from a markdown file.
// Front-matter is delimited by --- lines at the beginning of the file.
func parseSkillFrontMatter(data []byte) *SkillMeta {
	content := string(data)
	content = strings.TrimSpace(content)
	if !strings.HasPrefix(content, "---") {
		return &SkillMeta{}
	}

	rest := content[3:]
	end := strings.Index(rest, "\n---")
	if end < 0 {
		return &SkillMeta{}
	}

	fmData := rest[:end]
	meta := &SkillMeta{}
	if err := yaml.Unmarshal([]byte(fmData), meta); err != nil {
		return &SkillMeta{}
	}
	return meta
}

// mergeMeta merges parsed front-matter with user-provided overrides.
// Override fields take precedence when non-empty.
func mergeMeta(parsed *SkillMeta, override *SkillMeta) *SkillMeta {
	if override == nil {
		return parsed
	}

	result := *parsed
	if strings.TrimSpace(override.ID) != "" {
		result.ID = override.ID
	}
	if strings.TrimSpace(override.Name) != "" {
		result.Name = override.Name
	}
	if strings.TrimSpace(override.Description) != "" {
		result.Description = override.Description
	}
	if strings.TrimSpace(override.Version) != "" {
		result.Version = override.Version
	}
	if strings.TrimSpace(override.Author) != "" {
		result.Author = override.Author
	}
	if len(override.Tags) > 0 {
		result.Tags = override.Tags
	}
	return &result
}

func completeSkillMeta(data []byte, parsed *SkillMeta, override *SkillMeta, fallback string) *SkillMeta {
	meta := mergeMeta(parsed, override)
	if meta == nil {
		meta = &SkillMeta{}
	}

	title := markdownTitle(data)
	if strings.TrimSpace(meta.Name) == "" {
		if title != "" {
			meta.Name = title
		} else {
			meta.Name = humanizeSkillName(fallback)
		}
	}
	if strings.TrimSpace(meta.ID) == "" {
		meta.ID = skillIDFromText(meta.Name)
		if meta.ID == "skill" && strings.TrimSpace(fallback) != "" {
			meta.ID = skillIDFromText(humanizeSkillName(fallback))
		}
	}
	if strings.TrimSpace(meta.Description) == "" {
		meta.Description = markdownDescription(data, meta.Name)
	}
	return meta
}

func markdownTitle(data []byte) string {
	for _, line := range strings.Split(string(StripFrontMatter(data)), "\n") {
		if title, ok := markdownHeadingText(line); ok {
			return title
		}
	}
	return ""
}

func markdownDescription(data []byte, title string) string {
	var parts []string
	inFence := false
	for _, line := range strings.Split(string(StripFrontMatter(data)), "\n") {
		trimmed := strings.TrimSpace(line)
		if strings.HasPrefix(trimmed, "```") || strings.HasPrefix(trimmed, "~~~") {
			inFence = !inFence
			continue
		}
		if inFence {
			continue
		}
		if trimmed == "" {
			if len(parts) > 0 {
				break
			}
			continue
		}
		if heading, ok := markdownHeadingText(trimmed); ok {
			if strings.EqualFold(heading, strings.TrimSpace(title)) {
				continue
			}
			if len(parts) > 0 {
				break
			}
			continue
		}
		if strings.HasPrefix(trimmed, "<!--") {
			continue
		}
		parts = append(parts, trimmed)
	}
	if len(parts) == 0 {
		return ""
	}
	return truncateDescription(strings.Join(parts, " "), 240)
}

func markdownHeadingText(line string) (string, bool) {
	trimmed := strings.TrimSpace(line)
	hashes := 0
	for hashes < len(trimmed) && trimmed[hashes] == '#' {
		hashes++
	}
	if hashes == 0 || hashes > 6 || hashes >= len(trimmed) {
		return "", false
	}
	if trimmed[hashes] != ' ' && trimmed[hashes] != '\t' {
		return "", false
	}
	title := strings.TrimSpace(trimmed[hashes:])
	title = strings.TrimSpace(strings.TrimRight(title, "#"))
	return title, title != ""
}

func humanizeSkillName(source string) string {
	base := strings.TrimSpace(filepath.ToSlash(source))
	if base != "" {
		base = path.Base(base)
		base = trimSkillSourceExtension(base)
	}
	base = regexp.MustCompile(`[._-]+`).ReplaceAllString(base, " ")
	words := strings.Fields(base)
	if len(words) == 0 {
		return "Skill"
	}
	for i, word := range words {
		words[i] = capitalizeSkillWord(word)
	}
	return strings.Join(words, " ")
}

func trimSkillSourceExtension(name string) string {
	lower := strings.ToLower(name)
	for _, ext := range []string{".tar.gz", ".markdown", ".md", ".zip", ".tgz", ".tar", ".gz"} {
		if strings.HasSuffix(lower, ext) {
			return name[:len(name)-len(ext)]
		}
	}
	return name
}

func capitalizeSkillWord(word string) string {
	runes := []rune(word)
	if len(runes) == 0 {
		return word
	}
	runes[0] = unicode.ToUpper(runes[0])
	return string(runes)
}

func skillIDFromText(text string) string {
	base := strings.ToLower(strings.TrimSpace(text))
	base = regexp.MustCompile(`[^a-z0-9._-]+`).ReplaceAllString(base, "-")
	base = strings.Trim(base, "._-")
	if base == "" {
		return "skill"
	}
	return base
}

func truncateDescription(description string, maxRunes int) string {
	if maxRunes <= 0 {
		return ""
	}
	runes := []rune(description)
	if len(runes) <= maxRunes {
		return description
	}
	if maxRunes <= 3 {
		return string(runes[:maxRunes])
	}
	return string(runes[:maxRunes-3]) + "..."
}

// StripFrontMatter removes the YAML front-matter block from markdown content
// and returns only the body content.
func StripFrontMatter(data []byte) []byte {
	content := bytes.TrimSpace(data)
	if !bytes.HasPrefix(content, []byte("---")) {
		return data
	}
	rest := content[3:]
	end := bytes.Index(rest, []byte("\n---"))
	if end < 0 {
		return data
	}
	body := rest[end+4:]
	return bytes.TrimSpace(body)
}

// ---------------------------------------------------------------------------
// Batch import: multi-skill discovery from archives
// ---------------------------------------------------------------------------

// DiscoveredSkill represents a single skill found inside an archive.
type DiscoveredSkill struct {
	// RelRoot is the archive-relative directory that contains SKILL.md.
	// For a file at "superpowers-main/skills/debugging/SKILL.md", this is
	// "superpowers-main/skills/debugging".
	RelRoot string

	// Files maps relative paths (under RelRoot) to their content.
	// Always contains "SKILL.md".
	Files map[string][]byte
}

// BatchImportResult records the outcome of a single skill during batch import.
type BatchImportResult struct {
	ID     string `json:"id"`
	Name   string `json:"name"`
	Status string `json:"status"` // "created", "skipped_exists", "overwritten"
}

// BatchImportResponse is the aggregate result returned by batch import.
type BatchImportResponse struct {
	Imported []BatchImportResult `json:"imported"`
	Created  int                 `json:"created"`
	Skipped  int                 `json:"skipped"`
	Errors   []string            `json:"errors,omitempty"`
}

func discoverSkillsInArchive(filePath string) ([]DiscoveredSkill, error) {
	switch strings.ToLower(filepath.Ext(filePath)) {
	case ".zip":
		return discoverSkillsInZip(filePath)
	case ".tar":
		return discoverSkillsInTar(filePath)
	case ".gz", ".tgz":
		return discoverSkillsInTarGz(filePath)
	default:
		return nil, fmt.Errorf("unsupported archive type %q; supported formats are .zip, .tar, .tar.gz, and .tgz", filepath.Ext(filePath))
	}
}

func discoverSkillsInDirectory(dirPath string) ([]DiscoveredSkill, error) {
	root, err := filepath.Abs(dirPath)
	if err != nil {
		return nil, fmt.Errorf("resolve directory %q: %w", dirPath, err)
	}

	return discoverSkillsFromEntries(directoryEntryReader(root))
}

// discoverSkillsInZip walks a ZIP archive and returns all directories that
// contain a SKILL.md file. Each such directory is treated as a separate skill.
// Directory structure within each skill is preserved.
func discoverSkillsInZip(filePath string) ([]DiscoveredSkill, error) {
	r, err := zip.OpenReader(filePath)
	if err != nil {
		return nil, fmt.Errorf("failed to open ZIP archive: %w", err)
	}
	defer r.Close()

	return discoverSkillsFromEntries(zipEntryReader(r.File))
}

// discoverSkillsInTar walks a tar archive and returns all directories
// containing a SKILL.md file.
func discoverSkillsInTar(filePath string) ([]DiscoveredSkill, error) {
	f, err := os.Open(filePath)
	if err != nil {
		return nil, fmt.Errorf("failed to open archive: %w", err)
	}
	defer f.Close()

	return discoverSkillsFromEntries(tarEntryReader(tar.NewReader(f)))
}

// discoverSkillsInTarGz walks a tar.gz archive and returns all directories
// containing a SKILL.md file.
func discoverSkillsInTarGz(filePath string) ([]DiscoveredSkill, error) {
	f, err := os.Open(filePath)
	if err != nil {
		return nil, fmt.Errorf("failed to open archive: %w", err)
	}
	defer f.Close()

	gz, err := gzip.NewReader(f)
	if err != nil {
		return nil, fmt.Errorf("failed to decompress gzip: %w", err)
	}
	defer gz.Close()

	return discoverSkillsFromEntries(tarEntryReader(tar.NewReader(gz)))
}

// archiveEntry is a generic representation of a file entry inside an archive.
type archiveEntry struct {
	name string // full path within archive
	data []byte // file content
}

// entryReaderFunc yields archive entries or an error.
type entryReaderFunc func(yield func(archiveEntry, error) bool)

func readAllLimited(r io.Reader, maxBytes int64) ([]byte, error) {
	var buf bytes.Buffer
	written, err := io.Copy(&buf, io.LimitReader(r, maxBytes+1))
	if err != nil {
		return nil, err
	}
	if written > maxBytes {
		return nil, fmt.Errorf("content exceeds %d bytes", maxBytes)
	}
	return buf.Bytes(), nil
}

func unsafeSkillArchiveEntryError(entryPath, entryType, target string) error {
	if entryType == "symlink" {
		if target != "" {
			return fmt.Errorf("unsafe archive entry %q: symlink entries are not allowed (target %q); skill imports only allow regular files and directories. Repack the skill with linked content as regular files/directories, or remove special files before importing", entryPath, target)
		}
		return fmt.Errorf("unsafe archive entry %q: symlink entries are not allowed; skill imports only allow regular files and directories. Repack the skill with linked content as regular files/directories, or remove special files before importing", entryPath)
	}
	return fmt.Errorf("unsafe archive entry %q: special file entry (%s) is not allowed; skill imports only allow regular files and directories. Repack the skill with linked content as regular files/directories, or remove special files before importing", entryPath, entryType)
}

func zipSpecialEntryDetails(mode os.FileMode) string {
	switch {
	case mode&os.ModeSymlink != 0:
		return "symlink"
	case mode&os.ModeSocket != 0:
		return "socket"
	case mode&os.ModeNamedPipe != 0:
		return "named pipe"
	case mode&os.ModeDevice != 0:
		return "device"
	default:
		return "special file"
	}
}

func tarSpecialEntryDetails(hdr *tar.Header) (string, string) {
	switch hdr.Typeflag {
	case tar.TypeSymlink:
		return "symlink", hdr.Linkname
	case tar.TypeLink:
		return "hard link", hdr.Linkname
	case tar.TypeChar, tar.TypeBlock:
		return "device", ""
	case tar.TypeFifo:
		return "fifo", ""
	default:
		return "special file", ""
	}
}

func directorySpecialEntryDetails(entry os.DirEntry, path string) (string, string) {
	if entry.Type()&os.ModeSymlink != 0 {
		target, err := os.Readlink(path)
		if err != nil {
			target = fmt.Sprintf("<readlink failed: %v>", err)
		}
		return "symlink", target
	}
	mode := entry.Type()
	switch {
	case mode&os.ModeSocket != 0:
		return "socket", ""
	case mode&os.ModeNamedPipe != 0:
		return "named pipe", ""
	case mode&os.ModeDevice != 0:
		return "device", ""
	default:
		return "special file", ""
	}
}

func readZipSymlinkTarget(f *zip.File, maxBytes int64) string {
	rc, err := f.Open()
	if err != nil {
		return fmt.Sprintf("<readlink failed: %v>", err)
	}
	defer rc.Close()
	target, err := readAllLimited(rc, maxBytes)
	if err != nil {
		return fmt.Sprintf("<readlink failed: %v>", err)
	}
	return string(target)
}

func readFileLimited(filePath string, maxBytes int64) ([]byte, error) {
	f, err := os.Open(filePath)
	if err != nil {
		return nil, err
	}
	defer f.Close()

	return readAllLimited(f, maxBytes)
}

func readFilePrefix(filePath string, maxBytes int64) ([]byte, bool, error) {
	f, err := os.Open(filePath)
	if err != nil {
		return nil, false, err
	}
	defer f.Close()

	var buf bytes.Buffer
	written, err := io.Copy(&buf, io.LimitReader(f, maxBytes+1))
	if err != nil {
		return nil, false, err
	}
	data := buf.Bytes()
	if written <= maxBytes {
		return data, false, nil
	}
	return data[:maxBytes], true, nil
}

// zipEntryReader converts zip.File slice to an entryReaderFunc.
func zipEntryReader(files []*zip.File) entryReaderFunc {
	return zipEntryReaderWithLimits(files, defaultSkillArchiveLimits)
}

func zipEntryReaderWithLimits(files []*zip.File, limits skillArchiveLimits) entryReaderFunc {
	limits = limits.withDefaults()
	return func(yield func(archiveEntry, error) bool) {
		var extractedBytes int64
		if len(files) > limits.maxEntries {
			yield(archiveEntry{}, fmt.Errorf("archive contains too many entries: %d exceeds %d", len(files), limits.maxEntries))
			return
		}
		for _, f := range files {
			name, err := validateArchivePath(f.Name)
			if err != nil {
				yield(archiveEntry{}, err)
				return
			}

			mode := f.FileInfo().Mode()
			if mode.IsDir() {
				continue
			}
			if mode&os.ModeType != 0 {
				entryType := zipSpecialEntryDetails(mode)
				target := ""
				if entryType == "symlink" {
					target = readZipSymlinkTarget(f, limits.maxEntryBytes)
				}
				yield(archiveEntry{}, unsafeSkillArchiveEntryError(f.Name, entryType, target))
				return
			}
			if f.UncompressedSize64 > uint64(limits.maxEntryBytes) {
				yield(archiveEntry{}, fmt.Errorf("archive entry %q exceeds %d bytes", f.Name, limits.maxEntryBytes))
				return
			}
			if extractedBytes > limits.maxExtractedBytes-int64(f.UncompressedSize64) {
				yield(archiveEntry{}, fmt.Errorf("archive extracted content exceeds %d bytes", limits.maxExtractedBytes))
				return
			}

			rc, err := f.Open()
			if err != nil {
				if !yield(archiveEntry{}, fmt.Errorf("read %s: %w", f.Name, err)) {
					return
				}
				continue
			}
			data, err := readAllLimited(rc, limits.maxEntryBytes)
			rc.Close()
			if err != nil {
				if !yield(archiveEntry{}, fmt.Errorf("read %s: %w", f.Name, err)) {
					return
				}
				continue
			}
			actualSize := int64(len(data))
			if extractedBytes > limits.maxExtractedBytes-actualSize {
				yield(archiveEntry{}, fmt.Errorf("archive extracted content exceeds %d bytes", limits.maxExtractedBytes))
				return
			}
			extractedBytes += actualSize
			if !yield(archiveEntry{name: name, data: data}, nil) {
				return
			}
		}
	}
}

// tarEntryReader converts a tar.Reader to an entryReaderFunc.
func tarEntryReader(tr *tar.Reader) entryReaderFunc {
	return tarEntryReaderWithLimits(tr, defaultSkillArchiveLimits)
}

func tarEntryReaderWithLimits(tr *tar.Reader, limits skillArchiveLimits) entryReaderFunc {
	limits = limits.withDefaults()
	return func(yield func(archiveEntry, error) bool) {
		var entryCount int
		var extractedBytes int64
		for {
			hdr, err := tr.Next()
			if err == io.EOF {
				return
			}
			if err != nil {
				yield(archiveEntry{}, fmt.Errorf("failed to read tar archive: %w", err))
				return
			}

			entryCount++
			if entryCount > limits.maxEntries {
				yield(archiveEntry{}, fmt.Errorf("archive contains too many entries: %d exceeds %d", entryCount, limits.maxEntries))
				return
			}

			name, err := validateArchivePath(hdr.Name)
			if err != nil {
				yield(archiveEntry{}, err)
				return
			}
			if hdr.Typeflag == tar.TypeDir {
				continue
			}

			switch hdr.Typeflag {
			case tar.TypeReg, tar.TypeRegA:
			default:
				entryType, target := tarSpecialEntryDetails(hdr)
				yield(archiveEntry{}, unsafeSkillArchiveEntryError(hdr.Name, entryType, target))
				return
			}
			if hdr.Size < 0 {
				yield(archiveEntry{}, fmt.Errorf("archive entry %q has invalid size", hdr.Name))
				return
			}
			if hdr.Size > limits.maxEntryBytes {
				yield(archiveEntry{}, fmt.Errorf("archive entry %q exceeds %d bytes", hdr.Name, limits.maxEntryBytes))
				return
			}
			if extractedBytes > limits.maxExtractedBytes-hdr.Size {
				yield(archiveEntry{}, fmt.Errorf("archive extracted content exceeds %d bytes", limits.maxExtractedBytes))
				return
			}
			extractedBytes += hdr.Size

			data, err := readAllLimited(tr, limits.maxEntryBytes)
			if err != nil {
				if !yield(archiveEntry{}, fmt.Errorf("read %s: %w", hdr.Name, err)) {
					return
				}
				continue
			}
			if !yield(archiveEntry{name: name, data: data}, nil) {
				return
			}
		}
	}
}

func directoryEntryReader(root string) entryReaderFunc {
	return directoryEntryReaderWithLimits(root, defaultSkillArchiveLimits)
}

func directoryEntryReaderWithLimits(root string, limits skillArchiveLimits) entryReaderFunc {
	limits = limits.withDefaults()
	return func(yield func(archiveEntry, error) bool) {
		var entryCount int
		var extractedBytes int64
		err := filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error {
			if err != nil {
				if !yield(archiveEntry{}, fmt.Errorf("walk %s: %w", path, err)) {
					return filepath.SkipAll
				}
				return nil
			}
			if entry.IsDir() {
				return nil
			}
			entryCount++
			if entryCount > limits.maxEntries {
				if !yield(archiveEntry{}, fmt.Errorf("archive contains too many entries: %d exceeds %d", entryCount, limits.maxEntries)) {
					return filepath.SkipAll
				}
				return filepath.SkipAll
			}
			if entry.Type()&os.ModeType != 0 {
				entryType, target := directorySpecialEntryDetails(entry, path)
				if !yield(archiveEntry{}, unsafeSkillArchiveEntryError(path, entryType, target)) {
					return filepath.SkipAll
				}
				return filepath.SkipAll
			}

			info, err := entry.Info()
			if err != nil {
				if !yield(archiveEntry{}, fmt.Errorf("stat %s: %w", path, err)) {
					return filepath.SkipAll
				}
				return nil
			}
			if info.Size() > limits.maxEntryBytes {
				if !yield(archiveEntry{}, fmt.Errorf("archive entry %q exceeds %d bytes", path, limits.maxEntryBytes)) {
					return filepath.SkipAll
				}
				return filepath.SkipAll
			}
			if extractedBytes > limits.maxExtractedBytes-info.Size() {
				if !yield(archiveEntry{}, fmt.Errorf("archive extracted content exceeds %d bytes", limits.maxExtractedBytes)) {
					return filepath.SkipAll
				}
				return filepath.SkipAll
			}
			rel, err := filepath.Rel(root, path)
			if err != nil {
				if !yield(archiveEntry{}, fmt.Errorf("rel %s: %w", path, err)) {
					return filepath.SkipAll
				}
				return nil
			}
			name, err := validateArchivePath(filepath.ToSlash(rel))
			if err != nil {
				if !yield(archiveEntry{}, err) {
					return filepath.SkipAll
				}
				return filepath.SkipAll
			}

			file, err := os.Open(path)
			if err != nil {
				if !yield(archiveEntry{}, fmt.Errorf("read %s: %w", path, err)) {
					return filepath.SkipAll
				}
				return nil
			}
			data, readErr := readAllLimited(file, limits.maxEntryBytes)
			closeErr := file.Close()
			if readErr != nil {
				if !yield(archiveEntry{}, fmt.Errorf("read %s: %w", path, readErr)) {
					return filepath.SkipAll
				}
				return nil
			}
			if closeErr != nil {
				if !yield(archiveEntry{}, fmt.Errorf("read %s: %w", path, closeErr)) {
					return filepath.SkipAll
				}
				return nil
			}
			actualSize := int64(len(data))
			if extractedBytes > limits.maxExtractedBytes-actualSize {
				if !yield(archiveEntry{}, fmt.Errorf("archive extracted content exceeds %d bytes", limits.maxExtractedBytes)) {
					return filepath.SkipAll
				}
				return filepath.SkipAll
			}
			extractedBytes += actualSize
			if !yield(archiveEntry{name: name, data: data}, nil) {
				return filepath.SkipAll
			}
			return nil
		})
		if err != nil && err != filepath.SkipAll {
			yield(archiveEntry{}, fmt.Errorf("walk directory %s: %w", root, err))
		}
	}
}

// discoverSkillsFromEntries walks archive entries, identifies all directories
// that contain a SKILL.md, and then groups all files under those directories.
// All entries are collected in memory first to support single-pass archive
// readers (like tar.Reader that cannot be rewound).
func discoverSkillsFromEntries(readEntries entryReaderFunc) ([]DiscoveredSkill, error) {
	// Collect all entries in memory so we can process them in a single pass.
	var allEntries []archiveEntry
	skillDirs := make(map[string]bool)

	var readErr error
	readEntries(func(e archiveEntry, err error) bool {
		if err != nil {
			readErr = err
			return false
		}
		name := filepath.ToSlash(e.name)
		allEntries = append(allEntries, e)

		if strings.EqualFold(filepath.Base(name), "SKILL.md") {
			dir := filepath.Dir(name)
			if dir == "." {
				dir = ""
			}
			skillDirs[dir] = true
		}
		return true
	})
	if readErr != nil {
		return nil, readErr
	}

	if len(skillDirs) == 0 {
		return nil, fmt.Errorf("no SKILL.md found in archive; archives must contain at least one directory with a SKILL.md file")
	}

	// Sort skill dirs by length (longest first) so more specific matches win.
	sortedDirs := make([]string, 0, len(skillDirs))
	for d := range skillDirs {
		sortedDirs = append(sortedDirs, d)
	}
	sort.Slice(sortedDirs, func(i, j int) bool {
		return len(sortedDirs[i]) > len(sortedDirs[j])
	})

	// Collect all files belonging to each skill dir.
	skillFiles := make(map[string]map[string][]byte) // skillDir → relPath → data
	for _, sd := range sortedDirs {
		skillFiles[sd] = make(map[string][]byte)
	}

	for _, e := range allEntries {
		name := filepath.ToSlash(e.name)

		// Find the best matching skill directory for this entry.
		for _, sd := range sortedDirs {
			prefix := sd
			if prefix != "" {
				prefix += "/"
			}
			if strings.HasPrefix(name, prefix) || (sd == "" && !strings.Contains(name, "/")) {
				relPath := strings.TrimPrefix(name, prefix)
				if relPath == "" {
					continue
				}
				skillFiles[sd][relPath] = e.data
				break
			}
		}
	}

	var results []DiscoveredSkill
	for _, sd := range sortedDirs {
		files := skillFiles[sd]
		if len(files) == 0 {
			continue
		}
		results = append(results, DiscoveredSkill{
			RelRoot: sd,
			Files:   files,
		})
	}

	return results, nil
}

// skillIDFromDirectory derives a skill ID from the archive directory path.
// It uses the last path component, or "skill" if the path is empty (root).
func skillIDFromDirectory(relRoot string) string {
	if relRoot == "" {
		return "skill"
	}
	base := filepath.Base(relRoot)
	// Sanitize: replace spaces and special chars with hyphens.
	base = regexp.MustCompile(`[^a-zA-Z0-9._-]`).ReplaceAllString(base, "-")
	base = strings.Trim(base, "-")
	if base == "" {
		return "skill"
	}
	return base
}

// skillIDWithHash appends the first 7 hex chars of a SHA-256 hash of the
// content to the base ID, producing e.g. "debugging-a3b2c1f".
func skillIDWithHash(baseID string, content []byte) string {
	h := sha256.Sum256(content)
	return fmt.Sprintf("%s-%s", baseID, hex.EncodeToString(h[:])[:7])
}

// ResolveArchiveURL converts a GitHub/GitLab/Gitee/GitCode repository URL or
// git SSH URL into a direct archive download URL. It supports:
//   - GitHub: https://github.com/user/repo, /tree/branch, .git suffix
//   - GitLab: https://gitlab.com/user/repo, /-/tree/branch
//   - Gitee: https://gitee.com/user/repo, /tree/branch
//   - GitCode: https://gitcode.com/user/repo, /-/tree/branch
//   - Direct ZIP/tar.gz URLs: returned as-is
//   - Git SSH: git@github.com:user/repo.git → HTTPS archive URL
func ResolveArchiveURL(rawURL string) (string, error) {
	// Handle git SSH URLs: git@host:user/repo.git
	if strings.HasPrefix(rawURL, "git@") {
		parts := strings.SplitN(rawURL, ":", 2)
		if len(parts) != 2 {
			return "", fmt.Errorf("invalid git SSH URL %q", rawURL)
		}
		host := parts[0][4:] // strip "git@"
		path := strings.TrimSuffix(parts[1], ".git")
		switch host {
		case "github.com":
			return fmt.Sprintf("https://github.com/%s/archive/refs/heads/main.zip", path), nil
		case "gitlab.com":
			repo := filepath.Base(path)
			return fmt.Sprintf("https://gitlab.com/%s/-/archive/main/%s-main.zip", path, repo), nil
		case "gitee.com":
			return fmt.Sprintf("https://gitee.com/%s/repository/archive/main.zip", path), nil
		case "gitcode.com":
			repo := filepath.Base(path)
			return fmt.Sprintf("https://gitcode.com/%s/-/archive/main/%s-main.zip", path, repo), nil
		default:
			return "", fmt.Errorf("unsupported git host %q; only github.com, gitlab.com, gitee.com, and gitcode.com are supported for SSH URLs", host)
		}
	}

	parsed, err := url.Parse(rawURL)
	if err != nil {
		return "", fmt.Errorf("invalid URL %q: %w", rawURL, err)
	}

	if parsed.Scheme != "http" && parsed.Scheme != "https" {
		return "", fmt.Errorf("unsupported URL scheme %q; only http, https, and git@ SSH URLs are supported", parsed.Scheme)
	}

	host := parsed.Hostname()
	path := strings.TrimSuffix(parsed.Path, ".git")

	switch host {
	case "github.com":
		return resolveGitHubURL(path, parsed.RawQuery)
	case "gitlab.com":
		return resolveGitLabURL(path)
	case "gitee.com":
		return resolveGiteeURL(path)
	case "gitcode.com":
		return resolveGitCodeURL(path)
	default:
		// Allow direct download URLs (e.g. ending in .zip, .tar.gz, .tgz, .tar, .md, .markdown).
		if isDirectDownloadPath(path) {
			return rawURL, nil
		}
		return "", &UnsupportedHostError{Host: host, URL: rawURL}
	}
}

func resolveGitHubURL(path, query string) (string, error) {
	// /user/repo/tree/branch → branch archive
	parts := splitURLPath(path)
	switch len(parts) {
	case 2:
		// /user/repo → main branch archive
		return fmt.Sprintf("https://github.com/%s/%s/archive/refs/heads/main.zip", parts[0], parts[1]), nil
	case 4:
		if parts[2] == "tree" {
			branch := parts[3]
			return fmt.Sprintf("https://github.com/%s/%s/archive/refs/heads/%s.zip", parts[0], parts[1], branch), nil
		}
	}

	// Already an archive URL or other — return as-is with host prefix.
	if query == "" {
		return fmt.Sprintf("https://github.com%s", path), nil
	}
	return fmt.Sprintf("https://github.com%s?%s", path, query), nil
}

func resolveGitLabURL(path string) (string, error) {
	parts := splitURLPath(path)
	if len(parts) < 2 {
		return "", fmt.Errorf("invalid GitLab URL path %q", path)
	}

	// Find if there's a /-/tree/branch pattern.
	for i := 2; i < len(parts)-1; i++ {
		if parts[i] == "-" && i+1 < len(parts) && parts[i+1] == "tree" {
			// Found /-/tree/...
			repoParts := parts[:i]
			branch := "main"
			if i+2 < len(parts) {
				branch = parts[i+2]
			}
			repo := parts[i-1]
			return fmt.Sprintf("https://gitlab.com/%s/-/archive/%s/%s-%s.zip",
				strings.Join(repoParts, "/"), branch, repo, branch), nil
		}
	}

	// /user/repo → main branch archive
	repo := parts[len(parts)-1]
	return fmt.Sprintf("https://gitlab.com/%s/-/archive/main/%s-main.zip",
		strings.Join(parts, "/"), repo), nil
}

func resolveGiteeURL(path string) (string, error) {
	parts := splitURLPath(path)
	if len(parts) < 2 {
		return "", fmt.Errorf("invalid Gitee URL path %q", path)
	}

	// /user/repo/tree/branch → branch archive
	if len(parts) >= 4 && parts[2] == "tree" {
		branch := parts[3]
		return fmt.Sprintf("https://gitee.com/%s/%s/repository/archive/%s.zip",
			parts[0], parts[1], branch), nil
	}

	// /user/repo → main branch archive
	return fmt.Sprintf("https://gitee.com/%s/%s/repository/archive/main.zip",
		parts[0], parts[1]), nil
}

func resolveGitCodeURL(path string) (string, error) {
	parts := splitURLPath(path)
	if len(parts) < 2 {
		return "", fmt.Errorf("invalid GitCode URL path %q", path)
	}

	// Find if there's a /-/tree/branch pattern.
	for i := 2; i < len(parts)-1; i++ {
		if parts[i] == "-" && i+1 < len(parts) && parts[i+1] == "tree" {
			repoParts := parts[:i]
			branch := "main"
			if i+2 < len(parts) {
				branch = parts[i+2]
			}
			repo := parts[i-1]
			return fmt.Sprintf("https://gitcode.com/%s/-/archive/%s/%s-%s.zip",
				strings.Join(repoParts, "/"), branch, repo, branch), nil
		}
	}

	// /user/repo → main branch archive
	repo := parts[len(parts)-1]
	return fmt.Sprintf("https://gitcode.com/%s/-/archive/main/%s-main.zip",
		strings.Join(parts, "/"), repo), nil
}

func splitURLPath(path string) []string {
	trimmed := strings.Trim(path, "/")
	if trimmed == "" {
		return nil
	}
	parts := strings.Split(trimmed, "/")
	result := make([]string, 0, len(parts))
	for _, part := range parts {
		if part == "" {
			continue
		}
		result = append(result, part)
	}
	return result
}

// isDirectDownloadPath returns true if the URL path looks like a direct
// download link to an archive or markdown file.
func isDirectDownloadPath(path string) bool {
	lower := strings.ToLower(path)
	for _, ext := range []string{".zip", ".tar.gz", ".tgz", ".tar", ".md", ".markdown"} {
		if strings.HasSuffix(lower, ext) {
			return true
		}
	}
	return false
}

// ImportBatchFromFile discovers all skills in a ZIP or tar.gz archive and
// imports them. It returns the aggregate result of all imports.
func (sm *SkillManager) ImportBatchFromFile(filePath string, force bool) (*BatchImportResponse, error) {
	discoveries, err := discoverSkillsInArchive(filePath)
	if err != nil {
		return nil, err
	}

	return sm.importDiscovered(discoveries, force)
}

// importDiscovered processes all discovered skills from an archive.
func (sm *SkillManager) importDiscovered(discoveries []DiscoveredSkill, force bool) (*BatchImportResponse, error) {
	resp := &BatchImportResponse{}

	for _, disc := range discoveries {
		skillMDData, extraFiles, err := discoveredSkillFiles(disc)
		if err != nil {
			resp.Errors = append(resp.Errors, fmt.Sprintf("failed to read skill from %q: %v", disc.RelRoot, err))
			continue
		}

		parsed := parseSkillFrontMatter(skillMDData)
		meta := completeSkillMeta(skillMDData, parsed, nil, disc.RelRoot)
		skillID := meta.ID
		skillID = strings.TrimSpace(skillID)
		if err := validateSkillID(skillID); err != nil {
			resp.Errors = append(resp.Errors, fmt.Sprintf("failed to import skill from %q: %v", disc.RelRoot, err))
			continue
		}

		// Check for name conflict — add hash suffix if needed.
		sm.mgr.mu.RLock()
		_, exists := sm.mgr.skills[skillID]
		sm.mgr.mu.RUnlock()

		if exists && strings.TrimSpace(parsed.ID) == "" {
			hashedID := skillIDWithHash(skillID, skillMDData)
			sm.mgr.mu.RLock()
			_, hashExists := sm.mgr.skills[hashedID]
			sm.mgr.mu.RUnlock()
			if !hashExists {
				skillID = hashedID
				exists = false
			}
		}

		if exists && !force {
			resp.Imported = append(resp.Imported, BatchImportResult{
				ID:     skillID,
				Name:   parsed.Name,
				Status: "skipped_exists",
			})
			resp.Skipped++
			continue
		}

		meta.ID = skillID

		status := "created"
		if exists {
			status = "overwritten"
		}

		skill, _, err := sm.saveImportedSkill(meta, extraFiles, skillMDData, force || exists)
		if err != nil {
			resp.Errors = append(resp.Errors, fmt.Sprintf("failed to import skill from %q: %v", disc.RelRoot, err))
			continue
		}

		resp.Imported = append(resp.Imported, BatchImportResult{
			ID:     skill.ID,
			Name:   skill.Name,
			Status: status,
		})
		resp.Created++
	}

	return resp, nil
}

func discoveredSkillFiles(discovery DiscoveredSkill) ([]byte, []archiveEntry, error) {
	var skillMDData []byte
	var extraFiles []archiveEntry

	for name, data := range discovery.Files {
		if strings.EqualFold(filepath.Base(name), "SKILL.md") {
			skillMDData = data
			continue
		}
		extraFiles = append(extraFiles, archiveEntry{name: filepath.ToSlash(name), data: data})
	}

	if skillMDData == nil {
		return nil, nil, fmt.Errorf("no SKILL.md found")
	}

	sort.Slice(extraFiles, func(i, j int) bool {
		return extraFiles[i].name < extraFiles[j].name
	})

	return skillMDData, extraFiles, nil
}
