package sandbox

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

// ChmodRepoTree sets directory modes to 2770 (with the setgid bit) and
// file modes to 0660 under root, preserving the executable bit on files
// (hook scripts need it). Symlinks are skipped since their mode is not
// meaningful.
//
// The group bits exist so the knot service (running as the git user, which
// is in the git group that owns the repos) can still read and write the
// repo via group permissions even though the repo's UID owner is a virtual
// UID. Sandbox subprocesses drop supplementary groups so cross-owner
// isolation still holds.
//
// The setgid bit on directories makes new files and subdirectories created
// by sandbox subprocesses inherit the directory's group (the git group)
// rather than the subprocess's primary group (the virtual UID). Without
// it, sandbox-created files would be unreadable to the knot service.
func ChmodRepoTree(root string) error {
	return filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
		if err != nil {
			return err
		}
		if d.Type()&fs.ModeSymlink != 0 {
			return nil
		}
		if d.IsDir() {
			return os.Chmod(path, 0770|os.ModeSetgid)
		}
		info, err := d.Info()
		if err != nil {
			return err
		}
		mode := fs.FileMode(0660)
		if info.Mode()&0100 != 0 {
			mode = 0770
		}
		return os.Chmod(path, mode)
	})
}

// ChownRepoTree recursively chowns every entry under root to uid:gid.
// Entries are processed deepest-first so a directory is only chowned after
// its contents, preserving the calling process's access throughout the walk.
// Call ChmodRepoTree first if you also want to tighten permissions; this
// function only changes ownership.
func ChownRepoTree(root string, uid int, gid int) error {
	type entry struct {
		path  string
		depth int
	}
	var entries []entry
	if err := filepath.WalkDir(root, func(path string, _ fs.DirEntry, err error) error {
		if err != nil {
			return err
		}
		depth := strings.Count(path, string(filepath.Separator))
		entries = append(entries, entry{path, depth})
		return nil
	}); err != nil {
		return err
	}

	sort.Slice(entries, func(i, j int) bool {
		return entries[i].depth > entries[j].depth
	})

	for _, e := range entries {
		if err := os.Lchown(e.path, uid, gid); err != nil {
			return err
		}
	}
	return nil
}

// LookupUIDForRepoPath returns the owner UID and GID of the repo directory at
// repoPath. scanPath is validated as a prefix to guard against directory escape.
func LookupUIDForRepoPath(scanPath, repoPath string) (uid uint32, gid uint32, err error) {
	if !strings.HasPrefix(repoPath, scanPath) {
		return 0, 0, fmt.Errorf("repo path %q is outside scan path %q", repoPath, scanPath)
	}
	var stat syscall.Stat_t
	if err := syscall.Stat(repoPath, &stat); err != nil {
		return 0, 0, err
	}
	return stat.Uid, stat.Gid, nil
}

// ServiceGid returns the GID of scanPath, which is treated as the "service
// group" that owns all repositories. Callers chown repo trees to
// (virtualUID, ServiceGid(scanPath)) so the knot service (a member of this
// group) retains read+write access via the group bits set by ChmodRepoTree.
func ServiceGid(scanPath string) (uint32, error) {
	var stat syscall.Stat_t
	if err := syscall.Stat(scanPath, &stat); err != nil {
		return 0, fmt.Errorf("stat %s: %w", scanPath, err)
	}
	return stat.Gid, nil
}
