package fsext

import (
	"errors"
	"log/slog"
	"os"
	"path/filepath"
	"slices"
	"strings"
	"sync"

	"github.com/charlievieth/fastwalk"
	"github.com/charmbracelet/crush/internal/csync"
	"github.com/charmbracelet/crush/internal/home"
	ignore "github.com/sabhiram/go-gitignore"
)

// commonIgnorePatterns contains commonly ignored files and directories
var commonIgnorePatterns = sync.OnceValue(func() ignore.IgnoreParser {
	return ignore.CompileIgnoreLines(
		// Version control
		".git",
		".svn",
		".hg",
		".bzr",

		// IDE and editor files
		".vscode",
		".idea",
		"*.swp",
		"*.swo",
		"*~",
		".DS_Store",
		"Thumbs.db",

		// Build artifacts and dependencies
		"node_modules",
		"target",
		"build",
		"dist",
		"out",
		"bin",
		"obj",
		"*.o",
		"*.so",
		"*.dylib",
		"*.dll",
		"*.exe",

		// Logs and temporary files
		"*.log",
		"*.tmp",
		"*.temp",
		".cache",
		".tmp",

		// Language-specific
		"__pycache__",
		"*.pyc",
		"*.pyo",
		".pytest_cache",
		"vendor",
		"Cargo.lock",
		"package-lock.json",
		"yarn.lock",
		"pnpm-lock.yaml",

		// OS generated files
		".Trash",
		".Spotlight-V100",
		".fseventsd",

		// Crush
		".crush",

		// macOS stuff
		"OrbStack",
		".local",
		".share",
	)
})

var homeIgnore = sync.OnceValue(func() ignore.IgnoreParser {
	home := home.Dir()
	var lines []string
	for _, name := range []string{
		filepath.Join(home, ".gitignore"),
		filepath.Join(home, ".config", "git", "ignore"),
		filepath.Join(home, ".config", "crush", "ignore"),
	} {
		if bts, err := os.ReadFile(name); err == nil {
			lines = append(lines, strings.Split(string(bts), "\n")...)
		}
	}
	return ignore.CompileIgnoreLines(lines...)
})

type directoryLister struct {
	ignores  *csync.Map[string, ignore.IgnoreParser]
	rootPath string
}

func NewDirectoryLister(rootPath string) *directoryLister {
	dl := &directoryLister{
		rootPath: rootPath,
		ignores:  csync.NewMap[string, ignore.IgnoreParser](),
	}
	dl.getIgnore(rootPath)
	return dl
}

// git checks, in order:
// - ./.gitignore, ../.gitignore, etc, until repo root
// ~/.config/git/ignore
// ~/.gitignore
//
// This will do the following:
// - the given ignorePatterns
// - [commonIgnorePatterns]
// - ./.gitignore, ../.gitignore, etc, until dl.rootPath
// - ./.crushignore, ../.crushignore, etc, until dl.rootPath
// ~/.config/git/ignore
// ~/.gitignore
// ~/.config/crush/ignore
func (dl *directoryLister) shouldIgnore(path string, ignorePatterns []string) bool {
	if len(ignorePatterns) > 0 {
		base := filepath.Base(path)
		for _, pattern := range ignorePatterns {
			if matched, err := filepath.Match(pattern, base); err == nil && matched {
				return true
			}
		}
	}

	// Don't apply gitignore rules to the root directory itself
	// In gitignore semantics, patterns don't apply to the repo root
	if path == dl.rootPath {
		return false
	}

	relPath, err := filepath.Rel(dl.rootPath, path)
	if err != nil {
		relPath = path
	}

	if commonIgnorePatterns().MatchesPath(relPath) {
		slog.Debug("ignoring common pattern", "path", relPath)
		return true
	}

	parentDir := filepath.Dir(path)
	ignoreParser := dl.getIgnore(parentDir)
	if ignoreParser.MatchesPath(relPath) {
		slog.Debug("ignoring dir pattern", "path", relPath, "dir", parentDir)
		return true
	}

	// For directories, also check with trailing slash (gitignore convention)
	if ignoreParser.MatchesPath(relPath + "/") {
		slog.Debug("ignoring dir pattern with slash", "path", relPath+"/", "dir", parentDir)
		return true
	}

	if dl.checkParentIgnores(relPath) {
		return true
	}

	if homeIgnore().MatchesPath(relPath) {
		slog.Debug("ignoring home dir pattern", "path", relPath)
		return true
	}

	return false
}

func (dl *directoryLister) checkParentIgnores(path string) bool {
	parent := filepath.Dir(filepath.Dir(path))
	for parent != "." && path != "." {
		if dl.getIgnore(parent).MatchesPath(path) {
			slog.Debug("ingoring parent dir pattern", "path", path, "dir", parent)
			return true
		}
		if parent == dl.rootPath {
			break
		}
		parent = filepath.Dir(parent)
	}
	return false
}

func (dl *directoryLister) getIgnore(path string) ignore.IgnoreParser {
	return dl.ignores.GetOrSet(path, func() ignore.IgnoreParser {
		var lines []string
		for _, ign := range []string{".crushignore", ".gitignore"} {
			name := filepath.Join(path, ign)
			if content, err := os.ReadFile(name); err == nil {
				lines = append(lines, strings.Split(string(content), "\n")...)
			}
		}
		if len(lines) == 0 {
			// Return a no-op parser to avoid nil checks
			return ignore.CompileIgnoreLines()
		}
		return ignore.CompileIgnoreLines(lines...)
	})
}

// ListDirectory lists files and directories in the specified path,
func ListDirectory(initialPath string, ignorePatterns []string, depth, limit int) ([]string, bool, error) {
	found := csync.NewSlice[string]()
	dl := NewDirectoryLister(initialPath)

	slog.Debug("listing directory", "path", initialPath, "depth", depth, "limit", limit, "ignorePatterns", ignorePatterns)

	conf := fastwalk.Config{
		Follow:   true,
		ToSlash:  fastwalk.DefaultToSlash(),
		Sort:     fastwalk.SortDirsFirst,
		MaxDepth: depth,
	}

	err := fastwalk.Walk(&conf, initialPath, func(path string, d os.DirEntry, err error) error {
		if err != nil {
			return nil // Skip files we don't have permission to access
		}

		if dl.shouldIgnore(path, ignorePatterns) {
			if d.IsDir() {
				return filepath.SkipDir
			}
			return nil
		}

		if path != initialPath {
			if d.IsDir() {
				path = path + string(filepath.Separator)
			}
			found.Append(path)
		}

		if limit > 0 && found.Len() >= limit {
			return filepath.SkipAll
		}

		return nil
	})
	if err != nil && !errors.Is(err, filepath.SkipAll) {
		return nil, false, err
	}

	matches, truncated := truncate(slices.Collect(found.Seq()), limit)
	return matches, truncated || errors.Is(err, filepath.SkipAll), nil
}
