package main

import (
	"bufio"
	"errors"
	"fmt"
	"io"
	"io/fs"
	"os"
	"path"
	"path/filepath"
	"regexp"
	"runtime"
	"strings"

	lua "github.com/yuin/gopher-lua"
)

const (
	configFileName = "config.lua"
	filesDirName   = "files"
)

type packageConfig struct {
	targets         map[string]string
	disabled        map[string]bool
	ignore          []string
	compiledIgnores []*regexp.Regexp
}

var errTargetDisabled = errors.New("target disabled for this platform")

func main() {
	if len(os.Args) < 2 {
		usage()
		os.Exit(1)
	}

	args := os.Args[1:]

	var err error
	switch args[0] {
	case "apply":
		err = applyCmd(args[1:])
	case "add":
		err = addCmd(args[1:])
	case "unlink":
		err = unlinkCmd(args[1:])
	case "remove":
		err = removeCmd(args[1:])
	case "status":
		err = statusCmd()
	case "help", "-h", "--help":
		usage()
		return
	default:
		err = fmt.Errorf("unknown command %q", args[0])
	}

	if err != nil {
		fmt.Fprintln(os.Stderr, "sigil:", err)
		os.Exit(1)
	}
}

func usage() {
	fmt.Println("sigil: minimal dotfile symlink manager")
	fmt.Println("usage:")
	fmt.Println("  sigil apply [--prune]")
	fmt.Println("  sigil add <path>")
	fmt.Println("  sigil unlink <package> [--dry-run]")
	fmt.Println("  sigil remove <package> [--dry-run]")
	fmt.Println("  sigil status")
}

type packageFlags struct {
	dryRun bool
}

func parsePackageFlags(args []string) (packageFlags, string, error) {
	flags := packageFlags{}
	var pkg string
	for _, arg := range args {
		if arg == "--dry-run" {
			flags.dryRun = true
			continue
		}
		if strings.HasPrefix(arg, "-") {
			return flags, "", fmt.Errorf("unknown flag %q", arg)
		}
		if pkg != "" {
			return flags, "", errors.New("too many arguments")
		}
		pkg = arg
	}
	if pkg == "" {
		return flags, "", errors.New("missing package")
	}
	return flags, pkg, nil
}

func splitPackageSpec(spec string) (string, string, error) {
	if spec == "" {
		return "", "", errors.New("missing package")
	}

	parts := strings.SplitN(spec, ":", 2)
	pkg := parts[0]
	rel := ""
	if len(parts) == 2 {
		rel = parts[1]
	}

	pkg = strings.Trim(pkg, "/")
	rel = strings.TrimPrefix(rel, "/")

	if pkg == "" {
		return "", "", errors.New("invalid package")
	}

	return pkg, rel, nil
}

func resolvePackageSpec(spec string) (string, string, error) {
	repo, err := repoPath()
	if err != nil {
		return "", "", err
	}

	repoAbs, err := filepath.Abs(repo)
	if err != nil {
		return "", "", err
	}

	spec = expandHome(spec)
	if filepath.IsAbs(spec) {
		return resolvePathSpec(spec, repoAbs)
	}

	if strings.Contains(spec, string(os.PathSeparator)) {
		return resolvePathSpec(spec, repoAbs)
	}

	clean := filepath.Clean(spec)
	if strings.HasPrefix(clean, ".") || strings.HasPrefix(clean, string(os.PathSeparator)) {
		return resolvePathSpec(clean, repoAbs)
	}

	return splitPackageSpec(spec)
}

func resolvePathSpec(pathSpec, repoAbs string) (string, string, error) {
	absPath, err := filepath.Abs(pathSpec)
	if err != nil {
		return "", "", err
	}

	if strings.HasPrefix(absPath, repoAbs+string(os.PathSeparator)) || absPath == repoAbs {
		rel, err := filepath.Rel(repoAbs, absPath)
		if err != nil {
			return "", "", err
		}
		parts := strings.Split(rel, string(os.PathSeparator))
		if len(parts) >= 1 {
			pkg := parts[0]
			if len(parts) >= 2 && parts[1] == filesDirName {
				relPath := filepath.Join(parts[2:]...)
				return pkg, relPath, nil
			}
			return pkg, filepath.Join(parts[1:]...), nil
		}
	}

	entries, err := os.ReadDir(repoAbs)
	if err != nil {
		return "", "", err
	}

	for _, entry := range entries {
		if !entry.IsDir() || strings.HasPrefix(entry.Name(), ".") {
			continue
		}
		pkgDir := filepath.Join(repoAbs, entry.Name())
		configPath := filepath.Join(pkgDir, configFileName)
		cfg, err := loadConfig(configPath)
		if err != nil {
			continue
		}
		targetRoot, err := selectTarget(cfg)
		if err != nil {
			if errors.Is(err, errTargetDisabled) {
				continue
			}
			continue
		}
		absTarget, err := filepath.Abs(expandHome(targetRoot))
		if err != nil {
			continue
		}
		if strings.HasPrefix(absPath, absTarget+string(os.PathSeparator)) || absPath == absTarget {
			rel, err := filepath.Rel(absTarget, absPath)
			if err != nil {
				return "", "", err
			}
			return entry.Name(), rel, nil
		}
	}

	return "", "", fmt.Errorf("could not resolve %s to a package", pathSpec)
}

func applyCmd(args []string) error {
	prune := false
	for _, arg := range args {
		if arg == "--prune" {
			prune = true
			continue
		}
		return fmt.Errorf("unknown flag %q", arg)
	}

	repo, err := repoPath()
	if err != nil {
		return err
	}

	entries, err := os.ReadDir(repo)
	if err != nil {
		return err
	}

	var stales []string

	for _, entry := range entries {
		if !entry.IsDir() || strings.HasPrefix(entry.Name(), ".") {
			continue
		}

		pkgDir := filepath.Join(repo, entry.Name())
		configPath := filepath.Join(pkgDir, configFileName)
		if _, err := os.Stat(configPath); err != nil {
			if errors.Is(err, os.ErrNotExist) {
				continue
			}
			return err
		}

		cfg, err := loadConfig(configPath)
		if err != nil {
			return fmt.Errorf("%s: %w", entry.Name(), err)
		}

		targetRoot, err := selectTarget(cfg)
		if err != nil {
			if errors.Is(err, errTargetDisabled) {
				continue
			}
			return fmt.Errorf("%s: %w", entry.Name(), err)
		}

		filesDir := filepath.Join(pkgDir, filesDirName)
		if _, err := os.Stat(filesDir); err != nil {
			if errors.Is(err, os.ErrNotExist) {
				continue
			}
			return err
		}

		if err := applyPackage(filesDir, targetRoot, cfg); err != nil {
			return fmt.Errorf("%s: %w", entry.Name(), err)
		}

		stale, err := findStaleLinks(filesDir, targetRoot)
		if err != nil {
			return fmt.Errorf("%s: %w", entry.Name(), err)
		}
		stales = append(stales, stale...)
	}

	if len(stales) == 0 {
		return nil
	}

	if prune {
		return removeLinks(stales, false)
	}

	fmt.Printf("Stale links found: %d\n", len(stales))
	return handleStaleLinks(stales)
}

func addCmd(args []string) error {
	if len(args) < 1 {
		return errors.New("missing path")
	}

	argPath := args[0]
	if !filepath.IsAbs(argPath) {
		if cwd, err := os.Getwd(); err == nil {
			argPath = filepath.Join(cwd, argPath)
		}
	}

	absPath, err := filepath.Abs(argPath)
	if err != nil {
		return err
	}

	info, err := os.Lstat(absPath)
	if err != nil {
		return err
	}
	if info.Mode()&os.ModeSymlink != 0 {
		return fmt.Errorf("%s is a symlink; refusing to add", absPath)
	}

	repo, err := repoPath()
	if err != nil {
		return err
	}
	if err := os.MkdirAll(repo, 0o755); err != nil {
		return err
	}

	defaultTarget := absPath
	if !info.IsDir() {
		defaultTarget = filepath.Dir(absPath)
	}

	defaultPkg := filepath.Base(defaultTarget)
	if info.IsDir() {
		defaultPkg = filepath.Base(absPath)
	}

	reader := bufio.NewReader(os.Stdin)
	var pkgName string

	matchedPkg, err := findPackageByTarget(repo, defaultTarget)
	if err != nil {
		return err
	}
	if matchedPkg != "" {
		ok, err := promptYesNo(fmt.Sprintf("Merge into existing package %q?", matchedPkg), true)
		if err != nil {
			return err
		}
		if ok {
			pkgName = matchedPkg
		}
	}

	if pkgName == "" {
		pkgName = promptWithDefault(reader, "Package name", defaultPkg)
		if pkgName == "" {
			return errors.New("package name cannot be empty")
		}
	}

	targetRootInput := defaultTarget
	if pkgName != matchedPkg || matchedPkg == "" {
		targetRootInput = promptWithDefault(reader, "Target path", defaultTarget)
	}
	targetRoot, err := filepath.Abs(expandHome(targetRootInput))
	if err != nil {
		return err
	}

	pkgDir := filepath.Join(repo, pkgName)
	pkgExists := false
	if _, err := os.Stat(pkgDir); err == nil {
		pkgExists = true
	} else if !errors.Is(err, os.ErrNotExist) {
		return err
	}

	filesDir := filepath.Join(pkgDir, filesDirName)
	if err := os.MkdirAll(filesDir, 0o755); err != nil {
		return err
	}

	configPath := filepath.Join(pkgDir, configFileName)
	if !pkgExists {
		if err := writeConfig(configPath, targetRoot); err != nil {
			return err
		}
	}
	if info.IsDir() {
		if pkgExists {
			return errors.New("cannot merge a directory into an existing package yet")
		}
		if err := moveDirContents(absPath, filesDir); err != nil {
			return err
		}
	} else {
		rel, err := filepath.Rel(targetRoot, absPath)
		if err != nil {
			return err
		}
		if strings.HasPrefix(rel, "..") {
			return fmt.Errorf("path %s is outside target %s", absPath, targetRoot)
		}

		destPath := filepath.Join(filesDir, rel)
		if err := os.MkdirAll(filepath.Dir(destPath), 0o755); err != nil {
			return err
		}
		if _, err := os.Stat(destPath); err == nil {
			return fmt.Errorf("destination already exists: %s", destPath)
		} else if !errors.Is(err, os.ErrNotExist) {
			return err
		}
		if err := os.Rename(absPath, destPath); err != nil {
			return err
		}
	}

	cfg, err := loadConfig(configPath)
	if err != nil {
		return err
	}

	return applyPackage(filesDir, targetRoot, cfg)
}

func unlinkCmd(args []string) error {
	flags, pkgSpec, err := parsePackageFlags(args)
	if err != nil {
		return err
	}

	pkgName, relPath, err := resolvePackageSpec(pkgSpec)
	if err != nil {
		return err
	}

	repo, err := repoPath()
	if err != nil {
		return err
	}

	pkgDir := filepath.Join(repo, pkgName)
	configPath := filepath.Join(pkgDir, configFileName)
	cfg, err := loadConfig(configPath)
	if err != nil {
		return err
	}

	targetRoot, err := selectTarget(cfg)
	if err != nil {
		return err
	}

	filesDir := filepath.Join(pkgDir, filesDirName)
	if _, err := os.Stat(filesDir); err != nil {
		return err
	}

	if relPath == "" {
		if err := restorePackage(filesDir, targetRoot, flags.dryRun); err != nil {
			return err
		}
		if flags.dryRun {
			return nil
		}
		if err := os.RemoveAll(pkgDir); err != nil {
			return err
		}
		fmt.Printf("removed package %s\n", pkgName)
		return nil
	}

	if err := restorePath(filesDir, targetRoot, relPath, flags.dryRun); err != nil {
		return err
	}
	if flags.dryRun {
		return nil
	}
	if err := os.RemoveAll(filepath.Join(filesDir, relPath)); err != nil {
		return err
	}
	fmt.Printf("removed %s:%s\n", pkgName, relPath)
	return nil
}

func removeCmd(args []string) error {
	flags, pkgSpec, err := parsePackageFlags(args)
	if err != nil {
		return err
	}

	pkgName, relPath, err := resolvePackageSpec(pkgSpec)
	if err != nil {
		return err
	}

	repo, err := repoPath()
	if err != nil {
		return err
	}

	pkgDir := filepath.Join(repo, pkgName)
	configPath := filepath.Join(pkgDir, configFileName)
	cfg, err := loadConfig(configPath)
	if err != nil {
		return err
	}

	targetRoot, err := selectTarget(cfg)
	if err != nil {
		return err
	}

	filesDir := filepath.Join(pkgDir, filesDirName)
	if _, err := os.Stat(filesDir); err != nil {
		return err
	}

	if relPath == "" {
		if err := restorePackage(filesDir, targetRoot, flags.dryRun); err != nil {
			return err
		}
		if flags.dryRun {
			return nil
		}
		if err := os.RemoveAll(pkgDir); err != nil {
			return err
		}
		fmt.Printf("removed package %s\n", pkgName)
		return nil
	}

	if err := restorePath(filesDir, targetRoot, relPath, flags.dryRun); err != nil {
		return err
	}

	if flags.dryRun {
		return nil
	}

	if err := os.RemoveAll(filepath.Join(filesDir, relPath)); err != nil {
		return err
	}
	fmt.Printf("removed %s:%s\n", pkgName, relPath)
	return nil
}

func applyPackage(filesDir, targetRoot string, cfg *packageConfig) error {
	if err := ensureDir(targetRoot); err != nil {
		return err
	}

	return filepath.WalkDir(filesDir, func(path string, entry fs.DirEntry, err error) error {
		if err != nil {
			return err
		}
		if path == filesDir {
			return nil
		}

		rel, err := filepath.Rel(filesDir, path)
		if err != nil {
			return err
		}

		if shouldIgnorePath(rel, cfg) {
			if entry.IsDir() {
				return filepath.SkipDir
			}
			return nil
		}

		targetPath := filepath.Join(targetRoot, rel)

		if entry.IsDir() {
			return ensureDir(targetPath)
		}

		srcAbs, err := filepath.Abs(path)
		if err != nil {
			return err
		}
		return linkFile(srcAbs, targetPath)
	})
}

func linkFile(src, dst string) error {
	if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
		return err
	}

	if info, err := os.Lstat(dst); err == nil {
		if info.Mode()&os.ModeSymlink != 0 {
			current, err := os.Readlink(dst)
			if err != nil {
				return err
			}
			if current == src {
				return nil
			}
			return fmt.Errorf("conflict at %s (points to %s)", dst, current)
		}
		return fmt.Errorf("conflict at %s (exists and is not a symlink)", dst)
	} else if !errors.Is(err, os.ErrNotExist) {
		return err
	}

	return os.Symlink(src, dst)
}

func ensureDir(path string) error {
	info, err := os.Lstat(path)
	if err == nil {
		if info.IsDir() {
			return nil
		}
		return fmt.Errorf("%s exists and is not a directory", path)
	}
	if !errors.Is(err, os.ErrNotExist) {
		return err
	}
	return os.MkdirAll(path, 0o755)
}

func loadConfig(path string) (*packageConfig, error) {
	L := lua.NewState()
	defer L.Close()
	L.OpenLibs()

	if err := L.DoFile(path); err != nil {
		return nil, err
	}
	if L.GetTop() == 0 {
		return nil, errors.New("config.lua must return a table")
	}

	value := L.Get(-1)
	tbl, ok := value.(*lua.LTable)
	if !ok {
		return nil, errors.New("config.lua must return a table")
	}

	targetVal := tbl.RawGetString("target")
	targetTbl, ok := targetVal.(*lua.LTable)
	if !ok {
		return nil, errors.New("config.target must be a table")
	}

	targets := make(map[string]string)
	disabled := make(map[string]bool)
	targetTbl.ForEach(func(k, v lua.LValue) {
		ks, ok := k.(lua.LString)
		if !ok {
			return
		}

		if v == lua.LNil || v == lua.LFalse {
			disabled[string(ks)] = true
			return
		}

		vs, ok := v.(lua.LString)
		if !ok {
			return
		}

		targets[string(ks)] = expandHome(string(vs))
	})

	if len(targets) == 0 && len(disabled) == 0 {
		return nil, errors.New("config.target is empty")
	}

	ignore, err := parseIgnore(tbl)
	if err != nil {
		return nil, err
	}
	compiledIgnores, err := compileIgnorePatterns(ignore)
	if err != nil {
		return nil, err
	}

	return &packageConfig{targets: targets, disabled: disabled, ignore: ignore, compiledIgnores: compiledIgnores}, nil
}

func statusCmd() error {
	repo, err := repoPath()
	if err != nil {
		return err
	}

	entries, err := os.ReadDir(repo)
	if err != nil {
		return err
	}

	for _, entry := range entries {
		if !entry.IsDir() || strings.HasPrefix(entry.Name(), ".") {
			continue
		}

		pkgDir := filepath.Join(repo, entry.Name())
		configPath := filepath.Join(pkgDir, configFileName)
		cfg, err := loadConfig(configPath)
		if err != nil {
			return fmt.Errorf("%s: %w", entry.Name(), err)
		}

		targetRoot, err := selectTarget(cfg)
		if err != nil {
			if errors.Is(err, errTargetDisabled) {
				continue
			}
			return fmt.Errorf("%s: %w", entry.Name(), err)
		}

		filesDir := filepath.Join(pkgDir, filesDirName)
		stale, err := findStaleLinks(filesDir, targetRoot)
		if err != nil {
			return fmt.Errorf("%s: %w", entry.Name(), err)
		}
		if len(stale) == 0 {
			fmt.Printf("%s: ok\n", entry.Name())
			continue
		}

		fmt.Printf("%s: stale links (%d)\n", entry.Name(), len(stale))
		for _, path := range stale {
			fmt.Printf("  %s\n", path)
		}
	}

	return nil
}

func selectTarget(cfg *packageConfig) (string, error) {
	osKey := runtime.GOOS
	if osKey == "darwin" {
		osKey = "macos"
	}
	if cfg.disabled[osKey] {
		return "", errTargetDisabled
	}
	if target, ok := cfg.targets[osKey]; ok {
		return expandHome(target), nil
	}
	if target, ok := cfg.targets["default"]; ok {
		return expandHome(target), nil
	}
	return "", fmt.Errorf("missing target for %s and default", osKey)
}

func parseIgnore(cfgTbl *lua.LTable) ([]string, error) {
	ignoreVal := cfgTbl.RawGetString("ignore")
	if ignoreVal == lua.LNil {
		return nil, nil
	}

	ignoreTbl, ok := ignoreVal.(*lua.LTable)
	if !ok {
		return nil, errors.New("config.ignore must be an array of strings")
	}

	ignore := make([]string, 0, ignoreTbl.Len())
	var parseErr error
	ignoreTbl.ForEach(func(k, v lua.LValue) {
		if parseErr != nil {
			return
		}

		if _, ok := k.(lua.LNumber); !ok {
			parseErr = errors.New("config.ignore must be an array of strings")
			return
		}

		s, ok := v.(lua.LString)
		if !ok {
			parseErr = errors.New("config.ignore must contain only strings")
			return
		}

		pattern := strings.TrimSpace(string(s))
		if pattern == "" {
			parseErr = errors.New("config.ignore cannot contain empty patterns")
			return
		}

		ignore = append(ignore, pattern)
	})
	if parseErr != nil {
		return nil, parseErr
	}
	return ignore, nil
}

func compileIgnorePatterns(patterns []string) ([]*regexp.Regexp, error) {
	compiled := make([]*regexp.Regexp, 0, len(patterns))
	for _, pattern := range patterns {
		re, err := globToRegexp(pattern)
		if err != nil {
			return nil, fmt.Errorf("invalid ignore pattern %q: %w", pattern, err)
		}
		compiled = append(compiled, re)
	}
	return compiled, nil
}

func globToRegexp(pattern string) (*regexp.Regexp, error) {
	pattern = strings.ReplaceAll(pattern, "\\", "/")
	pattern = strings.TrimPrefix(pattern, "./")
	if strings.HasPrefix(pattern, "/") {
		pattern = strings.TrimPrefix(pattern, "/")
	}

	var b strings.Builder
	b.WriteString("^")
	for i := 0; i < len(pattern); {
		if i+1 < len(pattern) && pattern[i] == '*' && pattern[i+1] == '*' {
			b.WriteString(".*")
			i += 2
			continue
		}

		ch := pattern[i]
		switch ch {
		case '*':
			b.WriteString("[^/]*")
		case '?':
			b.WriteString("[^/]")
		default:
			b.WriteString(regexp.QuoteMeta(string(ch)))
		}
		i++
	}
	b.WriteString("$")

	return regexp.Compile(b.String())
}

func shouldIgnorePath(rel string, cfg *packageConfig) bool {
	if cfg == nil || len(cfg.compiledIgnores) == 0 {
		return false
	}

	normalized := path.Clean(filepath.ToSlash(rel))
	for _, re := range cfg.compiledIgnores {
		if re.MatchString(normalized) {
			return true
		}
	}

	return false
}

func repoPath() (string, error) {
	if override := os.Getenv("SIGIL_REPO"); override != "" {
		return filepath.Abs(expandHome(override))
	}
	return filepath.Abs(expandHome("~/.dotfiles"))
}

func expandHome(path string) string {
	if path == "~" {
		home, err := os.UserHomeDir()
		if err != nil {
			return path
		}
		return home
	}
	if strings.HasPrefix(path, "~/") {
		home, err := os.UserHomeDir()
		if err != nil {
			return path
		}
		return filepath.Join(home, path[2:])
	}
	return path
}

func compressHome(path string) string {
	home, err := os.UserHomeDir()
	if err != nil {
		return path
	}
	clean := filepath.Clean(path)
	homeClean := filepath.Clean(home)
	if clean == homeClean {
		return "~"
	}
	if strings.HasPrefix(clean, homeClean+string(os.PathSeparator)) {
		rel := strings.TrimPrefix(clean, homeClean+string(os.PathSeparator))
		return filepath.Join("~", rel)
	}
	return path
}

func writeConfig(path, targetRoot string) error {
	osKey := "linux"
	if runtime.GOOS == "darwin" {
		osKey = "macos"
	}

	prettyTarget := compressHome(targetRoot)
	content := fmt.Sprintf("---@class SigilConfig\n---@field target table<string, string|boolean>\n---@field ignore? string[]\n\n---@type SigilConfig\nlocal config = {\n\ttarget = {\n\t\t%s = %q,\n\t\tdefault = %q,\n\t},\n\tignore = {\n\t\t-- \"**/.DS_Store\",\n\t\t-- \"**/*.tmp\",\n\t\t-- \"cache/**\",\n\t},\n}\n\nreturn config\n", osKey, prettyTarget, prettyTarget)
	return os.WriteFile(path, []byte(content), 0o644)
}

func promptWithDefault(reader *bufio.Reader, label, def string) string {
	if def != "" {
		fmt.Printf("%s [%s]: ", label, def)
	} else {
		fmt.Printf("%s: ", label)
	}

	text, _ := reader.ReadString('\n')
	text = strings.TrimSpace(text)
	if text == "" {
		return def
	}
	return text
}

func promptYesNo(message string, def bool) (bool, error) {
	reader := bufio.NewReader(os.Stdin)
	defLabel := "y/N"
	if def {
		defLabel = "Y/n"
	}
	fmt.Printf("%s [%s]: ", message, defLabel)
	text, err := reader.ReadString('\n')
	if err != nil {
		return false, err
	}
	text = strings.TrimSpace(strings.ToLower(text))
	if text == "" {
		return def, nil
	}
	return text == "y" || text == "yes", nil
}

func moveDirContents(srcDir, destDir string) error {
	entries, err := os.ReadDir(srcDir)
	if err != nil {
		return err
	}

	for _, entry := range entries {
		srcPath := filepath.Join(srcDir, entry.Name())
		destPath := filepath.Join(destDir, entry.Name())

		if _, err := os.Stat(destPath); err == nil {
			return fmt.Errorf("destination already exists: %s", destPath)
		} else if !errors.Is(err, os.ErrNotExist) {
			return err
		}

		if err := os.Rename(srcPath, destPath); err != nil {
			return err
		}
	}

	return nil
}

func findStaleLinks(filesDir, targetRoot string) ([]string, error) {
	filesAbs, err := filepath.Abs(filesDir)
	if err != nil {
		return nil, err
	}

	var stale []string
	walkErr := filepath.WalkDir(targetRoot, func(path string, entry fs.DirEntry, err error) error {
		if err != nil {
			return err
		}
		if entry.IsDir() {
			return nil
		}

		info, err := os.Lstat(path)
		if err != nil {
			return err
		}
		if info.Mode()&os.ModeSymlink == 0 {
			return nil
		}

		src, err := os.Readlink(path)
		if err != nil {
			return err
		}
		if !filepath.IsAbs(src) {
			src = filepath.Join(filepath.Dir(path), src)
		}
		src = filepath.Clean(src)

		if !strings.HasPrefix(src, filesAbs+string(os.PathSeparator)) && src != filesAbs {
			return nil
		}

		if _, err := os.Stat(src); errors.Is(err, os.ErrNotExist) {
			stale = append(stale, path)
		}
		return nil
	})

	if walkErr != nil {
		return nil, walkErr
	}

	return stale, nil
}

func findPackageByTarget(repo, targetRoot string) (string, error) {
	repoEntries, err := os.ReadDir(repo)
	if err != nil {
		return "", err
	}

	absTarget, err := filepath.Abs(expandHome(targetRoot))
	if err != nil {
		return "", err
	}

	for _, entry := range repoEntries {
		if !entry.IsDir() || strings.HasPrefix(entry.Name(), ".") {
			continue
		}

		pkgDir := filepath.Join(repo, entry.Name())
		configPath := filepath.Join(pkgDir, configFileName)
		cfg, err := loadConfig(configPath)
		if err != nil {
			continue
		}
		target, err := selectTarget(cfg)
		if err != nil {
			if errors.Is(err, errTargetDisabled) {
				continue
			}
			continue
		}
		absPkgTarget, err := filepath.Abs(expandHome(target))
		if err != nil {
			continue
		}
		if absPkgTarget == absTarget {
			return entry.Name(), nil
		}
	}

	return "", nil
}

func removeLinks(paths []string, dryRun bool) error {
	for _, path := range paths {
		if dryRun {
			fmt.Printf("dry-run: remove %s\n", path)
			continue
		}
		if err := os.Remove(path); err != nil {
			return err
		}
		fmt.Printf("removed %s\n", path)
	}
	return nil
}

func handleStaleLinks(stales []string) error {
	if len(stales) == 0 {
		return nil
	}

	repo, err := repoPath()
	if err != nil {
		return err
	}

	reader := bufio.NewReader(os.Stdin)
	for _, path := range stales {
		fmt.Printf("stale: %s\n", path)

		canUnlink, err := staleHasRepoFile(path, repo)
		if err != nil {
			return err
		}

		prompt := "action [p=prune, u=unlink, i=ignore]: "
		if !canUnlink {
			prompt = "action [p=prune, i=ignore]: "
		}

		fmt.Print(prompt)
		choice, err := reader.ReadString('\n')
		if err != nil {
			return err
		}
		choice = strings.TrimSpace(strings.ToLower(choice))
		if choice == "" || choice == "i" {
			continue
		}
		if choice == "p" {
			if err := os.Remove(path); err != nil {
				return err
			}
			fmt.Printf("removed %s\n", path)
			continue
		}
		if choice == "u" && canUnlink {
			if err := unlinkStale(path, repo); err != nil {
				return err
			}
			continue
		}
		fmt.Println("invalid choice; skipping")
	}

	return nil
}

func staleHasRepoFile(targetPath, repo string) (bool, error) {
	repoPath, err := repoPathForTarget(targetPath, repo)
	if err != nil {
		return false, err
	}
	if repoPath == "" {
		return false, nil
	}
	if _, err := os.Stat(repoPath); errors.Is(err, os.ErrNotExist) {
		return false, nil
	} else if err != nil {
		return false, err
	}
	return true, nil
}

func unlinkStale(targetPath, repo string) error {
	repoPath, err := repoPathForTarget(targetPath, repo)
	if err != nil {
		return err
	}
	if repoPath == "" {
		return nil
	}

	if err := os.Remove(targetPath); err != nil {
		return err
	}

	if err := copyFile(repoPath, targetPath); err != nil {
		return err
	}

	if err := os.Remove(repoPath); err != nil {
		return err
	}

	fmt.Printf("unlinked %s (removed %s)\n", targetPath, repoPath)
	return nil
}

func repoPathForTarget(targetPath, repo string) (string, error) {
	info, err := os.Lstat(targetPath)
	if err != nil {
		return "", err
	}
	if info.Mode()&os.ModeSymlink == 0 {
		return "", nil
	}

	src, err := os.Readlink(targetPath)
	if err != nil {
		return "", err
	}
	if !filepath.IsAbs(src) {
		src = filepath.Join(filepath.Dir(targetPath), src)
	}
	src = filepath.Clean(src)

	repoAbs, err := filepath.Abs(repo)
	if err != nil {
		return "", err
	}

	rel, err := filepath.Rel(repoAbs, src)
	if err != nil {
		return "", err
	}
	if strings.HasPrefix(rel, "..") {
		return "", nil
	}

	parts := strings.Split(rel, string(os.PathSeparator))
	if len(parts) < 3 {
		return "", nil
	}
	if parts[1] != filesDirName {
		return "", nil
	}

	return filepath.Join(repoAbs, rel), nil
}

func restorePackage(filesDir, targetRoot string, dryRun bool) error {
	filesAbs, err := filepath.Abs(filesDir)
	if err != nil {
		return err
	}

	return filepath.WalkDir(filesDir, func(path string, entry fs.DirEntry, err error) error {
		if err != nil {
			return err
		}
		if path == filesDir {
			return nil
		}
		if entry.IsDir() {
			return nil
		}

		rel, err := filepath.Rel(filesDir, path)
		if err != nil {
			return err
		}
		targetPath := filepath.Join(targetRoot, rel)
		return restoreOne(path, targetPath, filesAbs, dryRun)
	})
}

func restorePath(filesDir, targetRoot, relPath string, dryRun bool) error {
	filesAbs, err := filepath.Abs(filesDir)
	if err != nil {
		return err
	}

	relPath = filepath.Clean(relPath)
	if strings.HasPrefix(relPath, "..") || filepath.IsAbs(relPath) {
		return fmt.Errorf("invalid relative path %q", relPath)
	}

	sourcePath := filepath.Join(filesDir, relPath)
	info, err := os.Lstat(sourcePath)
	if err != nil {
		return err
	}

	if info.IsDir() {
		return filepath.WalkDir(sourcePath, func(path string, entry fs.DirEntry, err error) error {
			if err != nil {
				return err
			}
			if entry.IsDir() {
				return nil
			}
			rel, err := filepath.Rel(filesDir, path)
			if err != nil {
				return err
			}
			targetPath := filepath.Join(targetRoot, rel)
			return restoreOne(path, targetPath, filesAbs, dryRun)
		})
	}

	rel, err := filepath.Rel(filesDir, sourcePath)
	if err != nil {
		return err
	}
	return restoreOne(sourcePath, filepath.Join(targetRoot, rel), filesAbs, dryRun)
}

func restoreOne(sourcePath, targetPath, filesAbs string, dryRun bool) error {
	info, err := os.Lstat(targetPath)
	if errors.Is(err, os.ErrNotExist) {
		return nil
	}
	if err != nil {
		return err
	}
	if info.Mode()&os.ModeSymlink == 0 {
		return nil
	}

	src, err := os.Readlink(targetPath)
	if err != nil {
		return err
	}
	if !filepath.IsAbs(src) {
		src = filepath.Join(filepath.Dir(targetPath), src)
	}
	src = filepath.Clean(src)

	if !strings.HasPrefix(src, filesAbs+string(os.PathSeparator)) && src != filesAbs {
		return nil
	}

	if dryRun {
		fmt.Printf("dry-run: restore %s\n", targetPath)
		return nil
	}

	if err := os.Remove(targetPath); err != nil {
		return err
	}

	fmt.Printf("restored %s\n", targetPath)
	return copyFile(sourcePath, targetPath)
}

func copyFile(src, dst string) error {
	if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
		return err
	}

	srcFile, err := os.Open(src)
	if err != nil {
		return err
	}
	defer srcFile.Close()

	info, err := srcFile.Stat()
	if err != nil {
		return err
	}

	dstFile, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, info.Mode())
	if err != nil {
		return err
	}
	defer dstFile.Close()

	if _, err := io.Copy(dstFile, srcFile); err != nil {
		return err
	}

	return nil
}
