// 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 uninstall

import (
	"bufio"
	"errors"
	"fmt"
	"io"
	"log/slog"
	"strings"

	"github.com/eminwux/kukeon/cmd/config"
	kukshared "github.com/eminwux/kukeon/cmd/kuke/shared"
	"github.com/eminwux/kukeon/cmd/types"
	"github.com/eminwux/kukeon/internal/controller"
	"github.com/eminwux/kukeon/internal/errdefs"
	"github.com/spf13/cobra"
	"github.com/spf13/viper"
)

// ErrAborted is returned when the interactive confirmation prompt receives
// any answer other than "yes"/"y". It signals a clean abort to cobra so the
// process exits non-zero, matching the documented invariant in
// docs/cli-use-cases.md § "Full per-host teardown" (EOF or non-`yes` answer
// aborts with non-zero exit and no destructive side effect).
var ErrAborted = errors.New("uninstall aborted")

// MockControllerKey injects a mock controller via cmd.Context() for tests.
type MockControllerKey struct{}

func NewUninstallCmd() *cobra.Command {
	cmd := &cobra.Command{
		Use:          "uninstall",
		Short:        "Remove all kukeon runtime state from this host",
		Long:         uninstallLongDesc,
		RunE:         runUninstall,
		SilenceUsage: true,
	}

	if err := setupUninstallCmd(cmd); err != nil {
		// setupUninstallCmd only fails if viper.BindPFlag is handed a nil
		// flag — unreachable by construction here. Panic surfaces a future
		// flag-rename or typo at startup instead of letting nil flow into
		// cobra.AddCommand and crash the root command registration.
		panic(fmt.Sprintf("kukeon: uninstall command misconfigured: %v", err))
	}
	return cmd
}

const uninstallLongDesc = `Remove all kukeon runtime state from this host.

This is the global counterpart to "kuke purge" (which is per-resource). It:
  1. Stops, disables, and removes the kukeond systemd unit (no-op on hosts
     without systemd, or where the unit was never installed). Done first so
     a Restart=on-failure unit cannot respawn kukeond mid-uninstall.
  2. Purges every realm with --cascade (drains spaces, stacks, cells,
     containers and their containerd tasks/containers, and deletes the
     containerd namespaces created by kukeon). For every realm whose
     namespace was actually removed, also reclaims the matching per-
     namespace BuildKit cache directory at /var/lib/kukebuild/<namespace>/
     (the cache references containerd by snapshot ID and content digest, so
     leaving it behind strands the next "kuke build" with "parent snapshot
     does not exist" or "content digest ... not found"). After the sweep,
     /var/lib/kukebuild/ itself is rmdir'd if empty — cousin instances'
     caches under sibling subdirs are left in place.
  3. Removes /run/kukeon/ recursively.
  4. Removes the configured run path (default /opt/kukeon) recursively.
  5. Removes the kukeon system user and group (no-op if absent).

If any realm fails to drop its containerd namespace, steps 3-5 are skipped:
tearing out /opt/kukeon while a residual namespace is still pinning overlay
mounts on disk would strand the next "kuke init" with stale containerd state.
The report flags every dir/account row as "skipped (realm purge failed)" so
the half-cleaned host is visible without scrolling to the trailing error.
Resolve the realm-purge failure (often: stop the live daemon, remove the
residual containerd namespace by hand, wipe the matching
/var/lib/kukebuild/<namespace>/ left behind by the partial pass) and re-run
the command.

The /usr/local/bin/kuke binary and the kukeond symlink are NOT removed —
uninstalling runtime state is not the same as uninstalling the binary.

By default the command asks for interactive confirmation. Pass --yes/-y to
skip the prompt (use this in scripts).`

func setupUninstallCmd(cmd *cobra.Command) error {
	cmd.Flags().BoolP("yes", "y", false, "Skip the interactive confirmation prompt")
	if err := viper.BindPFlag(config.KUKE_UNINSTALL_YES.ViperKey, cmd.Flags().Lookup("yes")); err != nil {
		return fmt.Errorf("failed to bind flag: %w", err)
	}

	// `--no-daemon` is retained on uninstall per #222 ACs. uninstall is
	// always in-process by construction (it tears down the daemon) — the
	// flag is accepted as a no-op so existing operator muscle memory does
	// not break.
	kukshared.RegisterNoDaemonFlag(cmd)

	// --server-configuration targets a specific kukeond instance, via the
	// shared precedence chain (flag > KUKEOND_CONFIGURATION env > default
	// file > hardcoded defaults). Scopes uninstall's suffix-gated namespace
	// enumeration to the configured instance — an uninstall on dev cannot
	// purge prod namespaces. Issue #284.
	kukshared.RegisterServerConfigurationFlag(cmd)

	return nil
}

func runUninstall(cmd *cobra.Command, _ []string) error {
	logger, ok := cmd.Context().Value(types.CtxLogger).(*slog.Logger)
	if !ok || logger == nil {
		return errdefs.ErrLoggerNotFound
	}

	// Resolve --server-configuration before reading runPath / socketPath /
	// suffix from viper, so a `kuke uninstall --server-configuration
	// ./kukeond-dev.yaml` scopes both the filesystem teardown and the
	// suffix-gated namespace enumeration (collectRealmsForUninstall) to
	// the dev instance only. Issue #284.
	if _, _, err := kukshared.LoadServerConfigurationFromFlag(cmd); err != nil {
		return err
	}

	socketPath := viper.GetString(config.KUKEOND_SOCKET.ViperKey)
	if socketPath == "" {
		socketPath = config.KUKEOND_SOCKET.Default
	}
	socketDir := socketDir(socketPath)
	runPath := viper.GetString(config.KUKEON_ROOT_RUN_PATH.ViperKey)
	if runPath == "" {
		runPath = config.DefaultRunPath()
	}

	if !viper.GetBool(config.KUKE_UNINSTALL_YES.ViperKey) {
		confirmed, err := confirm(cmd, runPath, socketDir)
		if err != nil {
			return err
		}
		if !confirmed {
			cmd.Println("Aborted.")
			return ErrAborted
		}
	}

	// systemd unit teardown runs before realm/filesystem teardown per
	// issue #541 AC #4: leaving Restart=on-failure on the unit while the
	// controller's PID-file-based daemon-stop step fires would let systemd
	// respawn kukeond mid-uninstall. Idempotent on hosts where the unit
	// was never installed (dev hosts via `make dev-init`, systemd-less
	// distros).
	systemdRemover := resolveSystemdRemover(cmd)
	systemdReport := systemdRemover(cmd.Context())
	printSystemdRemoval(cmd, systemdReport)
	if systemdReport.Err != nil {
		return systemdReport.Err
	}

	ctrl := resolveController(cmd, logger, runPath)
	defer func() { _ = ctrl.Close() }()

	report, err := ctrl.Uninstall(controller.UninstallOptions{
		SocketDir: socketDir,
	})
	printReport(cmd, report)
	return err
}

// resolveSystemdRemover returns the SystemdRemover used by runUninstall.
// Tests inject a stub via MockSystemdRemoverKey; production always runs the
// real systemctl-driven implementation.
func resolveSystemdRemover(cmd *cobra.Command) SystemdRemover {
	if mock, ok := cmd.Context().Value(MockSystemdRemoverKey{}).(SystemdRemover); ok {
		return mock
	}
	return removeKukeondSystemdUnit
}

// printSystemdRemoval renders the SystemdRemovalReport as the first line of
// the uninstall output so the operator sees the unit-teardown outcome
// before the realm/filesystem rows. Quiet on the skip path: a host that
// never had the unit installed (e.g. `make dev-init`) should not emit a
// systemd row at all, matching the existing report's terse style on the
// no-daemon path.
func printSystemdRemoval(cmd *cobra.Command, r SystemdRemovalReport) {
	out := cmd.OutOrStdout()
	if r.Skipped {
		// Quiet skip — no systemd row at all.
		return
	}
	switch {
	case r.Err != nil:
		fmt.Fprintf(out, "  - systemd unit %s: failed: %v\n", r.UnitPath, r.Err)
	case r.Stopped && r.Disabled && r.FileRemoved && r.DaemonReloaded:
		fmt.Fprintf(out, "  - systemd unit %s: stopped, disabled, removed\n", r.UnitPath)
	default:
		// Partial: at least one best-effort sub-step did not succeed. Show
		// each independently so the operator can tell which (e.g. stop
		// failed because there's no systemd PID 1 vs. file remove
		// succeeded — the binding step did its job).
		fmt.Fprintf(out, "  - systemd unit %s: partial (stop=%t disable=%t remove=%t reload=%t)\n",
			r.UnitPath, r.Stopped, r.Disabled, r.FileRemoved, r.DaemonReloaded)
	}
}

func resolveController(cmd *cobra.Command, logger *slog.Logger, runPath string) controller.Controller {
	if mock, ok := cmd.Context().Value(MockControllerKey{}).(controller.Controller); ok {
		return mock
	}
	opts := controller.Options{
		RunPath:          runPath,
		ContainerdSocket: viper.GetString(config.KUKEON_ROOT_CONTAINERD_SOCKET.ViperKey),
	}
	return controller.NewControllerExec(cmd.Context(), logger, opts)
}

func confirm(cmd *cobra.Command, runPath, socketDir string) (bool, error) {
	out := cmd.OutOrStdout()
	fmt.Fprintln(out, "kuke uninstall will remove ALL kukeon runtime state on this host:")
	fmt.Fprintln(out, "  - the kukeond systemd unit (stop + disable + remove, if installed)")
	fmt.Fprintln(out, "  - every realm (cascade), including the kuke-system realm and the kukeond cell")
	fmt.Fprintln(out, "  - the kukeon containerd namespaces")
	fmt.Fprintf(out, "  - %s/ (kukeond socket dir)\n", socketDir)
	fmt.Fprintf(out, "  - %s/ (run path)\n", runPath)
	fmt.Fprintln(out, "  - the kukeon system user and group (if present)")
	fmt.Fprintln(out)

	in := cmd.InOrStdin()
	for {
		fmt.Fprint(out, "Are you sure? (yes/no) ")
		line, err := readLine(in)
		if err != nil {
			return false, fmt.Errorf("read confirmation: %w", err)
		}
		switch strings.ToLower(strings.TrimSpace(line)) {
		case "yes", "y":
			return true, nil
		case "no", "n", "":
			return false, nil
		}
	}
}

func readLine(r io.Reader) (string, error) {
	br := bufio.NewReader(r)
	line, err := br.ReadString('\n')
	if err != nil && (line == "" || err != io.EOF) {
		return line, err
	}
	return line, nil
}

func printReport(cmd *cobra.Command, report controller.UninstallReport) {
	out := cmd.OutOrStdout()
	fmt.Fprintln(out, "kuke uninstall:")
	if report.Daemon.PIDFile != "" {
		fmt.Fprintf(out, "  - kukeond: %s\n", daemonOutcome(report.Daemon))
	}
	for _, r := range report.Realms {
		switch {
		case r.Err != nil:
			fmt.Fprintf(out, "  - realm %q (namespace %q): FAILED: %v\n", r.Name, r.Namespace, r.Err)
		case r.Purged:
			fmt.Fprintf(out, "  - realm %q (namespace %q): purged\n", r.Name, r.Namespace)
		}
	}
	renderBuildCaches(out, report)
	if report.CleanupSkipped {
		fmt.Fprintf(out, "  - %s: %s\n", report.SocketDir, outcomeSkipped)
		fmt.Fprintf(out, "  - %s: %s\n", report.RunPath, outcomeSkipped)
		fmt.Fprintf(out, "  - user %q: %s\n", report.UserName, outcomeSkipped)
		fmt.Fprintf(out, "  - group %q: %s\n", report.GroupName, outcomeSkipped)
		fmt.Fprintln(out, "  filesystem + user/group cleanup skipped: residual containerd namespace prevented teardown")
		return
	}
	renderMountReleases(out, report.SocketDirMounts)
	fmt.Fprintf(out, "  - %s: %s\n", report.SocketDir, dirOutcome(report.SocketDirExists, report.SocketDirRemove))
	renderMountReleases(out, report.RunPathMounts)
	fmt.Fprintf(out, "  - %s: %s\n", report.RunPath, dirOutcome(report.RunPathExists, report.RunPathRemove))
	fmt.Fprintf(out, "  - user %q: %s\n", report.UserName, accountOutcome(report.UserExisted, report.UserRemoved))
	fmt.Fprintf(out, "  - group %q: %s\n", report.GroupName, accountOutcome(report.GroupExisted, report.GroupRemoved))
}

// renderBuildCaches prints one row per per-namespace BuildKit cache the
// uninstall pass reclaimed plus a trailing row for the base directory's
// fate. Surfacing both lets the operator confirm the issue #904 follow-up
// fired (the next `kuke build` into a freshly-reinstalled namespace will
// no longer hit "parent snapshot does not exist" or "content digest ...
// not found"). Quiet on hosts that never built into the namespace — an
// absent per-namespace cache contributes no row, matching the report's
// terse style on the no-daemon path.
func renderBuildCaches(out io.Writer, report controller.UninstallReport) {
	for _, c := range report.BuildCaches {
		switch {
		case c.Err != nil:
			fmt.Fprintf(out, "  - build cache %s: FAILED: %v\n", c.Path, c.Err)
		case c.Removed:
			fmt.Fprintf(out, "  - build cache %s: removed\n", c.Path)
		case c.Existed:
			// Present-but-not-removed should never happen on the success path
			// (RemoveAll either succeeds or returns an error captured above);
			// rendered defensively so a regression surfaces instead of being
			// silently dropped.
			fmt.Fprintf(out, "  - build cache %s: remove failed\n", c.Path)
		default:
			fmt.Fprintf(out, "  - build cache %s: %s\n", c.Path, outcomeAbsent)
		}
	}
	if report.BuildCacheBaseDir == "" {
		return
	}
	switch {
	case report.BuildCacheBaseRemoved:
		fmt.Fprintf(out, "  - build cache %s: removed\n", report.BuildCacheBaseDir)
	case report.BuildCacheBaseExisted:
		// The base dir survived the rmdir because a cousin instance's cache
		// is still under it — the scoped-uninstall invariant we wanted. Name
		// the path so an operator who did want it gone knows what to wipe.
		fmt.Fprintf(out, "  - build cache %s: kept (cousin instance caches present)\n", report.BuildCacheBaseDir)
	default:
		// Absent base directory on a host that never built — quiet skip
		// rather than a noisy "absent" row, matching how the renderer treats
		// a never-installed systemd unit.
	}
}

// renderMountReleases prints one indented row per kukeon-owned bind mount
// the controller tried to release before the rmdir step. Skipped when no
// mounts were enumerated under the parent directory so the common case
// (host with no live attach sessions) stays terse.
func renderMountReleases(out io.Writer, attempts []controller.MountReleaseAttempt) {
	for _, a := range attempts {
		if a.Err != nil {
			// Residual mount: the operator needs the path + the kernel's
			// reason verbatim so manual recovery is obvious.
			fmt.Fprintf(out, "  - mount %s: still busy: %v\n", a.Target, a.Err)
			continue
		}
		if a.Released {
			fmt.Fprintf(out, "  - mount %s: unmounted\n", a.Target)
		}
	}
}

// outcomeAbsent labels every "we didn't find the resource" branch in the
// uninstall report so the operator sees the same word in every section.
const outcomeAbsent = "absent"

// outcomeSkipped labels filesystem + user/group rows when the half-cleaned
// host gate (issue #287) blocks teardown because a realm failed to drop its
// containerd namespace.
const outcomeSkipped = "skipped (realm purge failed)"

func daemonOutcome(d controller.DaemonStopReport) string {
	switch {
	case !d.PIDFilePresent:
		return outcomeAbsent
	case d.ForceKilled:
		return fmt.Sprintf("force-killed (pid %d)", d.PID)
	case d.Signalled:
		return fmt.Sprintf("stopped (pid %d)", d.PID)
	default:
		return "stale pid file"
	}
}

func dirOutcome(existed, removed bool) string {
	switch {
	case removed:
		return "removed"
	case existed:
		return "remove failed"
	default:
		return outcomeAbsent
	}
}

func accountOutcome(existed, removed bool) string {
	switch {
	case removed:
		return "removed"
	case existed:
		return "remove failed"
	default:
		return outcomeAbsent
	}
}

// socketDir mirrors the helper in controller/bootstrap.go so the CLI does
// not need to reach into that package just to derive the socket parent.
func socketDir(socketPath string) string {
	for i := len(socketPath) - 1; i >= 0; i-- {
		if socketPath[i] == '/' {
			if i == 0 {
				return "/"
			}
			return socketPath[:i]
		}
	}
	return "/run/kukeon"
}
