package agent

import (
	"log/slog"
	"os"
	"path/filepath"
	"syscall"
	"time"
)

// Permission watcher constants — no magic numbers.
const (
	// PermissionFixInterval is how often the watcher scans for wrong ownership.
	PermissionFixInterval = 10 * time.Second

	// DevUID is the uid of the "dev" user that agents run as.
	DevUID = 1001

	// NodeGID is the gid of the "node" group shared by all agent users.
	NodeGID = 1000

	// DirPerms is the minimum permission bits required on directories (u+rwx, g+rwx).
	// Group access is essential because agents run as different users sharing the node group.
	DirPerms = 0o770

	// FilePerms is the minimum permission bits required on files (u+rw, g+rw).
	FilePerms = 0o660

	// bobStateDirGroupRWX is `g+rwx` — the bits a bob state directory (.bob)
	// must grant the shared `node` group so an agent UID can create, replace and
	// traverse the files bob persists there (settings, installation_id, the
	// .bob-errors/ logger target). A dir-owning hive-<agent> user commonly
	// creates these 0755 (owner rwx, group r-x), which leaves the group without
	// write and makes bob log "error saving your latest settings changes" and
	// "Failed to initialize logger" while running degraded. The watcher ORs
	// these bits in to bring such a dir to at least 0770 without ever narrowing
	// the owner or other bits it already has.
	bobStateDirGroupRWX os.FileMode = 0o070
)

// bobStateDirBase is the directory name bob uses for both its shared-HOME state
// (/data/home/.bob) and its per-agent workdir state (/data/agents/<name>/.bob).
// The watcher recognizes an entry as a bob state dir by this basename so it can
// self-heal the group-write bit the general fixEntry guard would otherwise skip
// (those dirs are owned by hive-<agent>, not DevUID). Kept in sync with
// config.BobStateDirName without importing config here to avoid a cycle.
const bobStateDirBase = ".bob"

// WatchedHomeDirs are the subdirectories under the shared home and data
// volume that tools (Copilot, Claude, etc.) or init containers frequently
// create with root ownership, locking out agent UIDs.
var WatchedHomeDirs = []string{
	"/data/home/.copilot",
	"/data/home/.claude",
	"/data/home/.cache",
	"/data/home/.config",
	"/data/home/.local",
	// bobshell writes its installation-id file and chat-recording tree under
	// $HOME/.bob (verified in bobshell 1.0.6 bundle/bob.js: the state dir is
	// path.join(os.homedir(), ".bob")). Agents run with HOME=/data/home, which
	// the entrypoint creates as root, so without this entry every launch logs
	// "EACCES: permission denied, mkdir '/data/home/.bob'" and bob falls back
	// to an ephemeral installation ID with chat recording disabled.
	//
	// The nested tree (.bob/tmp/<uuid>/chats) needs no separate entries: bob
	// creates those with mkdirSync({recursive:true}) as the AGENT's own uid
	// once .bob itself is traversable+writable by the node group, and
	// fixPermissions walks each root recursively (filepath.Walk) so anything
	// created root-owned underneath is corrected on the next tick anyway.
	"/data/home/.bob",
	"/data/agents",
}

// GooseLogsDir is the rolling log directory goose 1.37 creates on startup.
// Goose panics if this directory doesn't exist, so the watcher ensures it
// is created at startup with correct permissions.
//
// A var (not const) so tests can point the permissions watcher at a writable
// temp tree (together with WatchedHomeDirs) to exercise ensureWatchedDirs /
// fixPermissions / fixEntry. Production value is unchanged.
var GooseLogsDir = "/data/home/.local/state/goose/logs/cli"

// StartPermissionsWatcher runs a background goroutine that periodically
// scans WatchedHomeDirs and fixes files/directories that were created
// with wrong ownership (e.g., root-owned by Copilot CLI).
//
// It never blocks or panics. Call it once at startup:
//
//	go agent.StartPermissionsWatcher(logger)
func StartPermissionsWatcher(logger *slog.Logger) {
	logger.Info("permissions watcher started",
		"interval", PermissionFixInterval,
		"watched_dirs", WatchedHomeDirs,
		"target_uid", DevUID,
		"target_gid", NodeGID,
	)

	// Ensure watched directories exist with correct ownership on first run.
	ensureWatchedDirs(logger)

	ticker := time.NewTicker(PermissionFixInterval)
	defer ticker.Stop()

	for range ticker.C {
		fixPermissions(logger)
	}
}

// ensureWatchedDirs creates each watched directory if it does not exist
// and sets correct ownership.
func ensureWatchedDirs(logger *slog.Logger) {
	allDirs := append([]string{GooseLogsDir}, WatchedHomeDirs...)
	for _, dir := range allDirs {
		if err := os.MkdirAll(dir, DirPerms|0o070); err != nil {
			logger.Warn("permissions watcher: failed to create directory",
				"path", dir,
				"error", err,
			)
			continue
		}
		// Always set ownership on the top-level dir at startup.
		if err := os.Chown(dir, DevUID, NodeGID); err != nil {
			logger.Warn("permissions watcher: failed to chown directory",
				"path", dir,
				"error", err,
			)
		}
	}
}

// fixPermissions walks each watched directory and fixes ownership/mode
// on any file or directory that is wrong. It only logs when it actually
// changes something.
func fixPermissions(logger *slog.Logger) {
	for _, root := range WatchedHomeDirs {
		info, err := os.Stat(root)
		if err != nil {
			// Directory doesn't exist yet — create it.
			if os.IsNotExist(err) {
				ensureWatchedDirs(logger)
			}
			continue
		}
		if !info.IsDir() {
			continue
		}

		err = filepath.Walk(root, func(path string, fi os.FileInfo, walkErr error) error {
			if walkErr != nil {
				return nil // skip unreadable entries, don't abort walk
			}
			fixEntry(path, fi, logger)
			return nil
		})
		if err != nil {
			logger.Warn("permissions watcher: walk error",
				"root", root,
				"error", err,
			)
		}
	}
}

// fixEntry checks a single file or directory and corrects ownership/mode
// if needed.
func fixEntry(path string, fi os.FileInfo, logger *slog.Logger) {
	stat, ok := fi.Sys().(*syscall.Stat_t)
	if !ok {
		return
	}

	// Fix group ownership if not in the node group — all agents share this group.
	// Also fix root-owned files (uid 0) to the dev user.
	needsChown := false
	newUID := int(stat.Uid)
	if stat.Uid == 0 {
		newUID = DevUID
		needsChown = true
	}
	if stat.Gid != uint32(NodeGID) {
		needsChown = true
	}
	if needsChown {
		if err := os.Chown(path, newUID, NodeGID); err != nil {
			logger.Warn("permissions watcher: chown failed",
				"path", path,
				"error", err,
			)
		} else {
			logger.Warn("permissions watcher: fixed ownership",
				"path", path,
				"was_uid", stat.Uid,
				"was_gid", stat.Gid,
				"new_uid", newUID,
				"new_gid", NodeGID,
			)
		}
	}

	// bob state dirs (.bob) are the one class of directory the watcher must
	// widen even though they are owned by a hive-<agent> UID rather than
	// DevUID: bob runs as that agent's UID through the shared `node` group, so
	// a 0755 dir leaves the group without write and bob logs "error saving your
	// latest settings changes" / "Failed to initialize logger" and runs
	// degraded. verifyBobStateDirsWritable only DETECTS this; here we actually
	// fix it, on every tick and at startup, so the fleet self-heals instead of
	// staying degraded until an operator hand-chmods the pod. Handled before
	// the general owner guard below precisely because that guard would skip
	// these non-DevUID dirs. Only the group r/w/x bits are added — owner and
	// other bits are preserved — so an already-770 dir is left untouched.
	if fi.IsDir() && filepath.Base(path) == bobStateDirBase {
		fixBobStateDirGroupWrite(path, fi.Mode(), logger)
	}

	// Only fix permissions on files we own or just chowned.
	// Skipping files owned by other users avoids "operation not permitted"
	// spam when agents create files as their own users.
	if newUID != DevUID && stat.Uid != uint32(DevUID) {
		return
	}

	mode := fi.Mode()
	if fi.IsDir() {
		// Directories need u+rwx to be usable.
		if mode.Perm()&DirPerms != DirPerms {
			newMode := mode.Perm() | DirPerms
			if err := os.Chmod(path, newMode); err != nil {
				logger.Warn("permissions watcher: chmod dir failed",
					"path", path,
					"error", err,
				)
			} else {
				logger.Warn("permissions watcher: fixed directory permissions",
					"path", path,
					"old_mode", mode.Perm().String(),
					"new_mode", newMode.String(),
				)
			}
		}
	} else {
		// Regular files need u+rw to be readable/writable.
		if mode.Perm()&FilePerms != FilePerms {
			newMode := mode.Perm() | FilePerms
			if err := os.Chmod(path, newMode); err != nil {
				logger.Warn("permissions watcher: chmod file failed",
					"path", path,
					"error", err,
				)
			} else {
				logger.Warn("permissions watcher: fixed file permissions",
					"path", path,
					"old_mode", mode.Perm().String(),
					"new_mode", newMode.String(),
				)
			}
		}
	}
}

// fixBobStateDirGroupWrite ensures a bob state directory (.bob) grants the
// shared `node` group read+write+execute, so an agent UID reaching it through
// that group can persist bob's settings, installation_id and .bob-errors/
// logger target. It ORs bobStateDirGroupRWX into whatever the dir already has —
// never narrowing owner or other bits — so a dir that is already group-writable
// (>= 0770) is left byte-identical and the watcher stays idempotent.
//
// This is the remediation that verifyBobStateDirsWritable only ever DETECTED:
// production .bob dirs are created 0755 (r-x for the group) by their
// hive-<agent> owner, which is why bob logged "error saving your latest
// settings changes" / "Failed to initialize logger" fleet-wide and ran
// degraded. The chmod runs on every watcher tick and at startup, so the fix
// self-heals rather than requiring a hand-chmod on the pod.
//
// Safe and non-fatal: a chmod we are not permitted to perform (EPERM — we are
// neither the owner nor privileged) is logged and swallowed, exactly like the
// watcher's other chmod/chown arms, so it never blocks or panics.
func fixBobStateDirGroupWrite(path string, mode os.FileMode, logger *slog.Logger) {
	perm := mode.Perm()
	// Nothing to do when the group already has all of r/w/x — this keeps an
	// already-remediated (>= 0770) dir untouched and the walk idempotent.
	if perm&bobStateDirGroupRWX == bobStateDirGroupRWX {
		return
	}
	newPerm := perm | bobStateDirGroupRWX
	if err := os.Chmod(path, newPerm); err != nil {
		logger.Error("permissions watcher: chmod bob state dir failed; bob will keep reporting 'error saving your latest settings changes' and run degraded",
			"path", path,
			"old_mode", perm.String(),
			"want_mode", newPerm.String(),
			"error", err,
		)
		return
	}
	logger.Info("permissions watcher: fixed bob state dir permissions",
		"path", path,
		"old_mode", perm.String(),
		"new_mode", newPerm.String(),
	)
}
