package path

import (
	"errors"
	"fmt"
	"os"
	"path/filepath"
	"strings"

	"github.com/argoproj/argo-cd/v3/pkg/apis/application/v1alpha1"
	"github.com/argoproj/argo-cd/v3/util/io/files"
	"github.com/argoproj/argo-cd/v3/util/security"
)

func Path(root, path string) (string, error) {
	if filepath.IsAbs(path) {
		return "", fmt.Errorf("%s: app path is absolute", path)
	}
	appPath := filepath.Join(root, path)
	if !strings.HasPrefix(appPath, filepath.Clean(root)) {
		return "", fmt.Errorf("%s: app path outside root", path)
	}
	info, err := os.Stat(appPath)
	if os.IsNotExist(err) {
		return "", fmt.Errorf("%s: app path does not exist", path)
	}
	if err != nil {
		return "", err
	}
	if !info.IsDir() {
		return "", fmt.Errorf("%s: app path is not a directory", path)
	}
	return appPath, nil
}

type OutOfBoundsSymlinkError struct {
	File string
	Err  error
}

func (e *OutOfBoundsSymlinkError) Error() string {
	return "out of bounds symlink found"
}

// CheckOutOfBoundsSymlinks determines if basePath contains any symlinks that
// are absolute or point to a path outside of the basePath. If found, an
// OutOfBoundsSymlinkError is returned.
func CheckOutOfBoundsSymlinks(basePath string) error {
	absBasePath, err := filepath.Abs(basePath)
	if err != nil {
		return fmt.Errorf("failed to get absolute path: %w", err)
	}
	return filepath.Walk(absBasePath, func(path string, info os.FileInfo, err error) error {
		if err != nil {
			// Ignore "no such file or directory" errors than can happen with
			// temporary files such as .git/*.lock
			if errors.Is(err, os.ErrNotExist) {
				return nil
			}
			return fmt.Errorf("failed to walk for symlinks in %s: %w", absBasePath, err)
		}
		if files.IsSymlink(info) {
			// We don't use filepath.EvalSymlinks because it fails without returning a path
			// if the target doesn't exist.
			linkTarget, err := os.Readlink(path)
			if err != nil {
				return fmt.Errorf("failed to read link %s: %w", path, err)
			}
			// get the path of the symlink relative to basePath, used for error description
			linkRelPath, err := filepath.Rel(absBasePath, path)
			if err != nil {
				return fmt.Errorf("failed to get relative path for symlink: %w", err)
			}
			// deny all absolute symlinks
			if filepath.IsAbs(linkTarget) {
				return &OutOfBoundsSymlinkError{File: linkRelPath}
			}
			// get the parent directory of the symlink
			currentDir := filepath.Dir(path)

			// walk each part of the symlink target to make sure it never leaves basePath
			parts := strings.SplitSeq(linkTarget, string(os.PathSeparator))
			for part := range parts {
				newDir := filepath.Join(currentDir, part)
				rel, err := filepath.Rel(absBasePath, newDir)
				if err != nil {
					return fmt.Errorf("failed to get relative path for symlink target: %w", err)
				}
				if rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
					// return an error so we don't keep traversing the tree
					return &OutOfBoundsSymlinkError{File: linkRelPath}
				}
				currentDir = newDir
			}
		}
		return nil
	})
}

// GetSourceRefreshPaths returns the list of paths that should trigger a refresh for an application.
// The source parameter influences the returned refresh paths:
//   - if source hydrator configured AND source is syncSource: use sync source path (ignores annotation)
//   - if source hydrator configured AND source is drySource WITH annotation: use annotation paths with drySource base
//   - if source hydrator not configured: use annotation paths with source base, or empty if no annotation
func GetSourceRefreshPaths(app *v1alpha1.Application, source v1alpha1.ApplicationSource) []string {
	annotationPaths, hasAnnotation := app.Annotations[v1alpha1.AnnotationKeyManifestGeneratePaths]

	if app.Spec.SourceHydrator != nil {
		syncSource := app.Spec.SourceHydrator.GetSyncSource()

		// if source is syncSource use the source path
		if (source).Equals(&syncSource) {
			return []string{source.Path}
		}
	}

	var paths []string
	if hasAnnotation && annotationPaths != "" {
		for item := range strings.SplitSeq(annotationPaths, ";") {
			// Trim whitespace because annotation values may contain spaces around
			// separators (e.g. ".; /path"). Without trimming, paths like " /path"
			// are not treated as absolute and empty/space-only entries may result
			// in duplicate or incorrect refresh paths.
			item = strings.TrimSpace(item)
			// skip empty paths
			if item == "" {
				continue
			}
			// if absolute path, add as is
			if filepath.IsAbs(item) {
				paths = append(paths, item[1:])
				continue
			}

			// add the path relative to the source path
			paths = append(paths, filepath.Clean(filepath.Join(source.Path, item)))
		}
	}

	return paths
}

// AppFilesHaveChanged returns true if any of the changed files are under the given refresh paths
// If refreshPaths or changedFiles are empty, it will always return true
func AppFilesHaveChanged(refreshPaths []string, changedFiles []string) bool {
	// an empty slice of changed files means that the payload didn't include a list
	// of changed files and we have to assume that a refresh is required
	if len(changedFiles) == 0 {
		return true
	}

	if len(refreshPaths) == 0 {
		// Apps without a given refreshed paths always be refreshed, regardless of changed files
		// this is the "default" behavior
		return true
	}

	// At last one changed file must be under refresh path
	for _, f := range changedFiles {
		f = ensureAbsPath(f)
		for _, item := range refreshPaths {
			item = ensureAbsPath(item)
			if f == item {
				return true
			} else if _, err := security.EnforceToCurrentRoot(item, f); err == nil {
				return true
			} else if matched, err := filepath.Match(item, f); err == nil && matched {
				return true
			}
		}
	}

	return false
}

func ensureAbsPath(input string) string {
	if !filepath.IsAbs(input) {
		return string(filepath.Separator) + input
	}
	return input
}
