// Copyright 2025 Emiliano Spinella (eminwux)
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// SPDX-License-Identifier: Apache-2.0

package controller

import (
	"context"
	"errors"
	"fmt"
	"os"
	"os/exec"
	"path/filepath"
	"sort"
	"syscall"
	"time"

	"github.com/eminwux/kukeon/internal/consts"
	intmodel "github.com/eminwux/kukeon/internal/modelhub"
)

// UserGroupRemover removes a system user and group from the host. The real
// implementation shells out to userdel/groupdel; tests inject a stub.
type UserGroupRemover func(ctx context.Context, user, group string) (UserGroupRemovalReport, error)

// UserGroupRemovalReport mirrors the user/group fields on UninstallReport
// without forcing the implementation to know the report type's layout.
type UserGroupRemovalReport struct {
	UserExisted  bool
	UserRemoved  bool
	GroupExisted bool
	GroupRemoved bool
}

// UninstallOptions configures Uninstall.
type UninstallOptions struct {
	// SocketDir is the parent directory of the kukeond unix socket
	// (typically /run/kukeon). Removed recursively when non-empty.
	SocketDir string
	// SystemUser/SystemGroup are the names of the kukeon system user and
	// group to remove. Defaults to "kukeon"/"kukeon" when empty.
	SystemUser  string
	SystemGroup string
	// SkipUserGroup skips userdel/groupdel. Set in tests where touching
	// the host's /etc/passwd is undesirable.
	SkipUserGroup bool
	// UserGroupRemover overrides the default userdel/groupdel routine.
	// Tests inject a stub; production callers leave it nil.
	UserGroupRemover UserGroupRemover
	// KukeondPIDFile points at the kukeond PID file. The daemon-stop step
	// signals the live daemon before any realm purge, so a fight between
	// the in-process PurgeRealm and the running daemon's containerd session
	// can never block the namespace delete with "namespace not empty".
	// Empty disables the daemon-stop step; absent file is a no-op (the
	// partial-uninstall path from #193).
	KukeondPIDFile string
	// DaemonStopper overrides the default PID-file-based stopper. Tests
	// inject a stub; production callers leave it nil.
	DaemonStopper DaemonStopper
	// DaemonStopGracePeriod is the SIGTERM→SIGKILL grace window. Zero uses
	// DefaultDaemonStopGracePeriod (5s).
	DaemonStopGracePeriod time.Duration
	// MountReleaser releases live bind mounts under SocketDir and RunPath
	// before the rmdir step. Production callers leave it nil (defaults to
	// reading /proc/self/mounts + syscall.Unmount); tests inject a stub so
	// they do not have to provision real mounts.
	MountReleaser MountReleaser
	// BuildCacheBaseDir overrides the directory under which `kukebuild`
	// keeps its per-namespace BuildKit state (issue #904). Empty falls
	// back to consts.KukebuildBaseDir; tests pin t.TempDir() so they do
	// not have to write under /var/lib.
	BuildCacheBaseDir string
}

// RealmPurgeOutcome reports the result of purging a single realm.
//
// Purged is the high-level "did the cleanup function complete without a
// fatal error" flag; NamespaceRemoved is the narrower "did the containerd
// namespace actually get removed" signal — the residual-namespace bug from
// issue #193 is the case where Purged was true but NamespaceRemoved was
// false, leaving the caller with a misleading "purged" report.
type RealmPurgeOutcome struct {
	Name             string
	Namespace        string
	Purged           bool
	NamespaceRemoved bool
	Err              error
}

// BuildCachePurgeOutcome reports the result of reclaiming one realm's
// per-namespace BuildKit state directory at <BuildCacheBaseDir>/<namespace>
// (issue #904). The reclaim only runs for realms whose containerd namespace
// was actually removed (NamespaceRemoved=true); a residual namespace's cache
// is intentionally left in place so a follow-up `kuke uninstall` still has
// the BuildKit metadata to drive its own teardown.
//
// Existed is "did we find the per-namespace dir before removal" — the
// renderer's analog of the dirOutcome existed/removed pair used for the
// socket and run-path rows.
type BuildCachePurgeOutcome struct {
	Namespace string
	Path      string
	Existed   bool
	Removed   bool
	Err       error
}

// UninstallReport summarizes what Uninstall did.
//
// CleanupSkipped is true when the filesystem + user/group teardown steps were
// skipped because at least one realm failed to drop its containerd namespace.
// Tearing out /opt/kukeon while a residual namespace is still pinning overlay
// mounts on disk left a half-cleaned host where the next `kuke init` had to
// coexist with stale containerd state — see issue #287.
type UninstallReport struct {
	Daemon          DaemonStopReport
	Realms          []RealmPurgeOutcome
	CleanupSkipped  bool
	SocketDir       string
	SocketDirExists bool
	SocketDirRemove bool
	// SocketDirMounts records every kukeon-owned bind mount found under
	// SocketDir at uninstall time and whether it was released. A non-empty
	// list with all entries Released=true is the success path: the rmdir
	// step that follows can now reach an empty directory. Any entry with
	// Err!=nil names a mountpoint the kernel refused to detach — surfacing
	// the busy target so the operator does not have to grep /proc/mounts to
	// find what blocked the teardown (the #434 regression).
	SocketDirMounts []MountReleaseAttempt
	RunPath         string
	RunPathExists   bool
	RunPathRemove   bool
	// RunPathMounts is the RunPath analogue of SocketDirMounts; the run
	// path is checked separately so a busy mount under /opt/kukeon does not
	// mask a busy mount under /run/kukeon (and vice versa).
	RunPathMounts []MountReleaseAttempt
	UserName      string
	UserExisted   bool
	UserRemoved   bool
	GroupName     string
	GroupExisted  bool
	GroupRemoved  bool
	// BuildCaches records the per-namespace BuildKit state directories the
	// uninstall pass reclaimed (issue #904). One entry per realm whose
	// containerd namespace was successfully removed; ordered to match
	// Realms. A realm whose namespace survived contributes no entry —
	// leaving its cache in place is intentional, see BuildCachePurgeOutcome.
	BuildCaches []BuildCachePurgeOutcome
	// BuildCacheBaseDir is the directory the renderer attempted to rmdir
	// after the per-namespace sweep. Empty when no BuildKit cache was
	// touched (no realm purged with NamespaceRemoved=true).
	BuildCacheBaseDir string
	// BuildCacheBaseExisted reports whether BuildCacheBaseDir existed at
	// the start of the rmdir step (after the per-namespace sweep). Lets the
	// renderer distinguish "absent" from "kept".
	BuildCacheBaseExisted bool
	// BuildCacheBaseRemoved reports whether BuildCacheBaseDir was rmdir'd.
	// Only true when the base directory was empty after the per-namespace
	// sweep — a scoped uninstall (`--server-configuration` narrowing the
	// realm set) leaves cousin-instance caches untouched and the base dir
	// stays. Plain `os.Remove`, not `RemoveAll`: stomping on another
	// instance's cache would be a regression of the scoping invariant.
	BuildCacheBaseRemoved bool
}

// Uninstall performs a comprehensive teardown of all kukeon runtime state.
// Steps (in order):
//  0. Stop the kukeond daemon (SIGTERM, then SIGKILL after a short grace) when
//     a PID file is present. Doing this before any per-realm purge prevents
//     the live daemon from racing the in-process containerd cleanup and
//     pinning containers in `kuke-system.kukeon.io` while we are trying to
//     drain it.
//  1. Purge every realm with --cascade --force (drains spaces/stacks/cells/
//     containers + tasks; the kukeond cell living inside `kuke-system` is
//     killed and deleted as part of that cascade). Realms are enumerated by
//     merging on-disk metadata with the set of containerd namespaces whose
//     name carries the `.kukeon.io` suffix, so user-created realms whose
//     metadata was wiped before `kuke uninstall` (the #193 partial-state
//     path) still get their namespaces cleaned up. The two well-known
//     realms (`default`, `kuke-system`) are kept as a safety floor so a
//     containerd-list failure cannot strand them.
//
//     For every realm whose containerd namespace was actually removed, also
//     reclaim the matching per-namespace BuildKit cache directory at
//     <BuildCacheBaseDir>/<namespace> (default consts.KukebuildBaseDir). The
//     cache references containerd by snapshot ID and content digest; a
//     surviving cache after a successful namespace drop strands the next
//     `kuke build` with "parent snapshot does not exist" or "content digest
//     ... not found" — issue #904.
//  2. Release live kukeon-owned bind mounts under SocketDir, then RemoveAll
//     on SocketDir (typically /run/kukeon). The unmount step is what makes a
//     post-attach uninstall succeed: kuketty leaves a /run/kukeon/tty bind
//     mount behind, and rmdir refuses to descend through it (the #434
//     regression). A plain umount is tried first, falling back to lazy
//     MNT_DETACH so the parent dir can still be removed when the mount has
//     stragglers holding it open.
//  3. The same release + RemoveAll for the run path (typically /opt/kukeon).
//  4. Remove the kukeon system user and group (no-op if absent).
//  5. After the per-namespace cache sweep, try a plain rmdir on
//     BuildCacheBaseDir — only succeeds when it is empty so a scoped
//     uninstall (`--server-configuration` narrowing realm enumeration to one
//     suffix) leaves cousin instances' caches under sibling subdirs intact.
//
// Steps 2–4 are gated on every realm reporting NamespaceRemoved=true. When at
// least one realm fails to drop its containerd namespace, the report's
// CleanupSkipped flag is set and the filesystem + user/group teardown is left
// for a follow-up run — see issue #287 for the half-cleaned-host failure
// mode this prevents. The per-namespace BuildKit cache sweep (the cache
// reclaim half of step 1) is **not** gated on that flag: a namespace that
// was actually removed should have its cache reclaimed even when a sibling
// realm's purge failed.
//
// Any step's error is recorded in the report. The first non-nil step error
// is returned so callers can surface "uninstall failed at step X" without
// dropping subsequent best-effort cleanup.
func (b *Exec) Uninstall(opts UninstallOptions) (UninstallReport, error) {
	report := UninstallReport{
		SocketDir: opts.SocketDir,
		RunPath:   b.opts.RunPath,
	}

	user := opts.SystemUser
	if user == "" {
		user = "kukeon"
	}
	group := opts.SystemGroup
	if group == "" {
		group = "kukeon"
	}
	report.UserName = user
	report.GroupName = group

	var firstErr error
	recordErr := func(err error) {
		if err != nil && firstErr == nil {
			firstErr = err
		}
	}

	// Step 0: stop the live kukeond daemon. Do this before realm enumeration
	// so the controller's containerd client is not racing the daemon when
	// PurgeRealm starts draining the kuke-system namespace.
	//
	// The default PID-file path mirrors cmd/kukeond/serve.go's write path —
	// kukeond writes its PID to <socketDir>/kukeond.pid (sibling of the
	// socket, per the storage-layout.md design "Sockets and pid files belong
	// in /run"). Reading from any other location turns this step into a
	// silent no-op (the #287 regression the daemon-stop step from #195 was
	// supposed to prevent).
	pidFile := opts.KukeondPIDFile
	if pidFile == "" && opts.SocketDir != "" {
		pidFile = filepath.Join(opts.SocketDir, "kukeond.pid")
	}
	if pidFile != "" {
		stopper := opts.DaemonStopper
		if stopper == nil {
			stopper = stopDaemonByPIDFile
		}
		grace := opts.DaemonStopGracePeriod
		if grace <= 0 {
			grace = DefaultDaemonStopGracePeriod
		}
		daemonReport, daemonErr := stopper(b.ctx, pidFile, grace)
		// Always record what the stopper saw, even on error — the report's
		// PIDFile/PID/Signalled fields are how the CLI tells the operator
		// whether the daemon-stop step actually fired.
		report.Daemon = daemonReport
		if daemonErr != nil {
			b.logger.WarnContext(
				b.ctx,
				"uninstall: daemon-stop step failed; continuing with realm purge",
				"pidFile",
				pidFile,
				"error",
				daemonErr,
			)
			recordErr(fmt.Errorf("stop kukeond: %w", daemonErr))
		}
	}

	// Step 1: enumerate realms and purge each.
	realms, listErr := b.collectRealmsForUninstall()
	if listErr != nil {
		// If listing fails we still attempt the well-known realms — partial
		// uninstall cleanup must not be blocked by a stale metadata read.
		b.logger.WarnContext(
			b.ctx,
			"uninstall: failed to list realms; proceeding with well-known realms only",
			"error",
			listErr,
		)
	}

	buildCacheBase := opts.BuildCacheBaseDir
	if buildCacheBase == "" {
		buildCacheBase = consts.KukebuildBaseDir
	}

	allNamespacesRemoved := true
	for _, realm := range realms {
		outcome := RealmPurgeOutcome{
			Name:      realm.Metadata.Name,
			Namespace: realm.Spec.Namespace,
		}
		result, err := b.PurgeRealm(realm, true, true)
		outcome.NamespaceRemoved = result.NamespaceRemoved
		if err != nil {
			outcome.Err = err
			recordErr(fmt.Errorf("purge realm %q: %w", realm.Metadata.Name, err))
		} else {
			outcome.Purged = true
		}
		if !outcome.NamespaceRemoved {
			allNamespacesRemoved = false
		}
		report.Realms = append(report.Realms, outcome)

		// Reclaim the per-namespace BuildKit cache directory only when the
		// containerd namespace was actually removed. Pairing the cache wipe
		// with the namespace wipe keeps the BuildKit cache from referencing
		// snapshots or content blobs that no longer exist in the (now empty
		// or absent) namespace — issue #904. A realm whose namespace
		// survived is intentionally skipped so the operator's follow-up
		// recovery has the same cache metadata available that the previous
		// builds saw.
		if outcome.NamespaceRemoved && realm.Spec.Namespace != "" {
			cacheOutcome := reclaimNamespaceBuildCache(buildCacheBase, realm.Spec.Namespace)
			report.BuildCaches = append(report.BuildCaches, cacheOutcome)
			if cacheOutcome.Err != nil {
				recordErr(fmt.Errorf(
					"remove build cache %q: %w", cacheOutcome.Path, cacheOutcome.Err,
				))
			}
		}
	}

	// Step 5 (early): try to rmdir the BuildKit cache base directory. A plain
	// os.Remove fails with ENOTEMPTY when a cousin instance's cache is still
	// under it, which is the right behavior for a scoped uninstall — the
	// renderer prints "kept (not empty)" instead of "removed" and the operator
	// sees the base dir survived for a reason. Done before the half-cleaned-
	// host gate returns so the BuildKit cache rows surface in the report even
	// when the SocketDir/RunPath teardown is suppressed.
	report.BuildCacheBaseDir = buildCacheBase
	rmBaseExisted, rmBaseRemoved, rmBaseErr := rmdirIfEmpty(buildCacheBase)
	report.BuildCacheBaseExisted = rmBaseExisted
	report.BuildCacheBaseRemoved = rmBaseRemoved
	if rmBaseErr != nil {
		recordErr(fmt.Errorf("remove build cache base %q: %w", buildCacheBase, rmBaseErr))
	}

	// Half-cleaned-host gate (issue #287): if any realm failed to drop its
	// containerd namespace, leave /run/kukeon, /opt/kukeon, and the kukeon
	// system user/group intact. Tearing those out while overlay snapshots in
	// the residual namespace are still pinning files on disk strands the next
	// `kuke init` with stale containerd state — and worse, can rip the bind
	// mounts out from under a still-live daemon.
	if !allNamespacesRemoved {
		report.CleanupSkipped = true
		return report, firstErr
	}

	releaser := opts.MountReleaser
	if releaser == nil {
		releaser = releaseMountsUnder
	}

	// Step 2: tear down /run/kukeon.
	if opts.SocketDir != "" {
		mounts, residualErr := releaseAndRecord(releaser, opts.SocketDir, "socket dir", recordErr)
		report.SocketDirMounts = mounts
		// Removing the dir even after residual mounts is intentional: the
		// rmdir will fail with EBUSY and the next-row "remove failed" output
		// plus the residual MountReleaseAttempt entries together tell the
		// operator exactly which bind kept it pinned. Aborting early would
		// hide the parallel /opt/kukeon teardown and leave the operator with
		// half the picture.
		_ = residualErr
		exists, removed, err := removePathIfExists(opts.SocketDir)
		report.SocketDirExists = exists
		report.SocketDirRemove = removed
		if err != nil {
			recordErr(fmt.Errorf("remove socket dir %q: %w", opts.SocketDir, err))
		}
	}

	// Step 3: tear down /opt/kukeon.
	if b.opts.RunPath != "" {
		mounts, residualErr := releaseAndRecord(releaser, b.opts.RunPath, "run path", recordErr)
		report.RunPathMounts = mounts
		_ = residualErr
		exists, removed, err := removePathIfExists(b.opts.RunPath)
		report.RunPathExists = exists
		report.RunPathRemove = removed
		if err != nil {
			recordErr(fmt.Errorf("remove run path %q: %w", b.opts.RunPath, err))
		}
	}

	// Step 4: drop the kukeon system user/group.
	if !opts.SkipUserGroup {
		remover := opts.UserGroupRemover
		if remover == nil {
			remover = removeSystemUserGroup
		}
		userReport, err := remover(b.ctx, user, group)
		report.UserExisted = userReport.UserExisted
		report.UserRemoved = userReport.UserRemoved
		report.GroupExisted = userReport.GroupExisted
		report.GroupRemoved = userReport.GroupRemoved
		if err != nil {
			recordErr(fmt.Errorf("remove user/group: %w", err))
		}
	}

	return report, firstErr
}

// collectRealmsForUninstall returns every realm that should be purged,
// merging three sources so a partial-uninstall path (metadata wiped before
// `kuke uninstall`) still cleans up containerd namespaces:
//  1. on-disk metadata via runner.ListRealms (the source of truth when present),
//  2. live containerd namespaces matching `*.kukeon.io` (catches user-created
//     realms whose metadata is gone — issue #193's partial-state path),
//  3. the two well-known realms (`default`, `kuke-system`) as a safety floor
//     so a failed containerd enumeration cannot strand them.
//
// The merge is order-stable: on-disk realms first (preserving ListRealms'
// insertion order), then suffix-enumerated namespaces, then the well-known
// floor — each de-duplicated against the previous step. Insertion order
// matters because the renderer prints realms in this order, and operators
// expect their named realms before "default" and "kuke-system".
func (b *Exec) collectRealmsForUninstall() ([]intmodel.Realm, error) {
	wellKnown := []intmodel.Realm{
		{
			Metadata: intmodel.RealmMetadata{Name: consts.KukeonDefaultRealmName},
			Spec:     intmodel.RealmSpec{Namespace: consts.RealmNamespace(consts.KukeonDefaultRealmName)},
		},
		{
			Metadata: intmodel.RealmMetadata{Name: consts.KukeSystemRealmName},
			Spec:     intmodel.RealmSpec{Namespace: consts.RealmNamespace(consts.KukeSystemRealmName)},
		},
	}

	listed, listErr := b.runner.ListRealms()

	// Headroom for suffix-enumerated realms beyond the on-disk + well-known
	// floor; small fixed reserve avoids an extra growth alloc on the common
	// case of one or two user-created realms.
	const suffixEnumeratedSlack = 4
	seen := make(map[string]struct{}, len(listed)+len(wellKnown)+suffixEnumeratedSlack)
	out := make([]intmodel.Realm, 0, len(listed)+len(wellKnown)+suffixEnumeratedSlack)
	for _, realm := range listed {
		seen[realm.Metadata.Name] = struct{}{}
		out = append(out, realm)
	}

	suffixRealms, suffixErr := b.realmsFromContainerdNamespaces()
	if suffixErr != nil {
		b.logger.WarnContext(
			b.ctx,
			"uninstall: failed to enumerate containerd namespaces by suffix; falling back to well-known realms",
			"error",
			suffixErr,
		)
	}
	for _, realm := range suffixRealms {
		if _, ok := seen[realm.Metadata.Name]; ok {
			continue
		}
		seen[realm.Metadata.Name] = struct{}{}
		out = append(out, realm)
	}

	for _, realm := range wellKnown {
		if _, ok := seen[realm.Metadata.Name]; ok {
			continue
		}
		out = append(out, realm)
	}
	return out, listErr
}

// realmsFromContainerdNamespaces enumerates every containerd namespace and
// returns realms for those whose name carries the `.kukeon.io` suffix. The
// runner's containerd client is the single source of truth — non-kukeon
// namespaces like "default" or "moby" are filtered out so this path cannot
// accidentally purge a co-tenant's namespace. Returns the list deterministic
// (sorted) so the report ordering does not flap between runs.
func (b *Exec) realmsFromContainerdNamespaces() ([]intmodel.Realm, error) {
	namespaces, err := b.runner.ListContainerdNamespaces()
	if err != nil {
		return nil, err
	}
	sort.Strings(namespaces)
	out := make([]intmodel.Realm, 0, len(namespaces))
	for _, ns := range namespaces {
		if !consts.IsKukeonNamespace(ns) {
			continue
		}
		realmName := consts.RealmFromNamespace(ns)
		if realmName == "" {
			continue
		}
		out = append(out, intmodel.Realm{
			Metadata: intmodel.RealmMetadata{Name: realmName},
			Spec:     intmodel.RealmSpec{Namespace: ns},
		})
	}
	return out, nil
}

// releaseAndRecord drives the MountReleaser for one kukeon-owned directory,
// turning any enumeration error and any per-mount residual into recordErr
// callbacks (so they surface in the returned firstErr) while still handing
// the per-mount attempts back to the caller for the report. label names the
// directory class ("socket dir" / "run path") so the wrapped error is
// readable in CLI output.
func releaseAndRecord(
	releaser MountReleaser,
	root string,
	label string,
	recordErr func(error),
) ([]MountReleaseAttempt, error) {
	attempts, enumErr := releaser(root)
	if enumErr != nil {
		recordErr(fmt.Errorf("enumerate mounts under %s %q: %w", label, root, enumErr))
		return attempts, enumErr
	}
	var firstResidual error
	for _, attempt := range attempts {
		if attempt.Err == nil {
			continue
		}
		// Name the mountpoint in the wrapped error so a caller printing the
		// returned error verbatim (e.g. cobra's "Error:" line) still sees
		// the surviving target without having to walk the report — the
		// #434 AC requires the operator-visible message to name what the
		// kernel refused to release.
		wrapped := fmt.Errorf(
			"release mount %q under %s %q: %w",
			attempt.Target, label, root, attempt.Err,
		)
		recordErr(wrapped)
		if firstResidual == nil {
			firstResidual = wrapped
		}
	}
	return attempts, firstResidual
}

// reclaimNamespaceBuildCache removes the per-namespace BuildKit state
// directory at <baseDir>/<namespace>. Returns a BuildCachePurgeOutcome
// populated with Existed / Removed / Err so the renderer can surface what
// happened. A missing directory is the absent-on-clean-host case and is
// reported as Existed=false, Removed=false, Err=nil. Issue #904.
func reclaimNamespaceBuildCache(baseDir, namespace string) BuildCachePurgeOutcome {
	path := filepath.Join(baseDir, namespace)
	outcome := BuildCachePurgeOutcome{
		Namespace: namespace,
		Path:      path,
	}
	if _, statErr := os.Stat(path); statErr != nil {
		if os.IsNotExist(statErr) {
			return outcome
		}
		outcome.Err = statErr
		return outcome
	}
	outcome.Existed = true
	if rmErr := os.RemoveAll(path); rmErr != nil {
		outcome.Err = rmErr
		return outcome
	}
	outcome.Removed = true
	return outcome
}

// rmdirIfEmpty attempts a plain rmdir (not RemoveAll) on path and reports
// whether it existed and whether it was removed. ENOTEMPTY is the expected
// no-op when a scoped uninstall has left cousin-instance caches under the
// base dir — it returns (existed=true, removed=false, err=nil) so the
// renderer can print "kept (not empty)" instead of misreporting a failure.
// All other errors (permission, missing parent on the way to a nested rmdir,
// etc.) propagate as err.
func rmdirIfEmpty(path string) (bool, bool, error) {
	if _, statErr := os.Stat(path); statErr != nil {
		if os.IsNotExist(statErr) {
			return false, false, nil
		}
		return false, false, statErr
	}
	if rmErr := os.Remove(path); rmErr != nil {
		// ENOTEMPTY (and the BSD-aliased ENOTEMPTY=EEXIST on some systems)
		// is the "other instance's cache is still here" case; it is not an
		// error from the caller's perspective. Anything else is real.
		if errors.Is(rmErr, syscall.ENOTEMPTY) || errors.Is(rmErr, syscall.EEXIST) {
			return true, false, nil
		}
		return true, false, rmErr
	}
	return true, true, nil
}

// removePathIfExists is a thin wrapper around os.RemoveAll that reports
// whether the path was present before removal. It treats os.IsNotExist as
// "already gone" so a re-run of `kuke uninstall` on a clean machine is a
// no-op rather than an error.
func removePathIfExists(path string) (bool, bool, error) {
	if _, statErr := os.Stat(path); statErr != nil {
		if os.IsNotExist(statErr) {
			return false, false, nil
		}
		return false, false, statErr
	}
	if rmErr := os.RemoveAll(path); rmErr != nil {
		return true, false, rmErr
	}
	return true, true, nil
}

// removeSystemUserGroup invokes userdel/groupdel for the kukeon system
// account. Both lookups go through `id`/`getent` so the function is a
// pure no-op on hosts where the account never existed (matches the
// "no-op if absent" acceptance criterion). userdel runs before groupdel
// so the primary group can be removed once the user is gone.
func removeSystemUserGroup(ctx context.Context, user, group string) (UserGroupRemovalReport, error) {
	var report UserGroupRemovalReport

	report.UserExisted = lookupUser(ctx, user)
	if report.UserExisted {
		if delErr := exec.CommandContext(ctx, "userdel", user).Run(); delErr != nil {
			// userdel returns 6 ("specified user doesn't exist") if the
			// account vanished between lookup and delete — treat that as
			// idempotent success.
			if lookupUser(ctx, user) {
				return report, fmt.Errorf("userdel %q: %w", user, delErr)
			}
			report.UserRemoved = true
		} else {
			report.UserRemoved = true
		}
	}

	report.GroupExisted = lookupGroup(ctx, group)
	if report.GroupExisted {
		if delErr := exec.CommandContext(ctx, "groupdel", group).Run(); delErr != nil {
			if lookupGroup(ctx, group) {
				return report, fmt.Errorf("groupdel %q: %w", group, delErr)
			}
			report.GroupRemoved = true
		} else {
			report.GroupRemoved = true
		}
	}
	return report, nil
}

func lookupUser(ctx context.Context, name string) bool {
	return exec.CommandContext(ctx, "id", "-u", name).Run() == nil
}

func lookupGroup(ctx context.Context, name string) bool {
	return exec.CommandContext(ctx, "getent", "group", name).Run() == nil
}
