package cli

import (
	"context"
	"errors"
	"fmt"
	"os/exec"
	"strings"

	"banger/internal/api"
	"banger/internal/model"
	"banger/internal/rpc"

	"github.com/spf13/cobra"
)

func (d *deps) newVMExecCommand() *cobra.Command {
	var guestPath string
	var autoPrepare bool
	cmd := &cobra.Command{
		Use:   "exec <id-or-name> -- <command> [args...]",
		Short: "Run a command in the VM workspace with the repo toolchain",
		Long: strings.TrimSpace(`
Run a command inside a persistent VM, wrapping it with 'mise exec' so
all mise-managed tools (Go, Node, Python, etc.) are on PATH.

If the VM has a prepared workspace (from 'vm workspace prepare' or
'vm run ./repo'), the command runs from that directory and a stale-
workspace warning is printed when the host repo has advanced since the
last prepare; pass --auto-prepare to re-sync first. Otherwise the
command runs from root's home directory. --guest-path overrides both.

Exit code of the guest command is propagated verbatim.
`),
		Example: strings.TrimSpace(`
  banger vm exec dev -- make test
  banger vm exec dev -- go build ./...
  banger vm exec dev --auto-prepare -- npm ci && npm test
  banger vm exec dev --guest-path /root/other -- make lint
`),
		Args: cobra.ArbitraryArgs,
		RunE: func(cmd *cobra.Command, args []string) error {
			// Split on -- : everything before is [vm-name], everything after is the command.
			dash := cmd.ArgsLenAtDash()
			var vmRef string
			var command []string
			switch {
			case dash < 0:
				// No -- separator: first arg is VM, rest is command.
				if len(args) < 2 {
					return errors.New("usage: banger vm exec <id-or-name> -- <command> [args...]")
				}
				vmRef = args[0]
				command = args[1:]
			case dash == 0 || len(args[dash:]) == 0:
				return errors.New("usage: banger vm exec <id-or-name> -- <command> [args...]")
			default:
				vmRef = args[:dash][0]
				command = args[dash:]
			}

			layout, cfg, err := d.ensureDaemon(cmd.Context())
			if err != nil {
				return err
			}
			if err := validateSSHPrereqs(cfg); err != nil {
				return err
			}

			// Fetch the full VM record — we need Workspace and GuestIP.
			result, err := rpc.Call[api.VMShowResult](cmd.Context(), layout.SocketPath, "vm.show", api.VMRefParams{IDOrName: vmRef})
			if err != nil {
				return err
			}
			vm := result.VM
			if vm.State != model.VMStateRunning {
				return fmt.Errorf("vm %q is not running (state: %s)", vm.Name, vm.State)
			}

			// Resolve effective guest workspace path. Empty means "no
			// cd": run from the SSH session's default cwd ($HOME). We
			// only auto-cd when the user explicitly passed --guest-path
			// or the VM actually has a recorded workspace — otherwise
			// arbitrary VMs (no repo) would fail with cd errors.
			execGuestPath := strings.TrimSpace(guestPath)
			if execGuestPath == "" {
				execGuestPath = strings.TrimSpace(vm.Workspace.GuestPath)
			}

			// Dirty-workspace check: compare stored HEAD with current host HEAD.
			isDirty, currentHead, _ := d.vmExecDirtyCheck(cmd.Context(), vm.Workspace)
			if isDirty {
				storedShort := shortRef(vm.Workspace.HeadCommit)
				currentShort := shortRef(currentHead)
				preparedLabel := relativeTime(vm.Workspace.PreparedAt)

				if autoPrepare && vm.Workspace.SourcePath != "" {
					_, _ = fmt.Fprintf(cmd.ErrOrStderr(),
						"[vm exec] workspace stale (prepared %s from %s, host HEAD now %s) — re-preparing\n",
						preparedLabel, storedShort, currentShort)
					if err := validateVMRunPrereqs(cfg); err != nil {
						return err
					}
					if _, err := d.vmWorkspacePrepare(cmd.Context(), layout.SocketPath, api.VMWorkspacePrepareParams{
						IDOrName:   vmRef,
						SourcePath: vm.Workspace.SourcePath,
						GuestPath:  execGuestPath,
						Mode:       string(model.WorkspacePrepareModeShallowOverlay),
					}); err != nil {
						return fmt.Errorf("auto-prepare workspace: %w", err)
					}
				} else {
					_, _ = fmt.Fprintf(cmd.ErrOrStderr(),
						"[vm exec] warning: workspace stale (prepared %s from %s, host HEAD now %s) — use --auto-prepare to re-sync\n",
						preparedLabel, storedShort, currentShort)
				}
			}

			// Build and run the exec script.
			script := buildVMExecScript(execGuestPath, command)
			sshArgs, err := sshCommandArgs(cfg, vm.Runtime.GuestIP, []string{"bash", "-lc", script})
			if err != nil {
				return fmt.Errorf("vm %q: build ssh args: %w", vm.Name, err)
			}
			if err := d.sshExec(cmd.Context(), cmd.InOrStdin(), cmd.OutOrStdout(), cmd.ErrOrStderr(), sshArgs); err != nil {
				var exitErr *exec.ExitError
				if errors.As(err, &exitErr) {
					return ExitCodeError{Code: exitErr.ExitCode()}
				}
				return err
			}
			return nil
		},
	}
	cmd.Flags().StringVar(&guestPath, "guest-path", "", "workspace directory in the guest (default: from last workspace prepare; otherwise root's home)")
	cmd.Flags().BoolVar(&autoPrepare, "auto-prepare", false, "re-sync the workspace from the host repo before running if it's stale")
	_ = cmd.RegisterFlagCompletionFunc("guest-path", cobra.NoFileCompletions)
	return cmd
}

// buildVMExecScript returns the bash -lc argument that runs the
// command through mise exec when mise is available, falling back to a
// plain exec if it's not. When guestPath is non-empty, the script
// cd's into it first (workspace mode); when empty, the command runs
// from the SSH session's default cwd so VMs without a prepared
// workspace don't blow up on a non-existent /root/repo. Each command
// argument is shell-quoted so spaces and special characters survive
// the bash re-parse inside the -lc string.
func buildVMExecScript(guestPath string, command []string) string {
	parts := make([]string, len(command))
	for i, a := range command {
		parts[i] = shellQuote(a)
	}
	quotedCmd := strings.Join(parts, " ")
	body := fmt.Sprintf(
		"if command -v mise >/dev/null 2>&1; then mise exec -- %s; else %s; fi",
		quotedCmd,
		quotedCmd,
	)
	if guestPath == "" {
		return body
	}
	return fmt.Sprintf("cd %s && %s", shellQuote(guestPath), body)
}

// vmExecDirtyCheck compares the HEAD commit stored in the VM's
// workspace record against the current HEAD of the host repo. Returns
// (false, "", nil) when the check can't be performed (no workspace
// recorded, path gone, not a repo, git not installed) so callers
// treat unknown as "not dirty" rather than blocking the exec.
func (d *deps) vmExecDirtyCheck(ctx context.Context, ws model.VMWorkspace) (isDirty bool, currentHead string, err error) {
	if ws.SourcePath == "" || ws.HeadCommit == "" {
		return false, "", nil
	}
	out, err := d.hostCommandOutput(ctx, "git", "-C", ws.SourcePath, "rev-parse", "HEAD")
	if err != nil {
		// Source path gone, not a git repo, or git not installed —
		// treat as unknown rather than blocking.
		return false, "", nil
	}
	currentHead = strings.TrimSpace(string(out))
	return currentHead != ws.HeadCommit, currentHead, nil
}

// shortRef returns the first 8 characters of a git ref / commit SHA
// for display. Returns the full string if it's already short.
func shortRef(ref string) string {
	if len(ref) > 8 {
		return ref[:8]
	}
	return ref
}
