//go:build smoke

package smoketest

import (
	"bytes"
	"os"
	"os/exec"
	"strings"
	"testing"
	"time"
)

// result captures the output and exit status of a banger invocation.
// stdout / stderr are kept separate so assertions can target one or the
// other (matches the bash suite's `out=$(cmd)` vs `2>&1` patterns).
type result struct {
	stdout string
	stderr string
	rc     int
}

// runCmd executes the given exec.Cmd, capturing stdout and stderr into
// the returned result. Non-zero exits are returned as a non-zero rc, not
// as an error — scenarios decide for themselves whether non-zero is a
// failure or the assertion under test.
func runCmd(t *testing.T, cmd *exec.Cmd) result {
	t.Helper()
	var outBuf, errBuf bytes.Buffer
	cmd.Stdout = &outBuf
	cmd.Stderr = &errBuf
	err := cmd.Run()
	res := result{stdout: outBuf.String(), stderr: errBuf.String()}
	if err != nil {
		if exitErr, ok := err.(*exec.ExitError); ok {
			res.rc = exitErr.ExitCode()
		} else {
			t.Fatalf("exec %s: %v\nstderr: %s", strings.Join(cmd.Args, " "), err, res.stderr)
		}
	}
	return res
}

// banger runs the instrumented `banger` binary with the given arguments
// and returns the captured result. GOCOVERDIR is inherited from the
// process environment (TestMain exports it), so child covdata lands
// under BANGER_SMOKE_COVER_DIR automatically.
func banger(t *testing.T, args ...string) result {
	t.Helper()
	return runCmd(t, exec.Command(bangerBin, args...))
}

// mustBanger runs `banger` and Fatals if it exits non-zero. Returns the
// captured stdout for downstream `wantContains`. Most happy-path
// scenarios use this; scenarios that assert on non-zero exits use
// banger() directly.
func mustBanger(t *testing.T, args ...string) string {
	t.Helper()
	res := banger(t, args...)
	if res.rc != 0 {
		t.Fatalf("banger %s: exit %d\nstdout: %s\nstderr: %s",
			strings.Join(args, " "), res.rc, res.stdout, res.stderr)
	}
	return res.stdout
}

// sudoBanger runs `banger` under `sudo env GOCOVERDIR=...`. Sudo strips
// the env by default; explicit re-export keeps coverage flowing for
// scenarios that exercise the privileged path (system install / restart
// / update / daemon stop).
func sudoBanger(t *testing.T, args ...string) result {
	t.Helper()
	full := append([]string{"env", "GOCOVERDIR=" + coverDir, bangerBin}, args...)
	return runCmd(t, exec.Command("sudo", full...))
}

// wantContains asserts that haystack contains needle. label is a short
// human-readable identifier for the failure message.
func wantContains(t *testing.T, haystack, needle, label string) {
	t.Helper()
	if !strings.Contains(haystack, needle) {
		t.Fatalf("%s missing %q\ngot: %s", label, needle, haystack)
	}
}

// wantNotContains is the negative-assertion counterpart. Used by
// scenarios that verify a warning has been suppressed (e.g. the post-
// auto-prepare clean-state check in vm_exec) or that an export patch
// did NOT capture a guest-side commit.
func wantNotContains(t *testing.T, haystack, needle, label string) {
	t.Helper()
	if strings.Contains(haystack, needle) {
		t.Fatalf("%s unexpectedly contains %q\ngot: %s", label, needle, haystack)
	}
}

// wantExit asserts the captured result exited with want. Used for
// scenarios that test exit-code propagation or refusal paths.
func wantExit(t *testing.T, got result, want int, label string) {
	t.Helper()
	if got.rc != want {
		t.Fatalf("%s: exit %d, want %d\nstdout: %s\nstderr: %s",
			label, got.rc, want, got.stdout, got.stderr)
	}
}

// vmDelete removes a VM, ignoring failure. Used in t.Cleanup hooks
// where the VM may already be gone (deleted by the scenario itself).
func vmDelete(name string) {
	cmd := exec.Command(bangerBin, "vm", "delete", name)
	_ = cmd.Run()
}

// vmCreate creates a VM with the given name and registers a cleanup
// hook to delete it. extraArgs is forwarded after `vm create --name X`
// so callers can pass --vcpu N / --nat / --no-start / etc. Fatals if
// creation fails — every scenario that uses vmCreate needs the VM up.
func vmCreate(t *testing.T, name string, extraArgs ...string) {
	t.Helper()
	args := append([]string{"vm", "create", "--name", name}, extraArgs...)
	mustBanger(t, args...)
	t.Cleanup(func() { vmDelete(name) })
}

// bangerHome runs `banger` with HOME overridden to the given directory.
// Used by ssh-config scenarios that mutate ~/.ssh/config under a fake
// home so the test doesn't touch the contributor's real config.
func bangerHome(t *testing.T, home string, args ...string) result {
	t.Helper()
	cmd := exec.Command(bangerBin, args...)
	cmd.Env = append(os.Environ(), "HOME="+home)
	return runCmd(t, cmd)
}

// mustBangerHome is bangerHome + Fatal-on-non-zero. Returns stdout.
func mustBangerHome(t *testing.T, home string, args ...string) string {
	t.Helper()
	res := bangerHome(t, home, args...)
	if res.rc != 0 {
		t.Fatalf("banger %s (HOME=%s): exit %d\nstdout: %s\nstderr: %s",
			strings.Join(args, " "), home, res.rc, res.stdout, res.stderr)
	}
	return res.stdout
}

// waitForSSH polls `banger vm ssh <name> -- true` until SSH answers,
// up to 120 seconds. The original bash suite used 60s and occasionally
// flaked under load (post-update VM, large parallel pool); 120s gives
// enough headroom for the post-update / post-rollback paths where the
// daemon has just restarted, without making genuine breakage slow to
// surface.
func waitForSSH(t *testing.T, name string) {
	t.Helper()
	const timeout = 120 * time.Second
	deadline := time.Now().Add(timeout)
	for time.Now().Before(deadline) {
		cmd := exec.Command(bangerBin, "vm", "ssh", name, "--", "true")
		if err := cmd.Run(); err == nil {
			return
		}
		time.Sleep(1 * time.Second)
	}
	t.Fatalf("vm %q ssh did not come up within %s", name, timeout)
}

// requirePasswordlessSudo skips the test if `sudo -n true` cannot run.
// Mirrors the bash `if ! sudo -n true 2>/dev/null; then return 0; fi`
// pattern used by scenarios that exercise privileged paths.
func requirePasswordlessSudo(t *testing.T) {
	t.Helper()
	if err := exec.Command("sudo", "-n", "true").Run(); err != nil {
		t.Skip("passwordless sudo unavailable")
	}
}

// requireSudoIptables skips the test if iptables can't be queried under
// `sudo -n`. Used by the NAT scenario whose assertions read POSTROUTING.
func requireSudoIptables(t *testing.T) {
	t.Helper()
	if err := exec.Command("sudo", "-n", "iptables", "-t", "nat", "-S", "POSTROUTING").Run(); err != nil {
		t.Skip("passwordless sudo iptables unavailable")
	}
}

// installedVersion reads `/usr/local/bin/banger --version` and returns
// the version token. This is the *installed* binary that `banger update`
// swaps out — the smoke CLI under $BANGER_SMOKE_BIN_DIR is separate
// (and unaffected by update). Mirrors the bash `installed_version`
// helper at scripts/smoke.sh:1156-1162.
func installedVersion(t *testing.T) string {
	t.Helper()
	out, err := exec.Command("/usr/local/bin/banger", "--version").Output()
	if err != nil {
		t.Fatalf("read installed version: %v", err)
	}
	parts := strings.Fields(string(out))
	if len(parts) < 2 {
		t.Fatalf("unparseable installed --version output: %q", string(out))
	}
	return parts[1]
}
