//go:build e2e && unix

package main

import (
	"bytes"
	"crypto/tls"
	"crypto/x509"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
	"os"
	"os/exec"
	"path/filepath"
	"regexp"
	"strconv"
	"strings"
	"sync"
	"syscall"
	"testing"
	"time"
	"unsafe"

	"github.com/creack/pty"
	"github.com/hinshun/vt10x"
)

const ringSize = 1 << 20 // 1 MiB scrollback

// mergeEnv merges base and overrides, collapsing duplicate keys with later
// writes winning. On Linux glibc's getenv returns the first matching entry,
// so simple appending wouldn't let callers override framework defaults —
// and base itself can contain duplicates (os.Environ() inheriting a value
// the framework also sets), so both halves need de-duping.
func mergeEnv(base, overrides []string) []string {
	idx := make(map[string]int, len(base)+len(overrides))
	out := make([]string, 0, len(base)+len(overrides))
	merge := func(kv string) {
		eq := strings.IndexByte(kv, '=')
		if eq < 0 {
			out = append(out, kv)
			return
		}
		key := kv[:eq]
		if i, ok := idx[key]; ok {
			out[i] = kv
			return
		}
		idx[key] = len(out)
		out = append(out, kv)
	}
	for _, kv := range base {
		merge(kv)
	}
	for _, kv := range overrides {
		merge(kv)
	}
	return out
}

// ANSI cleaner (CSI + OSC + CR)
var ansiRe = regexp.MustCompile(`(?:\x1b\[[0-9;?]*[ -/]*[@-~])|(?:\x1b\][^\x07]*\x07)|\r`)

// TUITestFramework is a minimal driver for Argonaut e2e tests
type TUITestFramework struct {
	t   *testing.T
	pty *os.File
	tty *os.File
	cmd *exec.Cmd

	workspace string

	mu   sync.Mutex
	buf  []byte
	head int
	full bool

	// vt is a terminal emulator that interprets cursor positioning
	// to give us the actual screen state, not just raw output
	vt     vt10x.Terminal
	vtRows int
	vtCols int

	// extraConfig is additional TOML config appended to the test config file
	extraConfig string

	// requestTimeout overrides the default "2s" request_timeout for E2E tests.
	// Set before calling StartApp/StartAppArgs.
	requestTimeout string
}

func NewTUITest(t *testing.T) *TUITestFramework {
	t.Helper()
	return &TUITestFramework{t: t, buf: make([]byte, ringSize)}
}

// ensureBinary builds the app test binary if it doesn't exist yet.
func ensureBinary(t *testing.T) error {
	t.Helper()
	// Resolve absolute binPath under e2e dir
	cwd, err := os.Getwd()
	if err != nil {
		return err
	}
	p := binPath
	if !filepath.IsAbs(p) {
		p = filepath.Join(cwd, p)
	}
	// Already exists?
	if st, err := os.Stat(p); err == nil && st.Mode().IsRegular() {
		binPath = p
		return nil
	}
	// Build it
	cmd := exec.Command("go", "build", "-o", p, "./cmd/app")
	cmd.Dir = ".."
	if out, err := cmd.CombinedOutput(); err != nil {
		return fmt.Errorf("build failed: %v\n%s", err, string(out))
	}
	binPath = p
	return nil
}

// Workspace returns the isolated workspace directory for this test
func (tf *TUITestFramework) Workspace() string {
	return tf.workspace
}

// SetupWorkspace creates an isolated HOME and returns ARGOCD_CONFIG path to write
func (tf *TUITestFramework) SetupWorkspace() (string, error) {
	tf.t.Helper()
	dir := tf.t.TempDir()
	tf.workspace = dir

	// t.TempDir() registers a RemoveAll cleanup, and Go's t.Cleanup is LIFO.
	// Tests typically register tf.Cleanup() in their body BEFORE calling
	// SetupWorkspace, which means the temp-dir RemoveAll would run first
	// while the app subprocess is still alive and writing config files,
	// causing intermittent "directory not empty" errors. Register a
	// just-in-time process kill here so it runs *before* the temp-dir
	// RemoveAll (LIFO: last registered runs first).
	tf.t.Cleanup(func() {
		if tf.cmd != nil && tf.cmd.Process != nil {
			_ = tf.cmd.Process.Kill()
			_, _ = tf.cmd.Process.Wait()
		}
	})
	// Ensure ~/.config/argocd exists
	cfgDir := filepath.Join(dir, ".config", "argocd")
	if err := os.MkdirAll(cfgDir, 0o755); err != nil {
		return "", err
	}

	// Also ensure ~/.config/argonaut exists for isolated Argonaut config
	argonautCfgDir := filepath.Join(dir, ".config", "argonaut")
	if err := os.MkdirAll(argonautCfgDir, 0o755); err != nil {
		return "", err
	}

	return filepath.Join(cfgDir, "config"), nil
}

// StartApp runs the compiled binary under a PTY
func (tf *TUITestFramework) StartApp(extraEnv ...string) error {
	tf.t.Helper()
	if err := ensureBinary(tf.t); err != nil {
		return err
	}
	tf.cmd = exec.Command(binPath)
	p, t, err := pty.Open()
	if err != nil {
		return err
	}
	tf.pty, tf.tty = p, t

	tf.cmd.Stdout, tf.cmd.Stdin, tf.cmd.Stderr = t, t, t
	// Run the app in the isolated workspace so per-test files (e.g., logs) don't clash
	if tf.workspace != "" {
		tf.cmd.Dir = tf.workspace
	}
	// Create low timeout config for all E2E tests to make them faster
	configDir := filepath.Join(tf.workspace, ".config", "argonaut")
	if err := os.MkdirAll(configDir, 0755); err != nil {
		return err
	}
	configPath := filepath.Join(configDir, "config.toml")
	
	// Create a mock clipboard file for all tests (even if they don't use it)
	clipboardFile := filepath.Join(tf.workspace, "clipboard.txt")
	
	// Create test config with both timeout and clipboard settings.
	// Extra config is prepended so top-level keys appear before TOML sections.
	testConfig := ""
	if tf.extraConfig != "" {
		testConfig += tf.extraConfig + "\n"
	}
	reqTimeout := tf.requestTimeout
	if reqTimeout == "" {
		reqTimeout = "2s"
	}
	testConfig += `[http_timeouts]
# Use lower timeout for E2E tests to speed them up
request_timeout = "` + reqTimeout + `"

[clipboard]
# Mock clipboard for tests - writes to file instead of system clipboard
copy_command = "tee ` + clipboardFile + `"

[updates]
# Disable the GitHub-API update check during e2e runs — it adds latency,
# paints "New version available" in the status bar, and contends for CPU.
check_enabled = false`

	if err := os.WriteFile(configPath, []byte(testConfig), 0600); err != nil {
		return err
	}

	env := append(os.Environ(),
		"TERM=xterm-256color",
		"LC_ALL=C",
		"LANG=C",
		"HOME="+tf.workspace,
		"ARGONAUT_E2E=1",
		// Force isolated Argonaut config - clear any inherited config paths
		"ARGONAUT_CONFIG="+configPath,
		"XDG_CONFIG_HOME=", // Clear XDG_CONFIG_HOME to ensure HOME-based path is used
		// Tests don't need the production retry budget — collapse it so
		// failure-path tests don't pay seconds of exponential back-off.
		// Tests that specifically exercise the "Connecting…" spinner can
		// override these via extraEnv (mergeEnv lets later entries win).
		"ARGONAUT_RETRY_MAX_ATTEMPTS=2",
		"ARGONAUT_RETRY_INITIAL_DELAY=10ms",
		// The 500ms watch-scope debounce is real-user-friendly anti-thrash;
		// in tests we want the restart to fire as soon as scope changes.
		"ARGONAUT_WATCH_SCOPE_DEBOUNCE=10ms",
		// The 500ms watch-event batch drain reduces render churn for busy
		// streams; tests want time-to-first-render to be short.
		"ARGONAUT_WATCH_BATCH_DRAIN=20ms",
	)
	env = mergeEnv(env, extraEnv)
	tf.cmd.Env = env

	// Set window size
	tf.vtRows, tf.vtCols = 40, 120
	ws := struct{ Row, Col, X, Y uint16 }{uint16(tf.vtRows), uint16(tf.vtCols), 0, 0}
	syscall.Syscall(syscall.SYS_IOCTL, p.Fd(), uintptr(syscall.TIOCSWINSZ), uintptr(unsafe.Pointer(&ws)))

	// Initialize terminal emulator to interpret cursor positioning
	tf.vt = vt10x.New(vt10x.WithSize(tf.vtCols, tf.vtRows))

	if err := tf.cmd.Start(); err != nil {
		_ = p.Close()
		_ = t.Close()
		return err
	}
	go tf.readLoop()
	return nil
}

// StartAppArgs starts the app with explicit CLI args and optional env
func (tf *TUITestFramework) StartAppArgs(args []string, extraEnv ...string) error {
	tf.t.Helper()
	if err := ensureBinary(tf.t); err != nil {
		return err
	}
	tf.cmd = exec.Command(binPath, args...)
	p, t, err := pty.Open()
	if err != nil {
		return err
	}
	tf.pty, tf.tty = p, t
	tf.cmd.Stdout, tf.cmd.Stdin, tf.cmd.Stderr = t, t, t
	if tf.workspace != "" {
		tf.cmd.Dir = tf.workspace
	}
	// Create low timeout config for all E2E tests to make them faster
	configDir := filepath.Join(tf.workspace, ".config", "argonaut")
	if err := os.MkdirAll(configDir, 0755); err != nil {
		return err
	}
	configPath := filepath.Join(configDir, "config.toml")
	
	// Create a mock clipboard file for all tests (even if they don't use it)
	clipboardFile := filepath.Join(tf.workspace, "clipboard.txt")
	
	// Create test config with both timeout and clipboard settings.
	// Extra config is prepended so top-level keys appear before TOML sections.
	testConfig := ""
	if tf.extraConfig != "" {
		testConfig += tf.extraConfig + "\n"
	}
	reqTimeout := tf.requestTimeout
	if reqTimeout == "" {
		reqTimeout = "2s"
	}
	testConfig += `[http_timeouts]
# Use lower timeout for E2E tests to speed them up
request_timeout = "` + reqTimeout + `"

[clipboard]
# Mock clipboard for tests - writes to file instead of system clipboard
copy_command = "tee ` + clipboardFile + `"

[updates]
# Disable the GitHub-API update check during e2e runs — it adds latency,
# paints "New version available" in the status bar, and contends for CPU.
check_enabled = false`

	if err := os.WriteFile(configPath, []byte(testConfig), 0600); err != nil {
		return err
	}

	env := append(os.Environ(),
		"TERM=xterm-256color",
		"LC_ALL=C",
		"LANG=C",
		"HOME="+tf.workspace,
		"ARGONAUT_E2E=1",
		// Force isolated Argonaut config - clear any inherited config paths
		"ARGONAUT_CONFIG="+configPath,
		"XDG_CONFIG_HOME=", // Clear XDG_CONFIG_HOME to ensure HOME-based path is used
		// Tests don't need the production retry budget — collapse it so
		// failure-path tests don't pay seconds of exponential back-off.
		// Tests that specifically exercise the "Connecting…" spinner can
		// override these via extraEnv (mergeEnv lets later entries win).
		"ARGONAUT_RETRY_MAX_ATTEMPTS=2",
		"ARGONAUT_RETRY_INITIAL_DELAY=10ms",
		// The 500ms watch-scope debounce is real-user-friendly anti-thrash;
		// in tests we want the restart to fire as soon as scope changes.
		"ARGONAUT_WATCH_SCOPE_DEBOUNCE=10ms",
		// The 500ms watch-event batch drain reduces render churn for busy
		// streams; tests want time-to-first-render to be short.
		"ARGONAUT_WATCH_BATCH_DRAIN=20ms",
	)
	env = mergeEnv(env, extraEnv)
	tf.cmd.Env = env

	// Set window size
	tf.vtRows, tf.vtCols = 40, 120
	ws := struct{ Row, Col, X, Y uint16 }{uint16(tf.vtRows), uint16(tf.vtCols), 0, 0}
	syscall.Syscall(syscall.SYS_IOCTL, p.Fd(), uintptr(syscall.TIOCSWINSZ), uintptr(unsafe.Pointer(&ws)))

	// Initialize terminal emulator to interpret cursor positioning
	tf.vt = vt10x.New(vt10x.WithSize(tf.vtCols, tf.vtRows))

	if err := tf.cmd.Start(); err != nil {
		_ = p.Close()
		_ = t.Close()
		return err
	}
	go tf.readLoop()
	return nil
}

func (tf *TUITestFramework) readLoop() {
	buf := make([]byte, 8192)
	for {
		n, err := tf.pty.Read(buf)
		if n > 0 {
			tf.mu.Lock()
			// Store raw output in ring buffer (for debugging/snapshots)
			for i := 0; i < n; i++ {
				tf.buf[tf.head] = buf[i]
				tf.head = (tf.head + 1) % ringSize
				if tf.head == 0 {
					tf.full = true
				}
			}
			// Feed output to terminal emulator for proper screen state
			if tf.vt != nil {
				_, _ = tf.vt.Write(buf[:n])
			}
			tf.mu.Unlock()
		}
		if err != nil {
			return
		}
	}
}

func (tf *TUITestFramework) Send(keys string) error { _, err := tf.pty.Write([]byte(keys)); return err }
func (tf *TUITestFramework) CtrlC() error           { return tf.Send("\x03") }
func (tf *TUITestFramework) Enter() error           { return tf.Send("\r") }
func (tf *TUITestFramework) Escape() error          { return tf.Send("\x1b") }

// Mouse event methods using SGR encoding (\x1b[<Btn;X;Y M/m)
// Coordinates are 1-based (terminal standard)

// MouseClick sends a mouse button press at (x, y). Button: 0=left, 1=middle, 2=right
func (tf *TUITestFramework) MouseClick(btn, x, y int) error {
	return tf.Send(fmt.Sprintf("\x1b[<%d;%d;%dM", btn, x, y))
}

// MouseRelease sends a mouse button release at (x, y). Button: 0=left, 1=middle, 2=right
func (tf *TUITestFramework) MouseRelease(btn, x, y int) error {
	return tf.Send(fmt.Sprintf("\x1b[<%d;%d;%dm", btn, x, y))
}

// MouseMotion sends a mouse motion event at (x, y) with button held. Button: 0=left, 1=middle, 2=right
func (tf *TUITestFramework) MouseMotion(btn, x, y int) error {
	// SGR motion encoding: button + 32
	return tf.Send(fmt.Sprintf("\x1b[<%d;%d;%dM", btn+32, x, y))
}

// MouseDrag simulates a click-drag-release sequence from (x1,y1) to (x2,y2)
func (tf *TUITestFramework) MouseDrag(x1, y1, x2, y2 int) error {
	// Press at start
	if err := tf.MouseClick(0, x1, y1); err != nil {
		return err
	}
	time.Sleep(10 * time.Millisecond)

	// Generate intermediate motion events
	steps := max(abs(x2-x1), abs(y2-y1))
	if steps == 0 {
		steps = 1
	}
	for i := 1; i <= steps; i++ {
		x := x1 + (x2-x1)*i/steps
		y := y1 + (y2-y1)*i/steps
		if err := tf.MouseMotion(0, x, y); err != nil {
			return err
		}
		time.Sleep(5 * time.Millisecond)
	}

	// Release at end
	time.Sleep(10 * time.Millisecond)
	return tf.MouseRelease(0, x2, y2)
}

func abs(x int) int {
	if x < 0 {
		return -x
	}
	return x
}

func (tf *TUITestFramework) Snapshot() string {
	tf.mu.Lock()
	defer tf.mu.Unlock()
	if !tf.full {
		return string(tf.buf[:tf.head])
	}
	out := make([]byte, ringSize)
	copy(out, tf.buf[tf.head:])
	copy(out[ringSize-tf.head:], tf.buf[:tf.head])
	return string(out)
}
func (tf *TUITestFramework) SnapshotPlain() string { return ansiRe.ReplaceAllString(tf.Snapshot(), "") }

// Screen returns the actual rendered screen content from the terminal emulator.
// Unlike SnapshotPlain which returns raw output with ANSI codes stripped,
// Screen properly interprets cursor positioning to show what users actually see.
func (tf *TUITestFramework) Screen() string {
	tf.mu.Lock()
	defer tf.mu.Unlock()
	if tf.vt == nil {
		return ""
	}
	return tf.vt.String()
}

func (tf *TUITestFramework) WaitForPlain(substr string, timeout time.Duration) bool {
	deadline := time.Now().Add(timeout)
	for time.Now().Before(deadline) {
		if strings.Contains(tf.SnapshotPlain(), substr) {
			return true
		}
		time.Sleep(25 * time.Millisecond)
	}
	return false
}

// WaitForScreen waits for a substring to appear in the actual rendered screen.
// Unlike WaitForPlain, this uses the terminal emulator for accurate screen state.
func (tf *TUITestFramework) WaitForScreen(substr string, timeout time.Duration) bool {
	deadline := time.Now().Add(timeout)
	for time.Now().Before(deadline) {
		if strings.Contains(tf.Screen(), substr) {
			return true
		}
		time.Sleep(25 * time.Millisecond)
	}
	return false
}

// OpenCommand enters command mode and waits for the command bar to be ready.
func (tf *TUITestFramework) OpenCommand() error {
	if err := tf.Send(":"); err != nil {
		return err
	}
	// Command bar shows "│ > " with box drawing character - unique to command input.
	// "> " alone matches the ASCII art logo, so we need the box char prefix.
	// Use a generous 10s timeout — CI runners are 2-core boxes with parallel=4
	// (heavy oversubscription) and the command-bar render can lag several
	// seconds under that load. The ceiling only bites on real regressions.
	if !tf.WaitForPlain("│ > ", 10*time.Second) {
		return fmt.Errorf("command bar not ready")
	}
	return nil
}

// OpenSearch enters search mode and waits for the search bar to be ready.
func (tf *TUITestFramework) OpenSearch() error {
	if err := tf.Send("/"); err != nil {
		return err
	}
	// Search bar shows label "Search" when ready
	if !tf.WaitForPlain("Search", 2*time.Second) {
		return fmt.Errorf("search bar not ready")
	}
	return nil
}

func (tf *TUITestFramework) Cleanup() {
	// Save snapshot if test failed
	if tf.t.Failed() {
		tf.saveFailureSnapshot()
	}

	if tf.pty != nil {
		_ = tf.pty.Close()
		tf.pty = nil
	}
	if tf.tty != nil {
		_ = tf.tty.Close()
		tf.tty = nil
	}
	if tf.cmd != nil && tf.cmd.Process != nil {
		_ = tf.cmd.Process.Kill()
		_, _ = tf.cmd.Process.Wait()
	}
}

// saveFailureSnapshot saves raw, plain, and screen snapshots to files for debugging
func (tf *TUITestFramework) saveFailureSnapshot() {
	// Create snapshots directory if it doesn't exist
	snapshotDir := "test-snapshots"
	if err := os.MkdirAll(snapshotDir, 0o755); err != nil {
		tf.t.Logf("Failed to create snapshot dir: %v", err)
		return
	}

	// Sanitize test name for filename
	testName := strings.ReplaceAll(tf.t.Name(), "/", "_")
	testName = strings.ReplaceAll(testName, " ", "_")

	// Save raw snapshot (with ANSI codes) for replay
	rawPath := filepath.Join(snapshotDir, testName+".raw")
	if err := os.WriteFile(rawPath, []byte(tf.Snapshot()), 0o644); err != nil {
		tf.t.Logf("Failed to save raw snapshot: %v", err)
	} else {
		tf.t.Logf("Saved raw snapshot to %s", rawPath)
	}

	// Save plain snapshot (ANSI stripped) for easy reading - this is the raw
	// output stream with ANSI codes removed, NOT the actual screen state
	plainPath := filepath.Join(snapshotDir, testName+".txt")
	if err := os.WriteFile(plainPath, []byte(tf.SnapshotPlain()), 0o644); err != nil {
		tf.t.Logf("Failed to save plain snapshot: %v", err)
	} else {
		tf.t.Logf("Saved plain snapshot to %s", plainPath)
	}

	// Save screen snapshot - this is the actual rendered screen content
	// from the terminal emulator, showing what users would actually see
	screenPath := filepath.Join(snapshotDir, testName+".screen")
	if err := os.WriteFile(screenPath, []byte(tf.Screen()), 0o644); err != nil {
		tf.t.Logf("Failed to save screen snapshot: %v", err)
	} else {
		tf.t.Logf("Saved screen snapshot to %s", screenPath)
	}
}

// MockArgoServer spins an httptest server that serves minimal Argo endpoints
func MockArgoServer() (*httptest.Server, error) {
	mux := http.NewServeMux()
	mux.HandleFunc("/api/v1/session/userinfo", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200); _, _ = w.Write([]byte(`{}`)) })
	mux.HandleFunc("/api/v1/applications", func(w http.ResponseWriter, r *http.Request) {
		// return one simple app with cluster and project metadata
		w.Header().Set("Content-Type", "application/json")
		_, _ = w.Write([]byte(wrapListResponse(`[{"metadata":{"name":"demo","namespace":"argocd"},"spec":{"project":"demo","destination":{"name":"cluster-a","namespace":"default"}},"status":{"sync":{"status":"Synced"},"health":{"status":"Healthy"}}}]`, "1000")))
	})
	mux.HandleFunc("/api/version", func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(`{"version":"e2e"}`)) })
	mux.HandleFunc("/api/v1/applications/demo/resource-tree", func(w http.ResponseWriter, r *http.Request) {
		_, _ = w.Write([]byte(`{"nodes":[
            {"kind":"Deployment","name":"demo","namespace":"default","version":"v1","group":"apps","uid":"dep-1","status":"Synced"},
            {"kind":"ReplicaSet","name":"demo-rs","namespace":"default","version":"v1","group":"apps","uid":"rs-1","status":"Synced","parentRefs":[{"uid":"dep-1","kind":"Deployment","name":"demo","namespace":"default","group":"apps","version":"v1"}]}
        ]}`))
	})
	// apps watch stream: send a single apps-loaded style event not required; ListApplications already populates
	mux.HandleFunc("/api/v1/stream/applications", func(w http.ResponseWriter, r *http.Request) {
		fl, _ := w.(http.Flusher)
		w.Header().Set("Content-Type", "text/event-stream")
		if shouldSendEvent(r, "demo") {
			_, _ = w.Write([]byte(sseEvent(`{"result":{"type":"MODIFIED","application":{"metadata":{"name":"demo","namespace":"argocd"},"spec":{"project":"demo","destination":{"name":"cluster-a","namespace":"default"}},"status":{"sync":{"status":"Synced"},"health":{"status":"Healthy"}}}}}`)))
		}
		if fl != nil {
			fl.Flush()
		}
	})
	// Handle delete operations
	mux.HandleFunc("/api/v1/applications/demo", func(w http.ResponseWriter, r *http.Request) {
		if r.Method == http.MethodDelete {
			// Add small delay to ensure loading modal is visible
			time.Sleep(50 * time.Millisecond)
			w.Header().Set("Content-Type", "application/json")
			// Return proper AppDeleteResponse with Success field
			_, _ = w.Write([]byte(`{"Success": true}`))
		}
	})
	srv := httptest.NewServer(mux)
	return srv, nil
}

// MockArgoServerStreaming creates a server whose REST list returns the demo
// app as Synced and whose SSE stream then sends a single OutOfSync event.
// This makes the corresponding test deterministic: the only way the apps
// view can show OutOfSync is if the streaming update was applied.
func MockArgoServerStreaming() (*httptest.Server, error) {
	mux := http.NewServeMux()
	mux.HandleFunc("/api/v1/session/userinfo", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200); _, _ = w.Write([]byte(`{}`)) })
	mux.HandleFunc("/api/v1/applications", func(w http.ResponseWriter, r *http.Request) {
		// Initial REST state: Synced.
		w.Header().Set("Content-Type", "application/json")
		_, _ = w.Write([]byte(wrapListResponse(`[{"metadata":{"name":"demo","namespace":"argocd"},"spec":{"project":"demo","destination":{"name":"cluster-a","namespace":"default"}},"status":{"sync":{"status":"Synced"},"health":{"status":"Healthy"}}}]`, "1000")))
	})
	mux.HandleFunc("/api/version", func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(`{"version":"e2e"}`)) })
	mux.HandleFunc("/api/v1/applications/demo/resource-tree", func(w http.ResponseWriter, r *http.Request) {
		_, _ = w.Write([]byte(`{"nodes":[
			{"kind":"Deployment","name":"demo","namespace":"default","version":"v1","group":"apps","uid":"dep-1","status":"Synced"},
			{"kind":"ReplicaSet","name":"demo-rs","namespace":"default","version":"v1","group":"apps","uid":"rs-1","status":"Synced","parentRefs":[{"uid":"dep-1","kind":"Deployment","name":"demo","namespace":"default","group":"apps","version":"v1"}]}
		]}`))
	})
	// SSE stream sends one OutOfSync event, then returns.
	mux.HandleFunc("/api/v1/stream/applications", func(w http.ResponseWriter, r *http.Request) {
		fl, _ := w.(http.Flusher)
		w.Header().Set("Content-Type", "text/event-stream")

		if shouldSendEvent(r, "demo") {
			_, _ = w.Write([]byte(sseEvent(`{"result":{"type":"MODIFIED","application":{"metadata":{"name":"demo","namespace":"argocd"},"spec":{"project":"demo","destination":{"name":"cluster-a","namespace":"default"}},"status":{"sync":{"status":"OutOfSync"},"health":{"status":"Healthy"}}}}}`)))
			if fl != nil {
				fl.Flush()
			}
		}
	})
	srv := httptest.NewServer(mux)
	return srv, nil
}

// WriteArgoConfig writes an argocd CLI config pointing to our test server
func WriteArgoConfig(path, baseURL string) error {
	return WriteArgoConfigWithToken(path, baseURL, "test-token")
}

// MockArgoServerAuth requires Authorization: Bearer <validToken> or returns 401
func MockArgoServerAuth(validToken string) (*httptest.Server, error) {
	mux := http.NewServeMux()
	// Enforce auth on userinfo and applications to drive auth-required view deterministically
	mux.HandleFunc("/api/v1/session/userinfo", func(w http.ResponseWriter, r *http.Request) {
		got := r.Header.Get("Authorization")
		if got != "Bearer "+validToken {
			w.WriteHeader(http.StatusUnauthorized)
			_, _ = w.Write([]byte(`{"error":"unauthorized"}`))
			return
		}
		w.WriteHeader(200)
		_, _ = w.Write([]byte(`{}`))
	})
	mux.HandleFunc("/api/v1/applications", func(w http.ResponseWriter, r *http.Request) {
		got := r.Header.Get("Authorization")
		if got != "Bearer "+validToken {
			w.WriteHeader(http.StatusUnauthorized)
			_, _ = w.Write([]byte(`{"error":"unauthorized"}`))
			return
		}
		w.Header().Set("Content-Type", "application/json")
		_, _ = w.Write([]byte(wrapListResponse(`[{"metadata":{"name":"demo","namespace":"argocd"},"spec":{"project":"demo","destination":{"name":"cluster-a","namespace":"default"}},"status":{"sync":{"status":"Synced"},"health":{"status":"Healthy"}}}]`, "1000")))
	})
	mux.HandleFunc("/api/version", func(w http.ResponseWriter, r *http.Request) {
		_, _ = w.Write([]byte(`{"version":"e2e"}`))
	})
	mux.HandleFunc("/api/v1/applications/demo/resource-tree", func(w http.ResponseWriter, r *http.Request) {
		_, _ = w.Write([]byte(`{"nodes":[]}`))
	})
	mux.HandleFunc("/api/v1/stream/applications", func(w http.ResponseWriter, r *http.Request) {
		fl, _ := w.(http.Flusher)
		w.Header().Set("Content-Type", "text/event-stream")
		_, _ = w.Write([]byte(sseEvent(`{"result":{"type":"MODIFIED","application":{"metadata":{"name":"demo"}}}}`)))
		if fl != nil {
			fl.Flush()
		}
	})
	srv := httptest.NewServer(mux)
	return srv, nil
}

// MockArgoServerExpiredToken returns 401 with a structured JSON body like Argo CD when token is expired
func MockArgoServerExpiredToken() (*httptest.Server, error) {
	mux := http.NewServeMux()
	body := `{"error":"invalid session: token has invalid claims: token is expired","code":16,"message":"invalid session: token has invalid claims: token is expired"}`
	mux.HandleFunc("/api/v1/session/userinfo", func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(http.StatusUnauthorized)
		_, _ = w.Write([]byte(body))
	})
	mux.HandleFunc("/api/v1/applications", func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(http.StatusUnauthorized)
		_, _ = w.Write([]byte(body))
	})
	mux.HandleFunc("/api/v1/stream/applications", func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(http.StatusUnauthorized)
		_, _ = w.Write([]byte(body))
	})
	mux.HandleFunc("/api/version", func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(`{"version":"e2e"}`)) })
	srv := httptest.NewServer(mux)
	return srv, nil
}

// WriteArgoConfigWithToken writes a CLI config using a specific token
func WriteArgoConfigWithToken(path, baseURL, token string) error {
	var y bytes.Buffer
	y.WriteString("contexts:\n")
	y.WriteString("  - name: default\n    server: " + baseURL + "\n    user: default-user\n")
	y.WriteString("servers:\n")
	y.WriteString("  - server: " + baseURL + "\n    insecure: true\n")
	y.WriteString("users:\n")
	y.WriteString("  - name: default-user\n    auth-token: " + token + "\n")
	y.WriteString("current-context: default\n")
	return os.WriteFile(path, y.Bytes(), 0o644)
}

// Removed WriteArgonautConfigWithClipboard - clipboard config is now set automatically by StartAppArgs

// MockArgoServerForbidden returns 403 Forbidden for applications (simulating RBAC/forbidden)
func MockArgoServerForbidden() (*httptest.Server, error) {
	mux := http.NewServeMux()
	mux.HandleFunc("/api/v1/session/userinfo", func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(http.StatusForbidden)
		_, _ = w.Write([]byte(`{"error":"forbidden","message":"forbidden"}`))
	})
	mux.HandleFunc("/api/v1/applications", func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(http.StatusForbidden)
		_, _ = w.Write([]byte(`{"error":"forbidden","message":"forbidden"}`))
	})
	mux.HandleFunc("/api/v1/stream/applications", func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(http.StatusForbidden)
		_, _ = w.Write([]byte(`{"error":"forbidden","message":"forbidden"}`))
	})
	srv := httptest.NewServer(mux)
	return srv, nil
}

// MockArgoServerStreamUnauthorized returns 200 for list but 401 for stream (expired token mid-flow)
func MockArgoServerStreamUnauthorized() (*httptest.Server, error) {
	mux := http.NewServeMux()
	// Require valid token for userinfo and applications
	requireAuth := func(w http.ResponseWriter, r *http.Request) bool {
		got := r.Header.Get("Authorization")
		if got != "Bearer "+"valid-token" {
			w.WriteHeader(http.StatusUnauthorized)
			_, _ = w.Write([]byte(`{"error":"unauthorized"}`))
			return false
		}
		return true
	}
	mux.HandleFunc("/api/v1/session/userinfo", func(w http.ResponseWriter, r *http.Request) {
		if !requireAuth(w, r) {
			return
		}
		w.WriteHeader(200)
		_, _ = w.Write([]byte(`{}`))
	})
	mux.HandleFunc("/api/v1/applications", func(w http.ResponseWriter, r *http.Request) {
		if !requireAuth(w, r) {
			return
		}
		w.Header().Set("Content-Type", "application/json")
		_, _ = w.Write([]byte(wrapListResponse(`[{"metadata":{"name":"demo","namespace":"argocd"},"spec":{"project":"demo","destination":{"name":"cluster-a","namespace":"default"}},"status":{"sync":{"status":"Synced"},"health":{"status":"Healthy"}}}]`, "1000")))
	})
	mux.HandleFunc("/api/v1/stream/applications", func(w http.ResponseWriter, r *http.Request) {
		// Simulate expired token on stream
		w.WriteHeader(http.StatusUnauthorized)
		_, _ = w.Write([]byte(`{"error":"invalid session: token is expired","code":16,"message":"invalid session: token is expired"}`))
	})
	srv := httptest.NewServer(mux)
	return srv, nil
}

// ---- Sync capturing mock server ----

type SyncCall struct {
	Name string
	Body string
}

type SyncRecorder struct {
	mu    sync.Mutex
	Calls []SyncCall
}

func (sr *SyncRecorder) add(call SyncCall) {
	sr.mu.Lock()
	defer sr.mu.Unlock()
	sr.Calls = append(sr.Calls, call)
}

func (sr *SyncRecorder) len() int {
	sr.mu.Lock()
	defer sr.mu.Unlock()
	return len(sr.Calls)
}

// MockArgoServerSync returns an auth-checking server and a recorder for /sync calls
func MockArgoServerSync(validToken string) (*httptest.Server, *SyncRecorder, error) {
	rec := &SyncRecorder{}
	mux := http.NewServeMux()
	requireAuth := func(w http.ResponseWriter, r *http.Request) bool {
		got := r.Header.Get("Authorization")
		if got != "Bearer "+validToken {
			w.WriteHeader(http.StatusUnauthorized)
			_, _ = w.Write([]byte(`{"error":"unauthorized"}`))
			return false
		}
		return true
	}
	mux.HandleFunc("/api/v1/session/userinfo", func(w http.ResponseWriter, r *http.Request) {
		if !requireAuth(w, r) {
			return
		}
		w.WriteHeader(200)
		_, _ = w.Write([]byte(`{}`))
	})
	mux.HandleFunc("/api/version", func(w http.ResponseWriter, r *http.Request) {
		if !requireAuth(w, r) {
			return
		}
		_, _ = w.Write([]byte(`{"version":"e2e"}`))
	})
	mux.HandleFunc("/api/v1/applications", func(w http.ResponseWriter, r *http.Request) {
		if !requireAuth(w, r) {
			return
		}
		w.Header().Set("Content-Type", "application/json")
		// Two apps for multi-select scenario
		_, _ = w.Write([]byte(wrapListResponse(`[
            {"metadata":{"name":"demo","namespace":"argocd"},"spec":{"project":"demo","destination":{"name":"cluster-a","namespace":"default"}},"status":{"sync":{"status":"OutOfSync"},"health":{"status":"Healthy"}}},
            {"metadata":{"name":"demo2","namespace":"argocd"},"spec":{"project":"demo","destination":{"name":"cluster-a","namespace":"default"}},"status":{"sync":{"status":"OutOfSync"},"health":{"status":"Healthy"}}}
        ]`, "1000")))
	})
	mux.HandleFunc("/api/v1/applications/demo/resource-tree", func(w http.ResponseWriter, r *http.Request) {
		if !requireAuth(w, r) {
			return
		}
		_, _ = w.Write([]byte(`{"nodes":[]}`))
	})
	mux.HandleFunc("/api/v1/applications/demo2/resource-tree", func(w http.ResponseWriter, r *http.Request) {
		if !requireAuth(w, r) {
			return
		}
		_, _ = w.Write([]byte(`{"nodes":[]}`))
	})

	// Diffs for :diff
	mux.HandleFunc("/api/v1/applications/demo/managed-resources", func(w http.ResponseWriter, r *http.Request) {
		if !requireAuth(w, r) {
			return
		}
		w.Header().Set("Content-Type", "application/json")
		// One resource with different image tag
		live := `{"apiVersion":"v1","kind":"ConfigMap","metadata":{"name":"demo","namespace":"default"},"data":{"IMAGE":"nginx:1.25"}}`
		desired := `{"apiVersion":"v1","kind":"ConfigMap","metadata":{"name":"demo","namespace":"default"},"data":{"IMAGE":"nginx:1.26"}}`
		_, _ = w.Write([]byte(`{"items":[{"kind":"ConfigMap","namespace":"default","name":"demo","liveState":` + jsonEscape(live) + `,"targetState":` + jsonEscape(desired) + `}]}`))
	})

	// Prefix handler for sync
	mux.HandleFunc("/api/v1/applications/", func(w http.ResponseWriter, r *http.Request) {
		if r.Method != http.MethodPost {
			http.NotFound(w, r)
			return
		}
		if !requireAuth(w, r) {
			return
		}
		// Expect path like /api/v1/applications/<name>/sync
		p := r.URL.Path
		if !strings.HasSuffix(p, "/sync") {
			http.NotFound(w, r)
			return
		}
		segs := strings.Split(p, "/")
		if len(segs) < 6 {
			http.NotFound(w, r)
			return
		}
		name := segs[4]
		body, _ := io.ReadAll(r.Body)
		rec.add(SyncCall{Name: name, Body: string(body)})
		w.WriteHeader(200)
		_, _ = w.Write([]byte(`{}`))
	})

	srv := httptest.NewServer(mux)
	return srv, rec, nil
}

// jsonEscape returns a JSON string literal for a raw string (quoted)
func jsonEscape(s string) string {
	b, _ := json.Marshal(s)
	return string(b)
}

// MockArgoServerHTTPS creates an HTTPS test server using provided certificate files
func MockArgoServerHTTPS(certFile, keyFile string) (*httptest.Server, error) {
	mux := http.NewServeMux()
	mux.HandleFunc("/api/v1/session/userinfo", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200); _, _ = w.Write([]byte(`{}`)) })
	mux.HandleFunc("/api/v1/applications", func(w http.ResponseWriter, r *http.Request) {
		// return one simple app with cluster and project metadata
		w.Header().Set("Content-Type", "application/json")
		_, _ = w.Write([]byte(wrapListResponse(`[{"metadata":{"name":"demo","namespace":"argocd"},"spec":{"project":"demo","destination":{"name":"cluster-a","namespace":"default"}},"status":{"sync":{"status":"Synced"},"health":{"status":"Healthy"}}}]`, "1000")))
	})
	mux.HandleFunc("/api/version", func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(`{"version":"e2e"}`)) })
	mux.HandleFunc("/api/v1/applications/demo/resource-tree", func(w http.ResponseWriter, r *http.Request) {
		_, _ = w.Write([]byte(`{"nodes":[
            {"kind":"Deployment","name":"demo","namespace":"default","version":"v1","group":"apps","uid":"dep-1","status":"Synced"},
            {"kind":"ReplicaSet","name":"demo-rs","namespace":"default","version":"v1","group":"apps","uid":"rs-1","status":"Synced","parentRefs":[{"uid":"dep-1","kind":"Deployment","name":"demo","namespace":"default","group":"apps","version":"v1"}]}
        ]}`))
	})
	// apps watch stream
	mux.HandleFunc("/api/v1/stream/applications", func(w http.ResponseWriter, r *http.Request) {
		fl, _ := w.(http.Flusher)
		w.Header().Set("Content-Type", "text/event-stream")
		_, _ = w.Write([]byte(sseEvent(`{"result":{"type":"MODIFIED","application":{"metadata":{"name":"demo","namespace":"argocd"},"spec":{"project":"demo","destination":{"name":"cluster-a","namespace":"default"}},"status":{"sync":{"status":"Synced"},"health":{"status":"Healthy"}}}}}`)))
		if fl != nil {
			fl.Flush()
		}
	})

	srv := httptest.NewUnstartedServer(mux)

	// Load the certificate and key
	cert, err := tls.LoadX509KeyPair(certFile, keyFile)
	if err != nil {
		return nil, fmt.Errorf("failed to load certificate: %v", err)
	}

	// Configure TLS
	srv.TLS = &tls.Config{
		Certificates: []tls.Certificate{cert},
	}

	srv.StartTLS()
	return srv, nil
}

// WriteArgoConfigHTTPS writes an ArgoCD CLI config pointing to an HTTPS server without insecure flag
func WriteArgoConfigHTTPS(path, baseURL string) error {
	return WriteArgoConfigHTTPSWithToken(path, baseURL, "test-token")
}

// WriteArgoConfigHTTPSWithToken writes a CLI config using a specific token for HTTPS server
func WriteArgoConfigHTTPSWithToken(path, baseURL, token string) error {
	var y bytes.Buffer
	y.WriteString("contexts:\n")
	y.WriteString("  - name: default\n    server: " + baseURL + "\n    user: default-user\n")
	y.WriteString("servers:\n")
	y.WriteString("  - server: " + baseURL + "\n    insecure: false\n") // Note: insecure: false for TLS validation
	y.WriteString("users:\n")
	y.WriteString("  - name: default-user\n    auth-token: " + token + "\n")
	y.WriteString("current-context: default\n")
	return os.WriteFile(path, y.Bytes(), 0o644)
}

// MockArgoServerHTTPSWithClientAuth creates an HTTPS test server that requires client certificates
func MockArgoServerHTTPSWithClientAuth(certFile, keyFile, caFile string) (*httptest.Server, error) {
	mux := http.NewServeMux()
	mux.HandleFunc("/api/v1/session/userinfo", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200); _, _ = w.Write([]byte(`{}`)) })
	mux.HandleFunc("/api/v1/applications", func(w http.ResponseWriter, r *http.Request) {
		// return one simple app with cluster and project metadata
		w.Header().Set("Content-Type", "application/json")
		_, _ = w.Write([]byte(wrapListResponse(`[{"metadata":{"name":"demo","namespace":"argocd"},"spec":{"project":"demo","destination":{"name":"cluster-a","namespace":"default"}},"status":{"sync":{"status":"Synced"},"health":{"status":"Healthy"}}}]`, "1000")))
	})
	mux.HandleFunc("/api/version", func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(`{"version":"e2e"}`)) })
	mux.HandleFunc("/api/v1/applications/demo/resource-tree", func(w http.ResponseWriter, r *http.Request) {
		_, _ = w.Write([]byte(`{"nodes":[
            {"kind":"Deployment","name":"demo","namespace":"default","version":"v1","group":"apps","uid":"dep-1","status":"Synced"},
            {"kind":"ReplicaSet","name":"demo-rs","namespace":"default","version":"v1","group":"apps","uid":"rs-1","status":"Synced","parentRefs":[{"uid":"dep-1","kind":"Deployment","name":"demo","namespace":"default","group":"apps","version":"v1"}]}
        ]}`))
	})
	// apps watch stream
	mux.HandleFunc("/api/v1/stream/applications", func(w http.ResponseWriter, r *http.Request) {
		fl, _ := w.(http.Flusher)
		w.Header().Set("Content-Type", "text/event-stream")
		_, _ = w.Write([]byte(sseEvent(`{"result":{"type":"MODIFIED","application":{"metadata":{"name":"demo","namespace":"argocd"},"spec":{"project":"demo","destination":{"name":"cluster-a","namespace":"default"}},"status":{"sync":{"status":"Synced"},"health":{"status":"Healthy"}}}}}`)))
		if fl != nil {
			fl.Flush()
		}
	})

	srv := httptest.NewUnstartedServer(mux)

	// Load the server certificate and key
	serverCert, err := tls.LoadX509KeyPair(certFile, keyFile)
	if err != nil {
		return nil, fmt.Errorf("failed to load server certificate: %v", err)
	}

	// Load the CA certificate for client verification
	caCertPEM, err := os.ReadFile(caFile)
	if err != nil {
		return nil, fmt.Errorf("failed to read CA file: %v", err)
	}

	caCertPool := x509.NewCertPool()
	if !caCertPool.AppendCertsFromPEM(caCertPEM) {
		return nil, fmt.Errorf("failed to parse CA certificate")
	}

	// Configure TLS with client certificate requirement
	srv.TLS = &tls.Config{
		Certificates: []tls.Certificate{serverCert},
		ClientAuth:   tls.RequireAndVerifyClientCert,
		ClientCAs:    caCertPool,
	}

	srv.StartTLS()
	return srv, nil
}

// ---- K9s testing helpers ----

// createMockK9s creates a mock k9s shell script in the workspace and returns its path and args file path.
// The mock script records its arguments to a file and exits with the given exit code.
func createMockK9s(t *testing.T, workspace string, exitCode int) (scriptPath, argsFile string) {
	t.Helper()

	// Create bin directory in workspace
	binDir := filepath.Join(workspace, "bin")
	if err := os.MkdirAll(binDir, 0o755); err != nil {
		t.Fatalf("failed to create bin dir: %v", err)
	}

	scriptPath = filepath.Join(binDir, "mock-k9s")
	argsFile = filepath.Join(workspace, "k9s_args.txt")

	// Create the mock script - output escape sequences that Argonaut's PTY handler recognizes
	// The script must output a clear screen sequence for the status bar injection to work
	script := fmt.Sprintf(`#!/bin/sh
# Mock k9s - records args and exits
printf "%%s" "$*" > %q
# Output clear screen sequence (triggers Argonaut's status bar injection)
printf '\033[2J\033[H'
printf 'Mock k9s\n'
# Brief delay then exit
sleep 0.05
exit %d
`, argsFile, exitCode)

	if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil {
		t.Fatalf("failed to create mock k9s: %v", err)
	}

	return scriptPath, argsFile
}

// createInteractiveMockK9s creates a mock k9s that reads stdin and records it to a file.
// This is used to verify that keyboard input is correctly forwarded to k9s.
func createInteractiveMockK9s(t *testing.T, workspace string) (scriptPath, argsFile, inputFile string) {
	t.Helper()

	binDir := filepath.Join(workspace, "bin")
	if err := os.MkdirAll(binDir, 0o755); err != nil {
		t.Fatalf("failed to create bin dir: %v", err)
	}

	scriptPath = filepath.Join(binDir, "mock-k9s-interactive")
	argsFile = filepath.Join(workspace, "k9s_args.txt")
	inputFile = filepath.Join(workspace, "k9s_input.txt")

	// Create a mock script that:
	// 1. Records args
	// 2. Outputs clear screen (triggers status bar)
	// 3. Reads stdin with timeout and records to input file
	// 4. Exits
	//
	// We use 'read' with timeout since head -c in background doesn't work
	// for PTY stdin. The -t flag provides timeout, -n provides char count.
	script := fmt.Sprintf(`#!/bin/bash
# Record args
printf "%%s" "$*" > %q
# Clear screen and show message
printf '\033[2J\033[H'
printf 'Mock k9s - type keys\n'
# Read 5 chars with 2 second timeout
read -t 2 -n 5 INPUT 2>/dev/null || true
printf "%%s" "$INPUT" > %q
exit 0
`, argsFile, inputFile)

	if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil {
		t.Fatalf("failed to create interactive mock k9s: %v", err)
	}

	return scriptPath, argsFile, inputFile
}

// readMockK9sInput reads the keyboard input that was captured by the interactive mock k9s.
func readMockK9sInput(t *testing.T, inputFile string) string {
	t.Helper()
	data, err := os.ReadFile(inputFile)
	if err != nil {
		if os.IsNotExist(err) {
			return ""
		}
		t.Fatalf("failed to read k9s input file: %v", err)
	}
	return string(data)
}

// readMockK9sArgs reads the arguments that were passed to the mock k9s script.
func readMockK9sArgs(t *testing.T, argsFile string) string {
	t.Helper()
	data, err := os.ReadFile(argsFile)
	if err != nil {
		if os.IsNotExist(err) {
			return "" // k9s was not invoked
		}
		t.Fatalf("failed to read k9s args file: %v", err)
	}
	return strings.TrimSpace(string(data))
}

// setupSingleContextKubeconfig creates a kubeconfig file with a single context.
func setupSingleContextKubeconfig(t *testing.T, workspace, contextName string) string {
	t.Helper()

	kubeDir := filepath.Join(workspace, ".kube")
	if err := os.MkdirAll(kubeDir, 0o755); err != nil {
		t.Fatalf("failed to create .kube dir: %v", err)
	}

	kubeconfigPath := filepath.Join(kubeDir, "config")
	content := fmt.Sprintf(`apiVersion: v1
kind: Config
current-context: %s
contexts:
  - name: %s
    context:
      cluster: %s
      user: %s-user
clusters:
  - name: %s
    cluster:
      server: https://kubernetes.local:6443
users:
  - name: %s-user
    user:
      token: test-token
`, contextName, contextName, contextName, contextName, contextName, contextName)

	if err := os.WriteFile(kubeconfigPath, []byte(content), 0o644); err != nil {
		t.Fatalf("failed to write kubeconfig: %v", err)
	}

	return kubeconfigPath
}

// setupMultipleContextsKubeconfig creates a kubeconfig file with multiple contexts.
// Set currentContext to empty string to NOT set a current context (triggers context picker).
func setupMultipleContextsKubeconfig(t *testing.T, workspace string, contexts []string) string {
	return setupMultipleContextsKubeconfigWithCurrent(t, workspace, contexts, contexts[0])
}

// setupMultipleContextsKubeconfigNoCurrent creates a kubeconfig with multiple contexts
// but NO current-context set. This forces the context picker to appear.
func setupMultipleContextsKubeconfigNoCurrent(t *testing.T, workspace string, contexts []string) string {
	return setupMultipleContextsKubeconfigWithCurrent(t, workspace, contexts, "")
}

// setupMultipleContextsKubeconfigWithCurrent creates a kubeconfig with multiple contexts
// and optionally sets the current context.
func setupMultipleContextsKubeconfigWithCurrent(t *testing.T, workspace string, contexts []string, currentContext string) string {
	t.Helper()

	kubeDir := filepath.Join(workspace, ".kube")
	if err := os.MkdirAll(kubeDir, 0o755); err != nil {
		t.Fatalf("failed to create .kube dir: %v", err)
	}

	kubeconfigPath := filepath.Join(kubeDir, "config")

	var sb strings.Builder
	sb.WriteString("apiVersion: v1\n")
	sb.WriteString("kind: Config\n")
	if currentContext != "" {
		sb.WriteString(fmt.Sprintf("current-context: %s\n", currentContext))
	}
	sb.WriteString("contexts:\n")
	for _, ctx := range contexts {
		sb.WriteString(fmt.Sprintf("  - name: %s\n", ctx))
		sb.WriteString(fmt.Sprintf("    context:\n"))
		sb.WriteString(fmt.Sprintf("      cluster: %s\n", ctx))
		sb.WriteString(fmt.Sprintf("      user: %s-user\n", ctx))
	}
	sb.WriteString("clusters:\n")
	for _, ctx := range contexts {
		sb.WriteString(fmt.Sprintf("  - name: %s\n", ctx))
		sb.WriteString(fmt.Sprintf("    cluster:\n"))
		sb.WriteString(fmt.Sprintf("      server: https://%s.local:6443\n", ctx))
	}
	sb.WriteString("users:\n")
	for _, ctx := range contexts {
		sb.WriteString(fmt.Sprintf("  - name: %s-user\n", ctx))
		sb.WriteString(fmt.Sprintf("    user:\n"))
		sb.WriteString(fmt.Sprintf("      token: test-token-%s\n", ctx))
	}

	if err := os.WriteFile(kubeconfigPath, []byte(sb.String()), 0o644); err != nil {
		t.Fatalf("failed to write kubeconfig: %v", err)
	}

	return kubeconfigPath
}

// MockArgoServerWithResources creates a mock server with a richer resource tree for k9s tests.
// The resource tree includes Pod, Deployment, Service, ReplicaSet nodes.
func MockArgoServerWithResources() (*httptest.Server, error) {
	mux := http.NewServeMux()
	mux.HandleFunc("/api/v1/session/userinfo", func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(200)
		_, _ = w.Write([]byte(`{}`))
	})
	mux.HandleFunc("/api/v1/applications", func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "application/json")
		_, _ = w.Write([]byte(wrapListResponse(`[{"metadata":{"name":"demo","namespace":"argocd"},"spec":{"project":"demo","destination":{"name":"cluster-a","namespace":"default"}},"status":{"sync":{"status":"Synced"},"health":{"status":"Healthy"}}}]`, "1000")))
	})
	mux.HandleFunc("/api/version", func(w http.ResponseWriter, r *http.Request) {
		_, _ = w.Write([]byte(`{"version":"e2e"}`))
	})
	mux.HandleFunc("/api/v1/applications/demo/resource-tree", func(w http.ResponseWriter, r *http.Request) {
		// Rich resource tree with multiple resource types
		_, _ = w.Write([]byte(`{"nodes":[
			{"kind":"Deployment","name":"demo-deploy","namespace":"default","version":"v1","group":"apps","uid":"dep-1","status":"Synced"},
			{"kind":"ReplicaSet","name":"demo-rs","namespace":"default","version":"v1","group":"apps","uid":"rs-1","status":"Synced","parentRefs":[{"uid":"dep-1","kind":"Deployment","name":"demo-deploy","namespace":"default","group":"apps","version":"v1"}]},
			{"kind":"Pod","name":"demo-pod-1","namespace":"default","version":"v1","group":"","uid":"pod-1","status":"Synced","parentRefs":[{"uid":"rs-1","kind":"ReplicaSet","name":"demo-rs","namespace":"default","group":"apps","version":"v1"}]},
			{"kind":"Service","name":"demo-svc","namespace":"default","version":"v1","group":"","uid":"svc-1","status":"Synced"}
		]}`))
	})
	mux.HandleFunc("/api/v1/stream/applications", func(w http.ResponseWriter, r *http.Request) {
		fl, _ := w.(http.Flusher)
		w.Header().Set("Content-Type", "text/event-stream")
		if shouldSendEvent(r, "demo") {
			_, _ = w.Write([]byte(sseEvent(`{"result":{"type":"MODIFIED","application":{"metadata":{"name":"demo","namespace":"argocd"},"spec":{"project":"demo","destination":{"name":"cluster-a","namespace":"default"}},"status":{"sync":{"status":"Synced"},"health":{"status":"Healthy"}}}}}`)))
		}
		if fl != nil {
			fl.Flush()
		}
	})
	srv := httptest.NewServer(mux)
	return srv, nil
}

// MockArgoServerWithInCluster is like MockArgoServerWithResources but uses
// "in-cluster" as the destination name, simulating ArgoCD managing apps on
// the same cluster it runs on.
func MockArgoServerWithInCluster() (*httptest.Server, error) {
	mux := http.NewServeMux()
	mux.HandleFunc("/api/v1/session/userinfo", func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(200)
		_, _ = w.Write([]byte(`{}`))
	})
	mux.HandleFunc("/api/v1/applications", func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "application/json")
		_, _ = w.Write([]byte(wrapListResponse(`[{"metadata":{"name":"demo","namespace":"argocd"},"spec":{"project":"demo","destination":{"name":"in-cluster","namespace":"default"}},"status":{"sync":{"status":"Synced"},"health":{"status":"Healthy"}}}]`, "1000")))
	})
	mux.HandleFunc("/api/version", func(w http.ResponseWriter, r *http.Request) {
		_, _ = w.Write([]byte(`{"version":"e2e"}`))
	})
	mux.HandleFunc("/api/v1/applications/demo/resource-tree", func(w http.ResponseWriter, r *http.Request) {
		_, _ = w.Write([]byte(`{"nodes":[
			{"kind":"Deployment","name":"demo-deploy","namespace":"default","version":"v1","group":"apps","uid":"dep-1","status":"Synced"},
			{"kind":"Service","name":"demo-svc","namespace":"default","version":"v1","group":"","uid":"svc-1","status":"Synced"}
		]}`))
	})
	mux.HandleFunc("/api/v1/stream/applications", func(w http.ResponseWriter, r *http.Request) {
		fl, _ := w.(http.Flusher)
		w.Header().Set("Content-Type", "text/event-stream")
		if shouldSendEvent(r, "demo") {
			_, _ = w.Write([]byte(sseEvent(`{"result":{"type":"MODIFIED","application":{"metadata":{"name":"demo","namespace":"argocd"},"spec":{"project":"demo","destination":{"name":"in-cluster","namespace":"default"}},"status":{"sync":{"status":"Synced"},"health":{"status":"Healthy"}}}}}`)))
		}
		if fl != nil {
			fl.Flush()
		}
	})
	srv := httptest.NewServer(mux)
	return srv, nil
}

// ---- Port-forward testing helpers ----

// MockKubectlOptions configures mock kubectl behavior for port-forward testing
type MockKubectlOptions struct {
	PodName       string        // Pod name to return from "kubectl get pods" (empty = no pods found)
	LocalPort     int           // Port to report in port-forward output
	PFExitAfter   time.Duration // Duration before port-forward exits (0 = run until killed)
}

// DefaultMockKubectlOptions returns sensible defaults for happy path testing
func DefaultMockKubectlOptions() MockKubectlOptions {
	return MockKubectlOptions{
		PodName:     "argocd-server-abc123",
		LocalPort:   0, // Will be set by test to mock server's port
		PFExitAfter: 0, // Run forever (stable connection)
	}
}

// createMockKubectl creates a mock kubectl shell script that simulates:
// - kubectl version --client --output=json (version check)
// - kubectl get pods ... (pod discovery)
// - kubectl port-forward ... (port forwarding)
//
// Returns the path to the kubectl script and a file where args are recorded.
func createMockKubectl(t *testing.T, workspace string, opts MockKubectlOptions) (scriptPath, argsFile string) {
	t.Helper()

	binDir := filepath.Join(workspace, "bin")
	if err := os.MkdirAll(binDir, 0o755); err != nil {
		t.Fatalf("failed to create bin dir: %v", err)
	}

	scriptPath = filepath.Join(binDir, "kubectl")
	argsFile = filepath.Join(workspace, "kubectl_args.txt")

	// Build the port-forward command handling
	var pfCommand string
	if opts.PFExitAfter > 0 {
		// Exit after delay (for reconnection testing). Emit a fractional-second
		// sleep so callers can request sub-second exits.
		pfCommand = fmt.Sprintf(`
    echo "Forwarding from 127.0.0.1:%d -> 8080"
    sleep %.3f
    exit 1`, opts.LocalPort, opts.PFExitAfter.Seconds())
	} else {
		// Run forever (stable connection)
		pfCommand = fmt.Sprintf(`
    echo "Forwarding from 127.0.0.1:%d -> 8080"
    # Keep running until killed
    while true; do sleep 1; done`, opts.LocalPort)
	}

	script := fmt.Sprintf(`#!/bin/bash
# Mock kubectl for port-forward E2E testing
ARGS_FILE=%q
echo "$@" >> "$ARGS_FILE"

# Parse the first argument to determine command
case "$1" in
  "version")
    echo '{"clientVersion":{"major":"1","minor":"28","gitVersion":"v1.28.0"}}'
    exit 0
    ;;
  "get")
    # Return pod name (or empty for no-pod-found scenario)
    echo %q
    exit 0
    ;;
  "port-forward")
    %s
    ;;
  *)
    echo "mock kubectl: unknown command: $@" >&2
    exit 1
    ;;
esac
`, argsFile, opts.PodName, pfCommand)

	if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil {
		t.Fatalf("failed to create mock kubectl: %v", err)
	}

	return scriptPath, argsFile
}

// WriteArgoConfigPortForward writes an ArgoCD CLI config for port-forward mode.
// The server is set to "port-forward" which triggers the port-forward detection.
func WriteArgoConfigPortForward(path, token string) error {
	var y bytes.Buffer
	y.WriteString("contexts:\n")
	y.WriteString("  - name: port-forward-ctx\n    server: port-forward\n    user: pf-user\n")
	y.WriteString("servers:\n")
	y.WriteString("  - server: port-forward\n    plain-text: true\n")
	y.WriteString("users:\n")
	y.WriteString("  - name: pf-user\n    auth-token: " + token + "\n")
	y.WriteString("current-context: port-forward-ctx\n")
	return os.WriteFile(path, y.Bytes(), 0o644)
}

// readMockKubectlArgs reads all kubectl invocations recorded by the mock script.
// Returns a slice of argument strings, one per invocation.
func readMockKubectlArgs(t *testing.T, argsFile string) []string {
	t.Helper()
	data, err := os.ReadFile(argsFile)
	if err != nil {
		if os.IsNotExist(err) {
			return nil // kubectl was never invoked
		}
		t.Fatalf("failed to read kubectl args file: %v", err)
	}
	lines := strings.Split(strings.TrimSpace(string(data)), "\n")
	// Filter empty lines
	var result []string
	for _, line := range lines {
		if line != "" {
			result = append(result, line)
		}
	}
	return result
}

// extractPortFromURL extracts the port number from a URL like "http://127.0.0.1:12345"
func extractPortFromURL(url string) int {
	// URL format: http://127.0.0.1:PORT or https://127.0.0.1:PORT
	parts := strings.Split(url, ":")
	if len(parts) < 3 {
		return 0
	}
	portStr := parts[len(parts)-1]
	port, _ := strconv.Atoi(portStr)
	return port
}

// wrapListResponse wraps items JSON with metadata containing resourceVersion.
// Real ArgoCD returns {"metadata":{"resourceVersion":"..."},"items":[...]}.
func wrapListResponse(items string, rv string) string {
	return fmt.Sprintf(`{"metadata":{"resourceVersion":"%s"},"items":%s}`, rv, items)
}

// sseEvent formats a JSON payload as a proper SSE data line (matching real ArgoCD format).
// Real ArgoCD sends "data: {json}\n\n" for each event.
func sseEvent(jsonPayload string) string {
	return "data: " + jsonPayload + "\n\n"
}

// shouldSendEvent checks if an event's project matches the ?projects= filter.
// Returns true if no filter is set or if the project matches one of the filter values.
func shouldSendEvent(r *http.Request, appProject string) bool {
	projects := r.URL.Query()["projects"]
	if len(projects) == 0 {
		return true
	}
	for _, p := range projects {
		if p == appProject {
			return true
		}
	}
	return false
}
