//go:build smoke

package smoketest

import (
	"os"
	"os/exec"
	"path/filepath"
	"regexp"
	"strings"
	"sync"
	"testing"
)

// testBareRun is the Go port of scenario_bare_run from
// scripts/smoke.sh. Bare ephemeral VM run: create + start + ssh +
// echo + --rm.
func testBareRun(t *testing.T) {
	t.Parallel()
	out := mustBanger(t, "vm", "run", "--rm", "--", "echo", "smoke-bare-ok")
	wantContains(t, out, "smoke-bare-ok", "bare vm run stdout")
}

// testExitCode is the Go port of scenario_exit_code. Asserts that
// `vm run -- sh -c 'exit 42'` propagates rc=42 verbatim.
func testExitCode(t *testing.T) {
	t.Parallel()
	res := banger(t, "vm", "run", "--rm", "--", "sh", "-c", "exit 42")
	wantExit(t, res, 42, "exit-code propagation")
}

// testConcurrentRun fires two `vm run --rm` invocations simultaneously
// and asserts both succeed and emit their respective markers. Bash uses
// `& ; wait`; Go uses two goroutines that capture the result and a
// WaitGroup. Note: t.Fatalf cannot be called from a goroutine, so the
// children write to result slots and assertions run on the main goroutine.
func testConcurrentRun(t *testing.T) {
	t.Parallel()
	var wg sync.WaitGroup
	var resA, resB result
	run := func(dst *result, marker string) {
		defer wg.Done()
		cmd := exec.Command(bangerBin, "vm", "run", "--rm", "--", "echo", marker)
		var out, errBuf strings.Builder
		cmd.Stdout = &out
		cmd.Stderr = &errBuf
		err := cmd.Run()
		dst.stdout = out.String()
		dst.stderr = errBuf.String()
		if err != nil {
			if exitErr, ok := err.(*exec.ExitError); ok {
				dst.rc = exitErr.ExitCode()
			} else {
				dst.rc = -1
				dst.stderr += "\nexec error: " + err.Error()
			}
		}
	}
	wg.Add(2)
	go run(&resA, "smoke-concurrent-a")
	go run(&resB, "smoke-concurrent-b")
	wg.Wait()
	wantExit(t, resA, 0, "concurrent A exit")
	wantExit(t, resB, 0, "concurrent B exit")
	wantContains(t, resA.stdout, "smoke-concurrent-a", "concurrent A stdout")
	wantContains(t, resB.stdout, "smoke-concurrent-b", "concurrent B stdout")
}

// testDetachRun ports scenario_detach_run. Verifies -d combined with
// --rm or with a guest command is rejected before VM creation, then
// that -d --name leaves the VM running and ssh-able.
func testDetachRun(t *testing.T) {
	t.Parallel()

	res := banger(t, "vm", "run", "-d", "--rm")
	if res.rc == 0 {
		t.Fatalf("detach: -d --rm should be rejected before VM creation")
	}

	res = banger(t, "vm", "run", "-d", "--", "echo", "hi")
	if res.rc == 0 {
		t.Fatalf("detach: -d -- <cmd> should be rejected before VM creation")
	}

	const name = "smoke-detach"
	mustBanger(t, "vm", "run", "-d", "--name", name)
	t.Cleanup(func() { vmDelete(name) })

	show := mustBanger(t, "vm", "show", name)
	wantContains(t, show, `"state": "running"`, "detach: post-detach state")

	out := mustBanger(t, "vm", "ssh", name, "--", "echo", "detach-marker")
	wantContains(t, out, "detach-marker", "detach: ssh stdout")
}

// testBootstrapPrecondition ports scenario_bootstrap_precondition.
// A workspace with .mise.toml requires NAT (or --no-bootstrap) to run.
// The fake repo lives in a TempDir so it doesn't pollute the shared
// repodir fixture used by repodir-class scenarios.
func testBootstrapPrecondition(t *testing.T) {
	t.Parallel()
	miseRepo := t.TempDir()
	gitInit := func(args ...string) {
		t.Helper()
		cmd := exec.Command(args[0], args[1:]...)
		cmd.Dir = miseRepo
		if out, err := cmd.CombinedOutput(); err != nil {
			t.Fatalf("setup mise repo: %s: %v\n%s", args, err, out)
		}
	}
	gitInit("git", "init", "-q")
	gitInit("git", "-c", "user.email=smoke@banger", "-c", "user.name=smoke",
		"commit", "--allow-empty", "-q", "-m", "init")
	if err := os.WriteFile(filepath.Join(miseRepo, ".mise.toml"), []byte("[tools]\n"), 0o644); err != nil {
		t.Fatalf("write .mise.toml: %v", err)
	}
	gitInit("git", "add", ".mise.toml")
	gitInit("git", "-c", "user.email=smoke@banger", "-c", "user.name=smoke",
		"commit", "-q", "-m", "add mise")

	res := banger(t, "vm", "run", "--rm", miseRepo, "--", "echo", "nope")
	if res.rc == 0 {
		t.Fatalf("bootstrap: workspace with .mise.toml should refuse without --nat / --no-bootstrap")
	}

	out := mustBanger(t, "vm", "run", "--rm", "--no-bootstrap", miseRepo, "--", "echo", "no-bootstrap-ok")
	wantContains(t, out, "no-bootstrap-ok", "bootstrap: --no-bootstrap stdout")
}

// testVMLifecycle ports scenario_vm_lifecycle. Drives an explicit
// create / show / ssh / stop / start / ssh / delete and asserts the
// state transitions are visible in `vm show`.
func testVMLifecycle(t *testing.T) {
	t.Parallel()
	const name = "smoke-lifecycle"
	vmCreate(t, name)

	show := mustBanger(t, "vm", "show", name)
	wantContains(t, show, `"state": "running"`, "post-create state")

	waitForSSH(t, name)
	out := mustBanger(t, "vm", "ssh", name, "--", "echo", "hello-1")
	wantContains(t, out, "hello-1", "vm ssh #1")

	mustBanger(t, "vm", "stop", name)
	show = mustBanger(t, "vm", "show", name)
	wantContains(t, show, `"state": "stopped"`, "post-stop state")

	mustBanger(t, "vm", "start", name)
	show = mustBanger(t, "vm", "show", name)
	wantContains(t, show, `"state": "running"`, "post-start state")

	waitForSSH(t, name)
	out = mustBanger(t, "vm", "ssh", name, "--", "echo", "hello-2")
	wantContains(t, out, "hello-2", "vm ssh #2 (post-restart)")

	mustBanger(t, "vm", "delete", name)
	res := banger(t, "vm", "show", name)
	if res.rc == 0 {
		t.Fatalf("vm show still finds %q after delete\nstdout: %s", name, res.stdout)
	}
}

// testVMSet ports scenario_vm_set. Creates with --vcpu 2, asserts
// guest sees 2 CPUs, reconfigures to 4 while stopped, asserts guest
// sees 4 after restart.
func testVMSet(t *testing.T) {
	t.Parallel()
	const name = "smoke-set"
	vmCreate(t, name, "--vcpu", "2")
	waitForSSH(t, name)

	out := mustBanger(t, "vm", "ssh", name, "--", "nproc")
	if got := strings.TrimSpace(out); got != "2" {
		t.Fatalf("vm set: initial nproc got %q, want 2", got)
	}

	mustBanger(t, "vm", "stop", name)
	mustBanger(t, "vm", "set", name, "--vcpu", "4")
	mustBanger(t, "vm", "start", name)
	waitForSSH(t, name)

	out = mustBanger(t, "vm", "ssh", name, "--", "nproc")
	if got := strings.TrimSpace(out); got != "4" {
		t.Fatalf("vm set: post-reconfig nproc got %q, want 4 (spec change didn't land)", got)
	}
}

// testVMRestart ports scenario_vm_restart. Reads /proc boot_id before
// and after `vm restart`; the kernel regenerates it on every boot, so
// distinct values prove the verb actually rebooted the guest.
func testVMRestart(t *testing.T) {
	t.Parallel()
	const name = "smoke-restart"
	vmCreate(t, name)
	waitForSSH(t, name)

	bootBefore := strings.TrimSpace(mustBanger(t, "vm", "ssh", name, "--", "cat", "/proc/sys/kernel/random/boot_id"))
	if bootBefore == "" {
		t.Fatalf("vm restart: could not read initial boot_id")
	}

	mustBanger(t, "vm", "restart", name)
	waitForSSH(t, name)

	bootAfter := strings.TrimSpace(mustBanger(t, "vm", "ssh", name, "--", "cat", "/proc/sys/kernel/random/boot_id"))
	if bootAfter == "" {
		t.Fatalf("vm restart: could not read post-restart boot_id")
	}
	if bootBefore == bootAfter {
		t.Fatalf("vm restart: boot_id unchanged (%s); verb didn't actually reboot the guest", bootBefore)
	}
}

// dmDevRE captures the dm-snapshot device name from `vm show` JSON.
// Used by testVMKill to check that `vm kill --signal KILL` cleans up
// the dm device alongside the firecracker process.
var dmDevRE = regexp.MustCompile(`"dm_dev":\s*"(fc-rootfs-[^"]+)"`)

// testVMKill ports scenario_vm_kill. `vm kill --signal KILL` must stop
// the VM and clean up its dm-snapshot device. The dm-name capture
// degrades gracefully — older builds without the field still pass the
// state-check half.
func testVMKill(t *testing.T) {
	t.Parallel()
	const name = "smoke-kill"
	vmCreate(t, name)

	show := mustBanger(t, "vm", "show", name)
	var dmName string
	if m := dmDevRE.FindStringSubmatch(show); len(m) == 2 {
		dmName = m[1]
	}

	mustBanger(t, "vm", "kill", "--signal", "KILL", name)
	show = mustBanger(t, "vm", "show", name)
	wantContains(t, show, `"state": "stopped"`, "post-kill state")

	if dmName != "" {
		out, _ := exec.Command("sudo", "-n", "dmsetup", "ls").CombinedOutput()
		for _, line := range strings.Split(string(out), "\n") {
			fields := strings.Fields(line)
			if len(fields) > 0 && fields[0] == dmName {
				t.Fatalf("vm kill: dm device %q still mapped (cleanup didn't run)", dmName)
			}
		}
	}
}

// testVMPorts ports scenario_vm_ports. Asserts `vm ports` reports the
// guest's sshd listener under the VM's DNS name.
func testVMPorts(t *testing.T) {
	t.Parallel()
	const name = "smoke-ports"
	vmCreate(t, name)
	waitForSSH(t, name)

	out := mustBanger(t, "vm", "ports", name)
	wantContains(t, out, "smoke-ports.vm:22", "vm ports stdout (host:port)")
	wantContains(t, out, "sshd", "vm ports stdout (process name)")
}

// testSSHConfig ports scenario_ssh_config. Drives ssh-config
// install/uninstall against a fake $HOME so the contributor's real
// ~/.ssh/config is never touched. Verifies idempotent install,
// preservation of pre-existing user content, and clean uninstall.
func testSSHConfig(t *testing.T) {
	t.Parallel()
	fakeHome := t.TempDir()
	if err := os.MkdirAll(filepath.Join(fakeHome, ".ssh"), 0o700); err != nil {
		t.Fatalf("mkdir .ssh: %v", err)
	}
	cfg := filepath.Join(fakeHome, ".ssh", "config")
	if err := os.WriteFile(cfg, []byte("Host myserver\n  HostName example.invalid\n"), 0o600); err != nil {
		t.Fatalf("write fake config: %v", err)
	}

	mustBangerHome(t, fakeHome, "ssh-config", "--install")
	cfgBytes, err := os.ReadFile(cfg)
	if err != nil {
		t.Fatalf("read fake config after install: %v", err)
	}
	body := string(cfgBytes)
	if !strings.Contains(body, "\nInclude ") && !strings.HasPrefix(body, "Include ") {
		t.Fatalf("ssh-config: install didn't add Include line:\n%s", body)
	}
	wantContains(t, body, "Host myserver", "ssh-config: install must preserve user content")

	mustBangerHome(t, fakeHome, "ssh-config", "--install")
	cfgBytes, _ = os.ReadFile(cfg)
	body = string(cfgBytes)
	includeCount := 0
	for _, line := range strings.Split(body, "\n") {
		if strings.HasPrefix(line, "Include ") && strings.Contains(line, "banger") {
			includeCount++
		}
	}
	if includeCount != 1 {
		t.Fatalf("ssh-config: install not idempotent (Include appeared %d times)", includeCount)
	}

	mustBangerHome(t, fakeHome, "ssh-config", "--uninstall")
	cfgBytes, _ = os.ReadFile(cfg)
	body = string(cfgBytes)
	for _, line := range strings.Split(body, "\n") {
		if strings.HasPrefix(line, "Include ") && strings.Contains(line, "banger") {
			t.Fatalf("ssh-config: uninstall left the Include line behind:\n%s", body)
		}
	}
	wantContains(t, body, "Host myserver", "ssh-config: uninstall must keep user content")
}
