package main

import (
	"errors"
	"fmt"
	"io"
	"io/fs"
	"os"
	"path/filepath"
	"strings"
)

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 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 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 := newReader()
	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 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
}

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
}
