//go:build smoke

package smoketest

import (
	"errors"
	"fmt"
	"io"
	"os"
	"os/exec"
	"os/user"
	"path/filepath"
	"regexp"
	"strings"
	"testing"
)

// Package-level state set up in TestMain and consumed by every test.
// Lowercase, file-scope; tests in this package don't share globals
// with other packages because of the build tag.
var (
	bangerBin   string
	bangerdBin  string
	vsockBin    string
	coverDir    string
	scratchRoot string
	runtimeDir  string
	repoDir     string
	smokeOwner  string
)

const (
	serviceCoverDir = "/var/lib/banger"
	smokeMarker     = "/etc/banger/.smoke-owned"
	ownerService    = "bangerd.service"
	rootService     = "bangerd-root.service"
)

// smokeConfigTOML is the smoke-tuned daemon config dropped at
// /etc/banger/config.toml after install (mirrors scripts/smoke.sh:404-415).
// Small VMs by default — scenarios that need full-size resources override
// --vcpu / --memory / --disk-size explicitly.
const smokeConfigTOML = `# Smoke-tuned defaults — every VM starts small unless the scenario
# overrides --vcpu / --memory / --disk-size explicitly.
[vm_defaults]
vcpu = 2
memory_mib = 1024
disk_size = "2G"
system_overlay_size = "2G"
`

func TestMain(m *testing.M) {
	// `go test -list ...` (used by `make smoke-list`) just enumerates
	// the test names. Skip the install preamble and let m.Run() print
	// the listing — env vars + KVM aren't needed for discovery.
	if isListMode() {
		os.Exit(m.Run())
	}

	if err := requireEnv(); err != nil {
		fmt.Fprintf(os.Stderr, "[smoke] %v\n", err)
		// Skip cleanly when run outside `make smoke`. Returning 0
		// prevents `go test` from being mistaken for a real failure
		// when a contributor accidentally runs the smoke package
		// directly without the harness env.
		os.Exit(0)
	}

	// Export GOCOVERDIR so every banger / bangerd subprocess this
	// test binary spawns lands its covdata under BANGER_SMOKE_COVER_DIR.
	// The test binary itself is not instrumented; only the smoke
	// binaries are (they were built with `go build -cover`).
	if err := os.Setenv("GOCOVERDIR", coverDir); err != nil {
		fmt.Fprintf(os.Stderr, "[smoke] setenv GOCOVERDIR: %v\n", err)
		os.Exit(1)
	}

	if err := installPreamble(); err != nil {
		fmt.Fprintf(os.Stderr, "[smoke] install preamble failed: %v\n", err)
		os.Exit(1)
	}

	if err := setupRepoFixture(); err != nil {
		fmt.Fprintf(os.Stderr, "[smoke] fixture setup failed: %v\n", err)
		teardown()
		os.Exit(1)
	}

	code := m.Run()
	teardown()
	os.Exit(code)
}

// isListMode returns true when the test binary was invoked with the
// `-test.list` flag, which `go test -list ...` translates into. In that
// mode the harness only enumerates names and never spawns a test, so
// requireEnv / installPreamble would needlessly block discovery on a
// fresh checkout (no KVM, no sudo).
func isListMode() bool {
	for _, a := range os.Args[1:] {
		if a == "-test.list" || strings.HasPrefix(a, "-test.list=") {
			return true
		}
	}
	return false
}

// requireEnv reads and validates the three BANGER_SMOKE_* env vars and
// confirms the binaries they point at exist and are executable. Returns
// a single descriptive error so a contributor running by hand sees
// exactly which variable is missing.
func requireEnv() error {
	binDir := os.Getenv("BANGER_SMOKE_BIN_DIR")
	if binDir == "" {
		return errors.New("BANGER_SMOKE_BIN_DIR not set; run via `make smoke`")
	}
	cov := os.Getenv("BANGER_SMOKE_COVER_DIR")
	if cov == "" {
		return errors.New("BANGER_SMOKE_COVER_DIR not set; run via `make smoke`")
	}
	xdg := os.Getenv("BANGER_SMOKE_XDG_DIR")
	if xdg == "" {
		return errors.New("BANGER_SMOKE_XDG_DIR not set; run via `make smoke`")
	}

	bangerBin = filepath.Join(binDir, "banger")
	bangerdBin = filepath.Join(binDir, "bangerd")
	vsockBin = filepath.Join(binDir, "banger-vsock-agent")
	coverDir = cov
	scratchRoot = xdg

	for _, bin := range []string{bangerBin, bangerdBin, vsockBin} {
		st, err := os.Stat(bin)
		if err != nil {
			return fmt.Errorf("smoke binary missing: %s: %w", bin, err)
		}
		if st.Mode()&0o111 == 0 {
			return fmt.Errorf("smoke binary not executable: %s", bin)
		}
	}

	if err := os.MkdirAll(coverDir, 0o755); err != nil {
		return fmt.Errorf("mkdir cover dir: %w", err)
	}
	// Reset the scratch root each run — leftover state from a prior
	// crashed run would otherwise leak into this one's fixtures.
	if err := os.RemoveAll(scratchRoot); err != nil {
		return fmt.Errorf("clean scratch root: %w", err)
	}
	if err := os.MkdirAll(scratchRoot, 0o755); err != nil {
		return fmt.Errorf("mkdir scratch root: %w", err)
	}

	rt, err := os.MkdirTemp(scratchRoot, "runtime-")
	if err != nil {
		return fmt.Errorf("mktemp runtime: %w", err)
	}
	runtimeDir = rt

	u, err := user.Current()
	if err != nil {
		return fmt.Errorf("user.Current: %w", err)
	}
	smokeOwner = u.Username

	return nil
}

// installPreamble mirrors scripts/smoke.sh's install_preamble. Refuses to
// overwrite a non-smoke install, otherwise installs the instrumented
// services, runs doctor, drops the smoke-tuned config, and restarts.
func installPreamble() error {
	if installExists() {
		if markerExists() {
			fmt.Fprintln(os.Stderr, "[smoke] found stale smoke-owned install; purging it first")
			_ = exec.Command("sudo", "env", "GOCOVERDIR="+coverDir, bangerBin,
				"system", "uninstall", "--purge").Run()
		} else {
			return errors.New("banger is already installed on this host; supported-path smoke refuses to overwrite a non-smoke install")
		}
	}

	// Wipe the user-side known_hosts. Fresh VMs reuse guest IPs with
	// new host keys every run; a stale entry trips StrictHostKeyChecking.
	// scripts/smoke.sh:374-380 explains why this is host-side, not
	// daemon-side state.
	if home, err := os.UserHomeDir(); err == nil {
		_ = os.Remove(filepath.Join(home, ".local", "state", "banger", "ssh", "known_hosts"))
	}

	fmt.Fprintln(os.Stderr, "[smoke] installing smoke-owned services")
	install := exec.Command("sudo", "env",
		"GOCOVERDIR="+coverDir,
		"BANGER_SYSTEM_GOCOVERDIR="+serviceCoverDir,
		"BANGER_ROOT_HELPER_GOCOVERDIR="+serviceCoverDir,
		bangerBin, "system", "install", "--owner", smokeOwner,
	)
	if out, err := install.CombinedOutput(); err != nil {
		return fmt.Errorf("system install: %w\n%s", err, out)
	}
	if out, err := exec.Command("sudo", "touch", smokeMarker).CombinedOutput(); err != nil {
		return fmt.Errorf("touch smoke marker: %w\n%s", err, out)
	}

	if err := assertServicesActive("after install"); err != nil {
		return err
	}

	fmt.Fprintln(os.Stderr, "[smoke] doctor: checking host readiness")
	if out, err := exec.Command(bangerBin, "doctor").CombinedOutput(); err != nil {
		return fmt.Errorf("doctor reported failures; fix the host before running smoke:\n%s", out)
	}

	fmt.Fprintln(os.Stderr, "[smoke] writing smoke-tuned daemon config")
	if err := writeSmokeConfig(); err != nil {
		return err
	}

	fmt.Fprintln(os.Stderr, "[smoke] system restart: services should come back cleanly")
	restart := exec.Command("sudo", "env", "GOCOVERDIR="+coverDir,
		bangerBin, "system", "restart")
	if out, err := restart.CombinedOutput(); err != nil {
		return fmt.Errorf("system restart: %w\n%s", err, out)
	}
	return assertServicesActive("after restart")
}

// installExists checks /etc/banger/install.toml under sudo (the dir is
// not always world-readable).
func installExists() bool {
	return exec.Command("sudo", "test", "-f", "/etc/banger/install.toml").Run() == nil
}

func markerExists() bool {
	return exec.Command("sudo", "test", "-f", smokeMarker).Run() == nil
}

var (
	statusOwnerRE  = regexp.MustCompile(`(?m)^active\s+active\b`)
	statusHelperRE = regexp.MustCompile(`(?m)^helper_active\s+active\b`)
)

func assertServicesActive(label string) error {
	out, err := exec.Command(bangerBin, "system", "status").CombinedOutput()
	if err != nil {
		return fmt.Errorf("system status %s: %w\n%s", label, err, out)
	}
	if !statusOwnerRE.Match(out) {
		return fmt.Errorf("owner daemon not active %s:\n%s", label, out)
	}
	if !statusHelperRE.Match(out) {
		return fmt.Errorf("root helper not active %s:\n%s", label, out)
	}
	return nil
}

// writeSmokeConfig drops smokeConfigTOML at /etc/banger/config.toml via
// `sudo tee`. tee is the path of least resistance for "write to a root-
// owned file from a non-root process".
func writeSmokeConfig() error {
	cmd := exec.Command("sudo", "tee", "/etc/banger/config.toml")
	cmd.Stdin = strings.NewReader(smokeConfigTOML)
	cmd.Stdout = io.Discard
	cmd.Stderr = os.Stderr
	if err := cmd.Run(); err != nil {
		return fmt.Errorf("write smoke config: %w", err)
	}
	return nil
}

// teardown is the equivalent of scripts/smoke.sh's `cleanup` trap. It
// best-efforts every step — partial failures during teardown should
// not mask the test outcome.
func teardown() {
	shutdownReleaseServer()
	stopServicesForCoverage()
	collectServiceCoverage()
	_ = exec.Command("sudo", "env", "GOCOVERDIR="+coverDir, bangerBin,
		"system", "uninstall", "--purge").Run()
	_ = os.RemoveAll(scratchRoot)
}

func stopServicesForCoverage() {
	_ = exec.Command("sudo", "systemctl", "stop", ownerService, rootService).Run()
}

// collectServiceCoverage copies covmeta.* / covcounters.* out of
// /var/lib/banger into BANGER_SMOKE_COVER_DIR, chowning to the test
// user so subsequent `go tool covdata` invocations can read them.
// Mirrors the inline `sudo bash -lc '...'` in scripts/smoke.sh:307-325.
func collectServiceCoverage() {
	uid := fmt.Sprint(os.Getuid())
	gid := fmt.Sprint(os.Getgid())
	const script = `
shopt -s nullglob
for file in "$1"/covmeta.* "$1"/covcounters.*; do
  base="${file##*/}"
  cp "$file" "$2/$base"
  chown "$3:$4" "$2/$base"
  chmod 0644 "$2/$base"
done
`
	_ = exec.Command("sudo", "bash", "-c", script, "bash",
		serviceCoverDir, coverDir, uid, gid).Run()
}
