package cli

import (
	"bufio"
	"context"
	"errors"
	"fmt"
	"io"
	"os"
	"strings"
	"sync"
	"text/tabwriter"

	"banger/internal/api"
	"banger/internal/config"
	"banger/internal/daemon/workspace"
	"banger/internal/model"
	"banger/internal/paths"
	"banger/internal/rpc"
	"banger/internal/system"

	"github.com/spf13/cobra"
)

func (d *deps) newVMCommand() *cobra.Command {
	cmd := &cobra.Command{
		Use:   "vm",
		Short: "Manage Firecracker microVM sandboxes",
		Long: strings.TrimSpace(`
Lifecycle commands for banger's microVMs.

For most cases you want 'banger vm run' — it creates, starts,
provisions ssh, and drops you into the guest in one command. Use
'vm create' / 'vm start' / 'vm ssh' separately when you want a
longer-lived VM you'll come back to.

Quick reference:
  banger vm run                          interactive sandbox (stays alive on disconnect)
  banger vm run --rm -- script.sh        ephemeral: VM auto-deletes on exit
  banger vm run ./repo -- make test      ship a repo, run a command, exit with its status
  banger vm run --nat ./repo             --nat: outbound internet (required for mise bootstrap)
  banger vm run -d ./repo --nat          -d/--detach: prep + bootstrap, exit (no ssh attach)
  banger vm create --name dev            persistent VM; pair with 'vm ssh'
  banger vm ssh <name>                   open a shell in a running VM
  banger vm exec <name> -- make test     run a command in the workspace with mise toolchain
  banger vm stop <name> | vm restart     graceful lifecycle
  banger vm kill <name>                  force-kill if stop hangs
  banger vm delete <name>                stop + remove disks
  banger ps  /  banger vm list           running / all VMs (use --all)
  banger vm logs <name>                  guest console + daemon log
  banger vm set --nat <name>             toggle NAT on an existing VM (--no-nat to remove)
  banger vm workspace prepare/export     ship a repo in, pull diffs back
`),
		Example: strings.TrimSpace(`
  banger vm run -- uname -a
  banger vm run ./project -- npm test
  banger vm create --name dev && banger vm workspace prepare dev . && banger vm exec dev -- make test
`),
		RunE: helpNoArgs,
	}
	cmd.AddCommand(
		d.newVMCreateCommand(),
		d.newVMRunCommand(),
		d.newVMListCommand(),
		d.newVMShowCommand(),
		d.newVMActionCommand("start", "Start a stopped VM", "vm.start"),
		d.newVMActionCommand("stop", "Stop a running VM gracefully", "vm.stop"),
		d.newVMKillCommand(),
		d.newVMActionCommand("restart", "Stop then start a VM", "vm.restart"),
		d.newVMDeleteCommand(),
		d.newVMPruneCommand(),
		d.newVMSetCommand(),
		d.newVMSSHCommand(),
		d.newVMExecCommand(),
		d.newVMWorkspaceCommand(),
		d.newVMLogsCommand(),
		d.newVMStatsCommand(),
		d.newVMPortsCommand(),
	)
	return cmd
}

func (d *deps) newVMRunCommand() *cobra.Command {
	defaults := effectiveVMDefaults()
	var (
		name              string
		imageName         string
		vcpu              = defaults.VCPUCount
		memory            = defaults.MemoryMiB
		systemOverlaySize = model.FormatSizeBytes(defaults.SystemOverlaySizeByte)
		workDiskSize      = model.FormatSizeBytes(defaults.WorkDiskSizeBytes)
		natEnabled        bool
		branchName        string
		fromRef           = "HEAD"
		removeOnExit      bool
		includeUntracked  bool
		dryRun            bool
		detach            bool
		skipBootstrap     bool
		verbose           bool
	)
	cmd := &cobra.Command{
		Use:   "run [path] [-- command args...]",
		Short: "Create and enter a sandbox VM",
		Long: strings.TrimSpace(`
Create a sandbox VM and either drop into an interactive shell or run a command.

Modes:
  banger vm run                          bare sandbox, drops into ssh
  banger vm run ./repo                   workspace sandbox, drops into ssh at /root/repo
  banger vm run ./repo -- make test      workspace + run command, exit with its status
  banger vm run --rm -- script.sh        ephemeral: VM auto-deletes when the session/command exits
  banger vm run -d ./repo                workspace + bootstrap, exit (reconnect with 'vm ssh')

Workspace mode (path argument):
  Passing a path copies the repo's git-tracked files into /root/repo
  inside the guest. Untracked files are skipped by default — pass
  --include-untracked to ship them too, or --dry-run to preview the
  file list without creating a VM.

Outbound internet (--nat):
  Guests have no internet access by default. Pass --nat to enable
  host-side MASQUERADE so the VM can reach the public network. NAT is
  required whenever the workspace declares mise tooling (see below).
  Toggle on an existing VM with 'banger vm set --nat <name>'.

Tooling bootstrap (workspace mode):
  When the workspace contains a .mise.toml or .tool-versions, vm run
  installs the listed tools via mise on first boot. The bootstrap
  needs internet, so --nat must be set. Pass --no-bootstrap to skip
  it entirely (no NAT requirement).

Exit behaviour:
  In command mode (-- <cmd>), the guest command's exit code propagates
  through banger. Without --rm, the VM stays alive after the session
  or command exits — reconnect with 'banger vm ssh <name>'. With --rm,
  the VM is deleted on exit (stdout/stderr are preserved).
`),
		Args: cobra.ArbitraryArgs,
		Example: strings.TrimSpace(`
  banger vm run
  banger vm run ../repo --name agent-box --branch feature/demo
  banger vm run ../repo -- make test
  banger vm run -d ../repo --nat
  banger vm run -- uname -a
`),
		RunE: func(cmd *cobra.Command, args []string) error {
			if cmd.Flags().Changed("branch") && strings.TrimSpace(branchName) == "" {
				return errors.New("--branch requires a branch name")
			}
			if cmd.Flags().Changed("from") && strings.TrimSpace(branchName) == "" {
				return errors.New("--from requires --branch")
			}

			pathArgs, commandArgs := splitVMRunArgs(cmd, args)
			if len(pathArgs) > 1 {
				return errors.New("usage: banger vm run [path] [-- command args...]")
			}
			sourcePath := ""
			if len(pathArgs) == 1 {
				sourcePath = pathArgs[0]
			}
			if sourcePath == "" && strings.TrimSpace(branchName) != "" {
				return errors.New("--branch requires a path argument")
			}
			if detach && removeOnExit {
				return errors.New("cannot combine --detach with --rm")
			}
			if detach && len(commandArgs) > 0 {
				return errors.New("cannot combine --detach with a guest command")
			}

			var repoPtr *vmRunRepo
			if sourcePath != "" {
				resolved, err := d.vmRunPreflightRepo(cmd.Context(), sourcePath)
				if err != nil {
					return err
				}
				repoPtr = &vmRunRepo{sourcePath: resolved, branchName: branchName, fromRef: fromRef, includeUntracked: includeUntracked}
			}
			if dryRun {
				if repoPtr == nil {
					return errors.New("--dry-run requires a workspace path")
				}
				dryFromRef := ""
				if strings.TrimSpace(repoPtr.branchName) != "" {
					dryFromRef = repoPtr.fromRef
				}
				return d.runWorkspaceDryRun(cmd.Context(), cmd.OutOrStdout(), repoPtr.sourcePath, repoPtr.branchName, dryFromRef, repoPtr.includeUntracked)
			}

			layout, err := paths.Resolve()
			if err != nil {
				return err
			}
			cfg, err := config.Load(layout)
			if err != nil {
				return err
			}
			if repoPtr != nil {
				if err := validateVMRunPrereqs(cfg); err != nil {
					return err
				}
			} else {
				if err := validateSSHPrereqs(cfg); err != nil {
					return err
				}
			}
			params, err := vmCreateParamsFromFlags(cmd, name, imageName, vcpu, memory, systemOverlaySize, workDiskSize, natEnabled, false)
			if err != nil {
				return err
			}
			layout, cfg, err = d.ensureDaemon(cmd.Context())
			if err != nil {
				return err
			}
			return d.runVMRun(cmd.Context(), layout.SocketPath, cfg, cmd.InOrStdin(), cmd.OutOrStdout(), cmd.ErrOrStderr(), params, repoPtr, commandArgs, removeOnExit, detach, skipBootstrap, verbose)
		},
	}
	cmd.Flags().StringVar(&name, "name", "", "vm name")
	cmd.Flags().StringVar(&imageName, "image", "", "image name or id (defaults to config's default_image_name; auto-pulled from imagecat if missing)")
	cmd.Flags().IntVar(&vcpu, "vcpu", defaults.VCPUCount, "vcpu count")
	cmd.Flags().IntVar(&memory, "memory", defaults.MemoryMiB, "memory in MiB")
	cmd.Flags().StringVar(&systemOverlaySize, "system-overlay-size", model.FormatSizeBytes(defaults.SystemOverlaySizeByte), "system overlay size")
	cmd.Flags().StringVar(&workDiskSize, "disk-size", model.FormatSizeBytes(defaults.WorkDiskSizeBytes), "work disk size")
	cmd.Flags().BoolVar(&natEnabled, "nat", false, "enable outbound internet from the guest (host-side MASQUERADE; required when the workspace declares mise tooling)")
	cmd.Flags().StringVar(&branchName, "branch", "", "create and switch to a new guest branch")
	cmd.Flags().StringVar(&fromRef, "from", "HEAD", "git ref to branch from when --branch is set (default: HEAD)")
	cmd.Flags().BoolVar(&removeOnExit, "rm", false, "ephemeral mode: delete the VM (and its disks) after the ssh session / command exits")
	cmd.Flags().BoolVar(&includeUntracked, "include-untracked", false, "also copy untracked non-ignored files into the guest workspace (default: tracked files only)")
	cmd.Flags().BoolVar(&dryRun, "dry-run", false, "list the files that would be copied into the guest workspace and exit without creating a VM")
	cmd.Flags().BoolVarP(&detach, "detach", "d", false, "detached mode: create the VM, run workspace prep + bootstrap synchronously, exit without ssh attach (reconnect with 'vm ssh')")
	cmd.Flags().BoolVar(&skipBootstrap, "no-bootstrap", false, "skip the mise tooling bootstrap (no --nat requirement)")
	cmd.Flags().BoolVarP(&verbose, "verbose", "v", false, "show every progress line instead of a single rewriting status line")
	_ = cmd.RegisterFlagCompletionFunc("image", d.completeImageNames)
	return cmd
}

func (d *deps) newVMKillCommand() *cobra.Command {
	var signal string
	cmd := &cobra.Command{
		Use:   "kill <id-or-name>...",
		Short: "Force-kill a VM (use when 'vm stop' hangs)",
		Long: strings.TrimSpace(`
Send a signal directly to the firecracker process. Default is
SIGTERM; pass --signal SIGKILL when the VM is stuck and a graceful
'vm stop' has already failed.

This skips the normal stop sequence (no flush, no clean shutdown).
Prefer 'banger vm stop' for routine teardown.
`),
		Args:              minArgsUsage(1, "usage: banger vm kill [--signal SIGTERM|SIGKILL|...] <id-or-name>..."),
		ValidArgsFunction: d.completeVMNames,
		RunE: func(cmd *cobra.Command, args []string) error {
			layout, _, err := d.ensureDaemon(cmd.Context())
			if err != nil {
				return err
			}
			if len(args) > 1 {
				return runVMBatchAction(cmd, layout.SocketPath, args, func(ctx context.Context, id string) (model.VMRecord, error) {
					result, err := rpc.Call[api.VMShowResult](
						ctx,
						layout.SocketPath,
						"vm.kill",
						api.VMKillParams{IDOrName: id, Signal: signal},
					)
					if err != nil {
						return model.VMRecord{}, err
					}
					return result.VM, nil
				})
			}
			result, err := rpc.Call[api.VMShowResult](
				cmd.Context(),
				layout.SocketPath,
				"vm.kill",
				api.VMKillParams{IDOrName: args[0], Signal: signal},
			)
			if err != nil {
				return err
			}
			return printVMSummary(cmd.OutOrStdout(), result.VM)
		},
	}
	cmd.Flags().StringVar(&signal, "signal", "TERM", "signal name to send")
	return cmd
}

func (d *deps) newVMPruneCommand() *cobra.Command {
	var force bool
	cmd := &cobra.Command{
		Use:   "prune",
		Short: "Delete every VM that isn't running",
		Long:  "Scan for VMs in state other than 'running' (stopped, created, error) and delete them after confirmation. Use -f to skip the prompt.",
		Args:  noArgsUsage("usage: banger vm prune [-f|--force]"),
		RunE: func(cmd *cobra.Command, args []string) error {
			layout, _, err := d.ensureDaemon(cmd.Context())
			if err != nil {
				return err
			}
			return d.runVMPrune(cmd, layout.SocketPath, force)
		},
	}
	cmd.Flags().BoolVarP(&force, "force", "f", false, "skip the confirmation prompt")
	return cmd
}

func (d *deps) runVMPrune(cmd *cobra.Command, socketPath string, force bool) error {
	ctx := cmd.Context()
	stdout := cmd.OutOrStdout()
	stderr := cmd.ErrOrStderr()

	list, err := d.vmList(ctx, socketPath)
	if err != nil {
		return err
	}
	var victims []model.VMRecord
	for _, vm := range list.VMs {
		if vm.State != model.VMStateRunning {
			victims = append(victims, vm)
		}
	}
	if len(victims) == 0 {
		_, err := fmt.Fprintln(stdout, "no non-running VMs to prune")
		return err
	}

	fmt.Fprintf(stdout, "The following %d VM(s) will be deleted:\n", len(victims))
	w := tabwriter.NewWriter(stdout, 0, 8, 2, ' ', 0)
	fmt.Fprintln(w, "  ID\tNAME\tSTATE")
	for _, vm := range victims {
		fmt.Fprintf(w, "  %s\t%s\t%s\n", shortID(vm.ID), vm.Name, vm.State)
	}
	if err := w.Flush(); err != nil {
		return err
	}

	if !force {
		ok, err := promptYesNo(cmd.InOrStdin(), stdout, "Delete these VMs? [y/N] ")
		if err != nil {
			return err
		}
		if !ok {
			_, err := fmt.Fprintln(stdout, "aborted")
			return err
		}
	}

	var failed int
	for _, vm := range victims {
		ref := vm.Name
		if ref == "" {
			ref = shortID(vm.ID)
		}
		if err := d.vmDelete(ctx, socketPath, vm.ID); err != nil {
			fmt.Fprintf(stderr, "delete %s: %v\n", ref, err)
			failed++
			continue
		}
		if err := removeUserKnownHosts(vm); err != nil {
			fmt.Fprintf(stderr, "known_hosts cleanup %s: %v\n", ref, err)
		}
		fmt.Fprintln(stdout, "deleted", ref)
	}
	if failed > 0 {
		return fmt.Errorf("%d VM(s) failed to delete", failed)
	}
	return nil
}

// promptYesNo reads a line from in and returns true iff the trimmed
// lowercase answer is "y" or "yes". EOF is "no"; other read errors
// surface to the caller.
func promptYesNo(in io.Reader, out io.Writer, prompt string) (bool, error) {
	if _, err := fmt.Fprint(out, prompt); err != nil {
		return false, err
	}
	reader := bufio.NewReader(in)
	line, err := reader.ReadString('\n')
	if err != nil && err != io.EOF {
		return false, err
	}
	answer := strings.ToLower(strings.TrimSpace(line))
	return answer == "y" || answer == "yes", nil
}

func (d *deps) newVMCreateCommand() *cobra.Command {
	defaults := effectiveVMDefaults()
	var (
		name              string
		imageName         string
		vcpu              = defaults.VCPUCount
		memory            = defaults.MemoryMiB
		systemOverlaySize = model.FormatSizeBytes(defaults.SystemOverlaySizeByte)
		workDiskSize      = model.FormatSizeBytes(defaults.WorkDiskSizeBytes)
		natEnabled        bool
		noStart           bool
		verbose           bool
	)
	cmd := &cobra.Command{
		Use:   "create",
		Short: "Create a VM (without entering it)",
		Long: strings.TrimSpace(`
Create a microVM in the 'running' state and return its summary.
Unlike 'banger vm run', this does NOT open an ssh session — pair it
with 'banger vm ssh <name>' when you want to attach.

Use 'vm create' for a longer-lived VM you'll come back to. Use
'vm run' for one-shot sandboxes (especially with --rm).
`),
		Example: strings.TrimSpace(`
  banger vm create --name agent
  banger vm create --name big --vcpu 8 --memory 16384
  banger vm create --no-start --name spare    # provision but leave stopped
`),
		Args: noArgsUsage("usage: banger vm create"),
		RunE: func(cmd *cobra.Command, args []string) error {
			params, err := vmCreateParamsFromFlags(cmd, name, imageName, vcpu, memory, systemOverlaySize, workDiskSize, natEnabled, noStart)
			if err != nil {
				return err
			}
			layout, _, err := d.ensureDaemon(cmd.Context())
			if err != nil {
				return err
			}
			vm, err := d.runVMCreate(cmd.Context(), layout.SocketPath, cmd.ErrOrStderr(), params, verbose)
			if err != nil {
				return err
			}
			return printVMSummary(cmd.OutOrStdout(), vm)
		},
	}
	cmd.Flags().StringVar(&name, "name", "", "vm name")
	cmd.Flags().StringVar(&imageName, "image", "", "image name or id (defaults to config's default_image_name; auto-pulled from imagecat if missing)")
	cmd.Flags().IntVar(&vcpu, "vcpu", defaults.VCPUCount, "vcpu count")
	cmd.Flags().IntVar(&memory, "memory", defaults.MemoryMiB, "memory in MiB")
	cmd.Flags().StringVar(&systemOverlaySize, "system-overlay-size", model.FormatSizeBytes(defaults.SystemOverlaySizeByte), "system overlay size")
	cmd.Flags().StringVar(&workDiskSize, "disk-size", model.FormatSizeBytes(defaults.WorkDiskSizeBytes), "work disk size")
	cmd.Flags().BoolVar(&natEnabled, "nat", false, "enable outbound internet from the guest (host-side MASQUERADE)")
	cmd.Flags().BoolVar(&noStart, "no-start", false, "create without starting")
	cmd.Flags().BoolVarP(&verbose, "verbose", "v", false, "show every progress line instead of a single rewriting status line")
	_ = cmd.RegisterFlagCompletionFunc("image", d.completeImageNames)
	return cmd
}

type vmListOptions struct {
	showAll bool
	latest  bool
	quiet   bool
}

func (d *deps) newPSCommand() *cobra.Command {
	return d.newVMListLikeCommand("ps", nil, "usage: banger ps")
}

func (d *deps) newVMListCommand() *cobra.Command {
	return d.newVMListLikeCommand("list", []string{"ls", "ps"}, "usage: banger vm list")
}

func (d *deps) newVMListLikeCommand(use string, aliases []string, usage string) *cobra.Command {
	var opts vmListOptions
	cmd := &cobra.Command{
		Use:     use,
		Aliases: aliases,
		Short:   "List VMs",
		Args:    noArgsUsage(usage),
		RunE: func(cmd *cobra.Command, args []string) error {
			return d.runVMList(cmd, opts)
		},
	}
	cmd.Flags().BoolVarP(&opts.showAll, "all", "a", false, "show all VMs")
	cmd.Flags().BoolVarP(&opts.latest, "latest", "l", false, "show only the latest VM")
	cmd.Flags().BoolVarP(&opts.quiet, "quiet", "q", false, "only show VM IDs")
	return cmd
}

func (d *deps) runVMList(cmd *cobra.Command, opts vmListOptions) error {
	layout, _, err := d.ensureDaemon(cmd.Context())
	if err != nil {
		return err
	}
	result, err := rpc.Call[api.VMListResult](cmd.Context(), layout.SocketPath, "vm.list", api.Empty{})
	if err != nil {
		return err
	}
	vms := selectVMListVMs(result.VMs, opts.showAll, opts.latest)
	if opts.quiet {
		return printVMIDList(cmd.OutOrStdout(), vms)
	}
	images, err := rpc.Call[api.ImageListResult](cmd.Context(), layout.SocketPath, "image.list", api.Empty{})
	if err != nil {
		return err
	}
	return printVMListTable(cmd.OutOrStdout(), vms, imageNameIndex(images.Images))
}

func selectVMListVMs(vms []model.VMRecord, showAll, latest bool) []model.VMRecord {
	filtered := make([]model.VMRecord, 0, len(vms))
	for _, vm := range vms {
		if !showAll && vm.State != model.VMStateRunning {
			continue
		}
		filtered = append(filtered, vm)
	}
	if !latest || len(filtered) <= 1 {
		return filtered
	}
	latestVM := filtered[0]
	for _, vm := range filtered[1:] {
		if vm.CreatedAt.After(latestVM.CreatedAt) {
			latestVM = vm
			continue
		}
		if vm.CreatedAt.Equal(latestVM.CreatedAt) && vm.UpdatedAt.After(latestVM.UpdatedAt) {
			latestVM = vm
		}
	}
	return []model.VMRecord{latestVM}
}

func (d *deps) newVMShowCommand() *cobra.Command {
	return &cobra.Command{
		Use:   "show <id-or-name>",
		Short: "Print full VM record as JSON",
		Long: strings.TrimSpace(`
Emit the complete VM record (spec, runtime state, image reference,
created/updated timestamps) as a single JSON object. Suitable for
piping into 'jq' or feeding into automation.

For human-readable summaries use 'banger ps' or 'banger vm stats'.
`),
		Args:              exactArgsUsage(1, "usage: banger vm show <id-or-name>"),
		ValidArgsFunction: d.completeVMNameOnlyAtPos0,
		RunE: func(cmd *cobra.Command, args []string) error {
			layout, _, err := d.ensureDaemon(cmd.Context())
			if err != nil {
				return err
			}
			result, err := rpc.Call[api.VMShowResult](cmd.Context(), layout.SocketPath, "vm.show", api.VMRefParams{IDOrName: args[0]})
			if err != nil {
				return err
			}
			return printJSON(cmd.OutOrStdout(), result.VM)
		},
	}
}

func (d *deps) newVMActionCommand(use, short, method string, aliases ...string) *cobra.Command {
	return &cobra.Command{
		Use:               use + " <id-or-name>...",
		Aliases:           aliases,
		Short:             short,
		Args:              minArgsUsage(1, fmt.Sprintf("usage: banger vm %s <id-or-name>...", use)),
		ValidArgsFunction: d.completeVMNames,
		RunE: func(cmd *cobra.Command, args []string) error {
			layout, _, err := d.ensureDaemon(cmd.Context())
			if err != nil {
				return err
			}
			if len(args) > 1 {
				return runVMBatchAction(cmd, layout.SocketPath, args, func(ctx context.Context, id string) (model.VMRecord, error) {
					result, err := rpc.Call[api.VMShowResult](ctx, layout.SocketPath, method, api.VMRefParams{IDOrName: id})
					if err != nil {
						return model.VMRecord{}, err
					}
					return result.VM, nil
				})
			}
			result, err := rpc.Call[api.VMShowResult](cmd.Context(), layout.SocketPath, method, api.VMRefParams{IDOrName: args[0]})
			if err != nil {
				return err
			}
			return printVMSummary(cmd.OutOrStdout(), result.VM)
		},
	}
}

func (d *deps) newVMDeleteCommand() *cobra.Command {
	return &cobra.Command{
		Use:     "delete <id-or-name>...",
		Aliases: []string{"rm"},
		Short:   "Stop a VM and remove its disks (irreversible)",
		Long: strings.TrimSpace(`
Stop the VM if it's running, then remove its work disk, system
overlay, snapshot, and metadata. Frees host disk space. The
operation is irreversible — anything written inside the guest is
lost.

Use 'banger vm prune' to bulk-delete every VM that isn't running.
`),
		Args:              minArgsUsage(1, "usage: banger vm delete <id-or-name>..."),
		ValidArgsFunction: d.completeVMNames,
		RunE: func(cmd *cobra.Command, args []string) error {
			layout, _, err := d.ensureDaemon(cmd.Context())
			if err != nil {
				return err
			}
			deleteOne := func(ctx context.Context, id string) (model.VMRecord, error) {
				result, err := rpc.Call[api.VMShowResult](ctx, layout.SocketPath, "vm.delete", api.VMRefParams{IDOrName: id})
				if err != nil {
					return model.VMRecord{}, err
				}
				if err := removeUserKnownHosts(result.VM); err != nil {
					_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "known_hosts cleanup for %s: %v\n", id, err)
				}
				return result.VM, nil
			}
			if len(args) > 1 {
				return runVMBatchAction(cmd, layout.SocketPath, args, deleteOne)
			}
			vm, err := deleteOne(cmd.Context(), args[0])
			if err != nil {
				return err
			}
			return printVMSummary(cmd.OutOrStdout(), vm)
		},
	}
}

func (d *deps) newVMSetCommand() *cobra.Command {
	var (
		vcpu     int
		memory   int
		diskSize string
		nat      bool
		noNat    bool
	)
	cmd := &cobra.Command{
		Use:   "set <id-or-name>...",
		Short: "Update stopped VM settings",
		Long: strings.TrimSpace(`
Reconfigure one or more stopped VMs. The VM must be stopped before
reconfiguring — start it again with 'banger vm start' to apply the new settings.
`),
		Example: strings.TrimSpace(`
  banger vm set dev --vcpu 4 --memory 8192
`),
		Args:              minArgsUsage(1, "usage: banger vm set [--vcpu N] [--memory MiB] [--disk-size SIZE] [--nat|--no-nat] <id-or-name>..."),
		ValidArgsFunction: d.completeVMNames,
		RunE: func(cmd *cobra.Command, args []string) error {
			params, err := vmSetParamsFromFlags(args[0], vcpu, memory, diskSize, nat, noNat)
			if err != nil {
				return err
			}
			layout, _, err := d.ensureDaemon(cmd.Context())
			if err != nil {
				return err
			}
			if len(args) > 1 {
				return runVMBatchAction(cmd, layout.SocketPath, args, func(ctx context.Context, id string) (model.VMRecord, error) {
					batchParams := params
					batchParams.IDOrName = id
					result, err := rpc.Call[api.VMShowResult](ctx, layout.SocketPath, "vm.set", batchParams)
					if err != nil {
						return model.VMRecord{}, err
					}
					return result.VM, nil
				})
			}
			result, err := rpc.Call[api.VMShowResult](cmd.Context(), layout.SocketPath, "vm.set", params)
			if err != nil {
				return err
			}
			return printVMSummary(cmd.OutOrStdout(), result.VM)
		},
	}
	cmd.Flags().IntVar(&vcpu, "vcpu", -1, "vcpu count")
	cmd.Flags().IntVar(&memory, "memory", -1, "memory in MiB")
	cmd.Flags().StringVar(&diskSize, "disk-size", "", "new work disk size")
	cmd.Flags().BoolVar(&nat, "nat", false, "enable NAT")
	cmd.Flags().BoolVar(&noNat, "no-nat", false, "disable NAT")
	return cmd
}

func (d *deps) newVMSSHCommand() *cobra.Command {
	return &cobra.Command{
		Use:   "ssh <id-or-name> [ssh args...]",
		Short: "Open an interactive ssh session to a running VM",
		Long: strings.TrimSpace(`
Connect to a running VM as root over the host bridge. Trailing
arguments are passed through to the underlying 'ssh' command, so
'-- -L 8080:localhost:8080' forwards a port and '-- echo hi' runs
a single command and exits.

To run a one-shot command without holding a session, prefer
'banger vm run --rm -- <command>' over 'vm ssh -- <command>'.
`),
		Example: strings.TrimSpace(`
  banger vm ssh agent
  banger vm ssh agent -- uname -a
  banger vm ssh agent -- -L 8080:localhost:8080 -N
`),
		Args:              minArgsUsage(1, "usage: banger vm ssh <id-or-name> [ssh args...]"),
		ValidArgsFunction: d.completeVMNameOnlyAtPos0,
		RunE: func(cmd *cobra.Command, args []string) error {
			layout, cfg, err := d.ensureDaemon(cmd.Context())
			if err != nil {
				return err
			}
			if err := validateSSHPrereqs(cfg); err != nil {
				return err
			}
			result, err := d.vmSSH(cmd.Context(), layout.SocketPath, args[0])
			if err != nil {
				return err
			}
			sshArgs, err := sshCommandArgs(cfg, result.GuestIP, args[1:])
			if err != nil {
				return err
			}
			return d.runSSHSession(cmd.Context(), layout.SocketPath, result.Name, cmd.InOrStdin(), cmd.OutOrStdout(), cmd.ErrOrStderr(), sshArgs, false)
		},
	}
}

func (d *deps) newVMWorkspaceCommand() *cobra.Command {
	cmd := &cobra.Command{
		Use:   "workspace",
		Short: "Ship a host repo into a guest, pull diffs back",
		Long: strings.TrimSpace(`
Two-step pattern for round-tripping a working tree through a guest
VM:

  prepare    Copy a local git repo into the guest at /root/repo
             (or any path you choose). Default ships tracked files
             only; pass --include-untracked to ship the rest.
  export     Capture every change inside the guest workspace as a
             host-readable patch. Non-mutating: the guest's working
             tree is left untouched.

This is the supported flow for AI agents and CI runners that want
to evaluate code changes inside a sandbox without touching the
host checkout. 'banger vm run ./repo -- <cmd>' is shorthand for
prepare + run + delete.
`),
		Example: strings.TrimSpace(`
  banger vm workspace prepare agent ../repo
  banger vm ssh agent -- bash -lc 'cd /root/repo && make test'
  banger vm workspace export agent --base-commit <commit> > out.patch
`),
		RunE: helpNoArgs,
	}
	cmd.AddCommand(
		d.newVMWorkspacePrepareCommand(),
		d.newVMWorkspaceExportCommand(),
	)
	return cmd
}

func (d *deps) newVMWorkspacePrepareCommand() *cobra.Command {
	var guestPath string
	var branchName string
	var fromRef string
	var mode string
	var includeUntracked bool
	var dryRun bool
	cmd := &cobra.Command{
		Use:               "prepare <id-or-name> [path]",
		Short:             "Copy a local repo into a running VM",
		Long:              "Prepare a repository workspace from a local git checkout into a running VM. The default guest path is /root/repo and the default mode is shallow_overlay. Repositories with git submodules must use --mode full_copy.",
		Args:              minArgsUsage(1, "usage: banger vm workspace prepare <id-or-name> [path]"),
		ValidArgsFunction: d.completeVMNameOnlyAtPos0,
		Example: strings.TrimSpace(`
  banger vm workspace prepare devbox
  banger vm workspace prepare devbox ../repo --guest-path /root/repo
  banger vm workspace prepare devbox ../repo --mode full_copy
`),
		RunE: func(cmd *cobra.Command, args []string) error {
			sourcePath := ""
			if len(args) > 1 {
				sourcePath = args[1]
			}
			if strings.TrimSpace(sourcePath) == "" {
				wd, err := d.cwd()
				if err != nil {
					return err
				}
				sourcePath = wd
			}
			resolvedPath, err := workspace.ResolveSourcePath(sourcePath)
			if err != nil {
				return err
			}
			prepareFrom := ""
			if strings.TrimSpace(branchName) != "" {
				prepareFrom = fromRef
			}
			if dryRun {
				return d.runWorkspaceDryRun(cmd.Context(), cmd.OutOrStdout(), resolvedPath, branchName, prepareFrom, includeUntracked)
			}
			layout, _, err := d.ensureDaemon(cmd.Context())
			if err != nil {
				return err
			}
			if !includeUntracked {
				d.noteUntrackedSkipped(cmd.Context(), cmd.ErrOrStderr(), resolvedPath)
			}
			result, err := d.vmWorkspacePrepare(cmd.Context(), layout.SocketPath, api.VMWorkspacePrepareParams{
				IDOrName:         args[0],
				SourcePath:       resolvedPath,
				GuestPath:        guestPath,
				Branch:           branchName,
				From:             prepareFrom,
				Mode:             mode,
				IncludeUntracked: includeUntracked,
			})
			if err != nil {
				return err
			}
			return printJSON(cmd.OutOrStdout(), result.Workspace)
		},
	}
	cmd.Flags().StringVar(&guestPath, "guest-path", "/root/repo", "guest workspace path")
	cmd.Flags().StringVar(&branchName, "branch", "", "create and switch to a new guest branch")
	cmd.Flags().StringVar(&fromRef, "from", "HEAD", "git ref to branch from when --branch is set (default: HEAD)")
	cmd.Flags().StringVar(&mode, "mode", string(model.WorkspacePrepareModeShallowOverlay), "workspace mode: shallow_overlay, full_copy, metadata_only")
	cmd.Flags().BoolVar(&includeUntracked, "include-untracked", false, "also copy untracked non-ignored files into the guest workspace (default: tracked files only)")
	cmd.Flags().BoolVar(&dryRun, "dry-run", false, "list the files that would be copied and exit without touching the guest")
	return cmd
}

func (d *deps) newVMWorkspaceExportCommand() *cobra.Command {
	var guestPath string
	var outputPath string
	var baseCommit string
	cmd := &cobra.Command{
		Use:               "export <id-or-name>",
		Short:             "Pull changes from a guest workspace back to the host as a patch",
		Long:              "Emit a binary-safe unified diff of every change inside the guest workspace (committed since base + uncommitted + untracked, minus .gitignore). Non-mutating — the guest's index and working tree are untouched. Pass --base-commit with the head_commit from workspace prepare to capture changes even when the worker ran git commit inside the VM. Without --base-commit the diff is against the current guest HEAD, which misses committed changes.",
		Args:              exactArgsUsage(1, "usage: banger vm workspace export <id-or-name>"),
		ValidArgsFunction: d.completeVMNameOnlyAtPos0,
		Example: strings.TrimSpace(`
  banger vm workspace export devbox | git apply
  banger vm workspace export devbox --base-commit abc1234 | git apply
  banger vm workspace export devbox --output worker.diff
  banger vm workspace export devbox --guest-path /root/project --output changes.diff
`),
		RunE: func(cmd *cobra.Command, args []string) error {
			layout, _, err := d.ensureDaemon(cmd.Context())
			if err != nil {
				return err
			}
			result, err := d.vmWorkspaceExport(cmd.Context(), layout.SocketPath, api.WorkspaceExportParams{
				IDOrName:   args[0],
				GuestPath:  guestPath,
				BaseCommit: baseCommit,
			})
			if err != nil {
				return err
			}
			if !result.HasChanges {
				_, _ = fmt.Fprintln(cmd.ErrOrStderr(), "no changes")
				return nil
			}
			if outputPath != "" {
				if err := os.WriteFile(outputPath, result.Patch, 0o644); err != nil {
					return fmt.Errorf("write patch: %w", err)
				}
				_, err = fmt.Fprintf(cmd.ErrOrStderr(), "patch written to %s (%d bytes, %d files)\n",
					outputPath, len(result.Patch), len(result.ChangedFiles))
				return err
			}
			_, err = cmd.OutOrStdout().Write(result.Patch)
			return err
		},
	}
	cmd.Flags().StringVar(&guestPath, "guest-path", "/root/repo", "guest workspace path")
	cmd.Flags().StringVar(&outputPath, "output", "", "write patch to this file instead of stdout")
	cmd.Flags().StringVar(&baseCommit, "base-commit", "", "diff from this commit (use head_commit from workspace prepare to capture worker git commits)")
	return cmd
}

func (d *deps) newVMLogsCommand() *cobra.Command {
	var follow bool
	cmd := &cobra.Command{
		Use:   "logs <id-or-name>",
		Short: "Show guest console + per-VM daemon log",
		Long: strings.TrimSpace(`
Print the firecracker console log (kernel + early init output) and
the per-VM daemon log (lifecycle stages, errors). Pass -f to follow
new lines as they arrive — useful while a VM is starting up or
hanging on boot.
`),
		Example: strings.TrimSpace(`
  banger vm logs agent
  banger vm logs agent -f
`),
		Args:              exactArgsUsage(1, "usage: banger vm logs [-f] <id-or-name>"),
		ValidArgsFunction: d.completeVMNameOnlyAtPos0,
		RunE: func(cmd *cobra.Command, args []string) error {
			layout, _, err := d.ensureDaemon(cmd.Context())
			if err != nil {
				return err
			}
			result, err := rpc.Call[api.VMLogsResult](cmd.Context(), layout.SocketPath, "vm.logs", api.VMRefParams{IDOrName: args[0]})
			if err != nil {
				return err
			}
			if result.LogPath == "" {
				return errors.New("vm has no log path")
			}
			return system.CopyStream(cmd.OutOrStdout(), system.TailCommand(result.LogPath, follow))
		},
	}
	cmd.Flags().BoolVarP(&follow, "follow", "f", false, "follow logs")
	return cmd
}

func (d *deps) newVMStatsCommand() *cobra.Command {
	return &cobra.Command{
		Use:   "stats <id-or-name>",
		Short: "Show VM stats",
		Long: strings.TrimSpace(`
Print real-time resource statistics for a running VM as a JSON object,
including CPU usage, memory balloon metrics, and disk I/O counters.
Pipe into 'jq' for quick field extraction, e.g. banger vm stats dev | jq .mem.
`),
		Example: strings.TrimSpace(`
  banger vm stats dev
  banger vm stats dev | jq .
`),
		Args:              exactArgsUsage(1, "usage: banger vm stats <id-or-name>"),
		ValidArgsFunction: d.completeVMNameOnlyAtPos0,
		RunE: func(cmd *cobra.Command, args []string) error {
			layout, _, err := d.ensureDaemon(cmd.Context())
			if err != nil {
				return err
			}
			result, err := rpc.Call[api.VMStatsResult](cmd.Context(), layout.SocketPath, "vm.stats", api.VMRefParams{IDOrName: args[0]})
			if err != nil {
				return err
			}
			return printJSON(cmd.OutOrStdout(), result)
		},
	}
}

func (d *deps) newVMPortsCommand() *cobra.Command {
	var jsonOut bool
	cmd := &cobra.Command{
		Use:               "ports <id-or-name>",
		Short:             "Show host-reachable listening guest ports",
		Args:              exactArgsUsage(1, "usage: banger vm ports <id-or-name>"),
		ValidArgsFunction: d.completeVMNameOnlyAtPos0,
		RunE: func(cmd *cobra.Command, args []string) error {
			layout, _, err := d.ensureDaemon(cmd.Context())
			if err != nil {
				return err
			}
			result, err := d.vmPorts(cmd.Context(), layout.SocketPath, args[0])
			if err != nil {
				return err
			}
			if jsonOut {
				return printJSON(cmd.OutOrStdout(), result)
			}
			return printVMPortsTable(cmd.OutOrStdout(), result)
		},
	}
	cmd.Flags().BoolVar(&jsonOut, "json", false, "print ports as JSON instead of a table")
	return cmd
}

type resolvedVMTarget struct {
	Index int
	Ref   string
	VM    model.VMRecord
}

type vmRefResolutionError struct {
	Index int
	Ref   string
	Err   error
}

type vmBatchActionResult struct {
	Target resolvedVMTarget
	VM     model.VMRecord
	Err    error
}

func runVMBatchAction(cmd *cobra.Command, socketPath string, refs []string, action func(context.Context, string) (model.VMRecord, error)) error {
	listResult, err := rpc.Call[api.VMListResult](cmd.Context(), socketPath, "vm.list", api.Empty{})
	if err != nil {
		return err
	}
	targets, resolutionErrs := resolveVMTargets(listResult.VMs, refs)
	results := executeVMActionBatch(cmd.Context(), targets, action)

	failed := false
	for _, resolutionErr := range resolutionErrs {
		if _, err := fmt.Fprintf(cmd.ErrOrStderr(), "%s: %v\n", resolutionErr.Ref, resolutionErr.Err); err != nil {
			return err
		}
		failed = true
	}
	for _, result := range results {
		if result.Err != nil {
			if _, err := fmt.Fprintf(cmd.ErrOrStderr(), "%s: %v\n", result.Target.Ref, result.Err); err != nil {
				return err
			}
			failed = true
			continue
		}
		if err := printVMSummary(cmd.OutOrStdout(), result.VM); err != nil {
			return err
		}
	}
	if failed {
		return errors.New("one or more VM operations failed")
	}
	return nil
}

func resolveVMTargets(vms []model.VMRecord, refs []string) ([]resolvedVMTarget, []vmRefResolutionError) {
	targets := make([]resolvedVMTarget, 0, len(refs))
	resolutionErrs := make([]vmRefResolutionError, 0)
	seen := make(map[string]struct{}, len(refs))
	for index, ref := range refs {
		vm, err := resolveVMRef(vms, ref)
		if err != nil {
			resolutionErrs = append(resolutionErrs, vmRefResolutionError{Index: index, Ref: ref, Err: err})
			continue
		}
		if _, ok := seen[vm.ID]; ok {
			continue
		}
		seen[vm.ID] = struct{}{}
		targets = append(targets, resolvedVMTarget{Index: index, Ref: ref, VM: vm})
	}
	return targets, resolutionErrs
}

func resolveVMRef(vms []model.VMRecord, ref string) (model.VMRecord, error) {
	ref = strings.TrimSpace(ref)
	if ref == "" {
		return model.VMRecord{}, errors.New("vm id or name is required")
	}
	exactMatches := make([]model.VMRecord, 0, 1)
	for _, vm := range vms {
		if vm.ID == ref || vm.Name == ref {
			exactMatches = append(exactMatches, vm)
		}
	}
	switch len(exactMatches) {
	case 1:
		return exactMatches[0], nil
	case 0:
	default:
		return model.VMRecord{}, fmt.Errorf("multiple VMs match %q", ref)
	}

	prefixMatches := make([]model.VMRecord, 0, 1)
	for _, vm := range vms {
		if strings.HasPrefix(vm.ID, ref) || strings.HasPrefix(vm.Name, ref) {
			prefixMatches = append(prefixMatches, vm)
		}
	}
	switch len(prefixMatches) {
	case 1:
		return prefixMatches[0], nil
	case 0:
		return model.VMRecord{}, fmt.Errorf("vm %q not found", ref)
	default:
		return model.VMRecord{}, fmt.Errorf("multiple VMs match %q", ref)
	}
}

func executeVMActionBatch(ctx context.Context, targets []resolvedVMTarget, action func(context.Context, string) (model.VMRecord, error)) []vmBatchActionResult {
	results := make([]vmBatchActionResult, len(targets))
	var wg sync.WaitGroup
	wg.Add(len(targets))
	for index, target := range targets {
		index := index
		target := target
		go func() {
			defer wg.Done()
			vm, err := action(ctx, target.VM.ID)
			results[index] = vmBatchActionResult{
				Target: target,
				VM:     vm,
				Err:    err,
			}
		}()
	}
	wg.Wait()
	return results
}

func vmSetParamsFromFlags(idOrName string, vcpu, memory int, diskSize string, nat, noNat bool) (api.VMSetParams, error) {
	if nat && noNat {
		return api.VMSetParams{}, errors.New("use only one of --nat or --no-nat")
	}
	params := api.VMSetParams{IDOrName: idOrName, WorkDiskSize: diskSize}
	if vcpu >= 0 {
		if err := validatePositiveSetting("vcpu", vcpu); err != nil {
			return api.VMSetParams{}, err
		}
		params.VCPUCount = &vcpu
	}
	if memory >= 0 {
		if err := validatePositiveSetting("memory", memory); err != nil {
			return api.VMSetParams{}, err
		}
		params.MemoryMiB = &memory
	}
	if nat || noNat {
		value := nat && !noNat
		params.NATEnabled = &value
	}
	if params.VCPUCount == nil && params.MemoryMiB == nil && params.WorkDiskSize == "" && params.NATEnabled == nil {
		return api.VMSetParams{}, errors.New("no VM settings changed")
	}
	return params, nil
}

func vmCreateParamsFromFlags(cmd *cobra.Command, name, imageName string, vcpu, memory int, systemOverlaySize, workDiskSize string, natEnabled, noStart bool) (api.VMCreateParams, error) {
	// Flag defaults were resolved from config + host heuristics at
	// command-build time, so we always forward the flag values. The CLI
	// becomes the single source of truth for effective defaults and the
	// progress renderer shows the exact sizing.
	if strings.TrimSpace(name) != "" {
		if err := model.ValidateVMName(name); err != nil {
			return api.VMCreateParams{}, err
		}
	}
	if err := validatePositiveSetting("vcpu", vcpu); err != nil {
		return api.VMCreateParams{}, err
	}
	if err := validatePositiveSetting("memory", memory); err != nil {
		return api.VMCreateParams{}, err
	}
	params := api.VMCreateParams{
		Name:              name,
		ImageName:         imageName,
		NATEnabled:        natEnabled,
		NoStart:           noStart,
		VCPUCount:         &vcpu,
		MemoryMiB:         &memory,
		SystemOverlaySize: systemOverlaySize,
		WorkDiskSize:      workDiskSize,
	}
	return params, nil
}
