package app

import (
	"bytes"
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"net/http"
	"os"
	"path/filepath"
	"reflect"
	"strings"
	"testing"
	"time"

	intpsmux "github.com/crevissepartners/projmux/internal/integrations/psmux"
	"github.com/crevissepartners/projmux/internal/integrations/sessionstate"
	"github.com/crevissepartners/projmux/internal/theme"
	intpicker "github.com/crevissepartners/projmux/internal/ui/picker"
	intpickercompat "github.com/crevissepartners/projmux/internal/ui/pickercompat"
	"github.com/crevissepartners/projmux/internal/version"
)

func TestShellWritesAppConfigAndRunsIsolatedTmux(t *testing.T) {
	t.Setenv("TMUX", "/tmp/default-tmux")

	home := t.TempDir()
	recorder := &recordingShellRunner{}
	cmd := &shellCommand{
		executable: func() (string, error) { return "/tmp/proj mux/bin/projmux", nil },
		lookupEnv: func(name string) string {
			if name == "HOME" {
				return home
			}
			if name == "XDG_CONFIG_HOME" {
				return ""
			}
			if name == "SHELL" {
				return "/bin/bash"
			}
			return os.Getenv(name)
		},
		homeDir:    func() (string, error) { return home, nil },
		writeFile:  os.WriteFile,
		runCommand: recorder.run,
		goos:       func() string { return "linux" },
	}

	if err := cmd.Run(nil, &bytes.Buffer{}, &bytes.Buffer{}); err != nil {
		t.Fatalf("Run() error = %v", err)
	}

	configPath := filepath.Join(home, ".config", "projmux", "tmux.conf")
	content, err := os.ReadFile(configPath)
	if err != nil {
		t.Fatal(err)
	}
	config := string(content)
	for _, want := range []string{
		"Generated by projmux. Used by `projmux shell`.",
		"set -g @projmux_app 1",
		"set -g history-limit 10000",
		"set -g set-clipboard on",
		"set -g default-shell \"/bin/bash\"",
		"set -ga update-environment \"WSL_DISTRO_NAME\"",
		"set -g pane-border-status top",
		"set -ga update-environment \"PROJMUX_WELCOME\"",
		"set-hook -g client-attached",
		"'/tmp/proj mux/bin/projmux' welcome --popup >/dev/null 2>&1",
		"set -g status-left-length 20",
		"set -g status-left \"#[range=user|session]#[bold,fg=colour254,bg=colour60] #{s|^[^-]*-||:session_name} #[default]#[norange] \"",
		"#[bold,fg=colour254,bg=colour60] #{s|^[^-]*-||:session_name} #[default]",
		"#{n:window_name}",
		"#{=/7/...:window_name}",
		"'/tmp/proj mux/bin/projmux' tmux popup-toggle --client #{client_tty} sessionizer-sidebar",
		"set -g status 2",
		"range=user|settings",
		"range=user|notify",
		"range=user|usage",
		"set -g status-format[0]",
		"set -g status-format[1]",
		"align=left",
		"align=right",
		"set -gu status-format[2]",
	} {
		if !strings.Contains(config, want) {
			t.Fatalf("config = %q, want substring %q", config, want)
		}
	}
	if strings.Contains(config, "#[bold,fg=colour16,bg=colour45] app #[default]") {
		t.Fatalf("config = %q, did not expect duplicate app status badge", config)
	}
	for _, banned := range []string{
		"set -g status 3",
		"set -g status-format[2] \"",
		"range=user|sessionstate",
		"$env:PROJMUX_MUX_BACKEND",
		"$env:PROJMUX_PICKER_BACKEND",
		"$env:PROJMUX_NATIVE_LINE_MODE",
		"psmux",
		"set -s user-keys",
		"\\033[900",
		"\\033[901",
		"bind-key -n User",
	} {
		if strings.Contains(config, banned) {
			t.Fatalf("config = %q, did not expect substring %q", config, banned)
		}
	}

	wantArgs := []string{"-L", "projmux", "-f", configPath, "new-session", "-A", "-s", "home", "-c", home}
	if recorder.name != "tmux" || !reflect.DeepEqual(recorder.args, wantArgs) {
		t.Fatalf("command = %s %#v, want tmux %#v", recorder.name, recorder.args, wantArgs)
	}
	for _, env := range recorder.env {
		if strings.HasPrefix(env, "TMUX=") {
			t.Fatalf("runner env contains TMUX: %#v", recorder.env)
		}
	}
}

func TestShellWriteAppConfigAppliesGlobalThemeToPaneChrome(t *testing.T) {
	home := t.TempDir()
	writeFile(t, filepath.Join(home, ".config", "projmux", "config.toml"), `
[theme]
background = "#0000ff"
pane_active_bg = "#41eb3a"
focus = "#ff0000"
`)
	cmd := &shellCommand{
		lookupEnv: func(name string) string {
			if name == "HOME" {
				return home
			}
			return ""
		},
		homeDir:   func() (string, error) { return home, nil },
		readFile:  os.ReadFile,
		writeFile: os.WriteFile,
	}
	configPath := filepath.Join(home, ".config", "projmux", "tmux.conf")
	if err := cmd.writeAppConfig(configPath, "/tmp/projmux"); err != nil {
		t.Fatalf("writeAppConfig() error = %v", err)
	}
	got := readFile(t, configPath)

	globalRoles := theme.RenderRolesFromEffective(theme.ResolveTheme(theme.ThemeConfig{
		Background: "#0000ff", PaneActiveBg: "#41eb3a", Focus: "#ff0000",
	}))
	fallbackRoles := theme.RenderRolesFromEffective(theme.ResolveTheme(theme.ThemeConfig{}))
	for _, want := range []string{
		"set -g pane-active-border-style \"fg=" + globalRoles.FocusBorder + ",bold\"",
		"set -g window-active-style \"bg=" + globalRoles.FocusPaneActiveBg + "\"",
		"set -g window-style \"bg=" + globalRoles.PaneInactiveBg + "\"",
	} {
		if !strings.Contains(got, want) {
			t.Fatalf("shell app config missing global-theme chrome line %q\n--- got ---\n%s", want, got)
		}
	}
	for _, unwanted := range []string{
		"set -g window-active-style \"bg=" + fallbackRoles.FocusPaneActiveBg + "\"",
		"set -g pane-active-border-style \"fg=" + fallbackRoles.FocusBorder + ",bold\"",
	} {
		if strings.Contains(got, unwanted) {
			t.Fatalf("shell app config leaked fallback chrome line %q\n--- got ---\n%s", unwanted, got)
		}
	}
}

func TestShellWriteAppConfigUnsetThemeMatchesFallback(t *testing.T) {
	home := t.TempDir()
	cmd := &shellCommand{
		lookupEnv: func(name string) string {
			if name == "HOME" {
				return home
			}
			return ""
		},
		homeDir:   func() (string, error) { return home, nil },
		readFile:  os.ReadFile,
		writeFile: os.WriteFile,
	}
	configPath := filepath.Join(home, ".config", "projmux", "tmux.conf")
	if err := cmd.writeAppConfig(configPath, "/tmp/projmux"); err != nil {
		t.Fatalf("writeAppConfig() error = %v", err)
	}
	got := readFile(t, configPath)

	// With no global theme, the shell-start writer must be byte-identical to the
	// built-in fallback render (the pre-change behavior).
	catalog, present, err := loadMergedKeyBindingCatalog(keymapLoader{
		homeDir:   cmd.homeDir,
		lookupEnv: cmd.lookupEnv,
		readFile:  cmd.readFile,
	})
	if err != nil {
		t.Fatalf("loadMergedKeyBindingCatalog() error = %v", err)
	}
	want := fallbackRenderThemeSource().tmuxAppConfigWithAIBadgeStyleAndDesktopNotifyMode(
		"/tmp/projmux", cmd.defaultShell(),
		loadStatusbarDecorationSet(cmd.homeDir, cmd.lookupEnv),
		loadAIBadgeStyle(cmd.homeDir, cmd.lookupEnv),
		loadDesktopNotifyModeForTmuxConfig(cmd.homeDir, cmd.lookupEnv),
		catalog, present,
	)
	if got != want {
		t.Fatalf("shell app config unset theme is not byte-identical to fallback\n--- got ---\n%s\n--- want ---\n%s", got, want)
	}
}

func TestShellNativeWindowsWritesPSMuxConfigAndRunsIsolatedPSMux(t *testing.T) {
	t.Setenv("TMUX", `/tmp/default-tmux`)

	home := `C:\Users\Ada Lovelace`
	configPath := `C:\Users\Ada Lovelace\AppData\Local\projmux\ps mux.conf`
	binaryPath := `C:\Program Files\projmux weird\projmux.exe`
	recorder := &recordingShellRunner{}
	writes := map[string][]byte{}
	cmd := &shellCommand{
		executable: func() (string, error) { return binaryPath, nil },
		lookupEnv: func(name string) string {
			return os.Getenv(name)
		},
		homeDir:    func() (string, error) { return home, nil },
		writeFile:  recordShellWrite(writes),
		runCommand: recorder.run,
		goos:       func() string { return "windows" },
	}

	if err := cmd.Run([]string{"--config", configPath}, &bytes.Buffer{}, &bytes.Buffer{}); err != nil {
		t.Fatalf("Run() error = %v", err)
	}

	config := string(writes[configPath])
	for _, want := range []string{
		"Generated by projmux. Used by native Windows `projmux.exe shell` with psmux",
		"Phase 4B includes only the minimal project switch popup",
		"set -g @projmux_app 1",
		"set -g status-left \" #{session_name} \"",
		"#(& 'C:\\Program Files\\projmux weird\\projmux.exe' 'status' 'git' '#{pane_current_path}')",
		"bind-key -n C-n new-window -c \"#{pane_current_path}\"",
		"bind-key -n M-1 display-popup -d \"#{pane_current_path}\" -x 0 -y 0 -w 42% -h 100% -T \"Projects\" -E powershell -NoProfile -Command \"$env:PROJMUX_MUX_BACKEND = 'psmux'; $env:PROJMUX_PICKER_BACKEND = 'native'; $env:PROJMUX_NATIVE_LINE_MODE = '1'; & 'C:\\Program Files\\projmux weird\\projmux.exe' 'switch' '--ui=sidebar'\"",
	} {
		if !strings.Contains(config, want) {
			t.Fatalf("psmux config = %q, want substring %q", config, want)
		}
	}
	for _, banned := range []string{
		"tmux popup-toggle",
		"set-hook",
		"set-option -p",
		"@projmux_ai_",
		"sh -c",
		`'\''`,
	} {
		if strings.Contains(config, banned) {
			t.Fatalf("psmux config = %q, did not expect substring %q", config, banned)
		}
	}

	wantArgs := []string{"-L", "projmux", "-f", configPath, "new-session", "-A", "-s", "home", "-c", home}
	if recorder.name != "psmux" || !reflect.DeepEqual(recorder.args, wantArgs) {
		t.Fatalf("command = %s %#v, want psmux %#v", recorder.name, recorder.args, wantArgs)
	}
	for _, env := range recorder.env {
		if strings.HasPrefix(env, "TMUX=") {
			t.Fatalf("runner env contains TMUX: %#v", recorder.env)
		}
	}
}

func TestShellBackendEnvCanSelectPSMuxOffWindows(t *testing.T) {
	t.Setenv(muxBackendEnvVar, "psmux")

	home := t.TempDir()
	configPath := filepath.Join(home, "psmux.conf")
	recorder := &recordingShellRunner{}
	writes := map[string][]byte{}
	cmd := &shellCommand{
		executable: func() (string, error) { return "/tmp/projmux", nil },
		lookupEnv: func(name string) string {
			if name == "TMUX" {
				return ""
			}
			return os.Getenv(name)
		},
		homeDir:    func() (string, error) { return home, nil },
		writeFile:  recordShellWrite(writes),
		runCommand: recorder.run,
		goos:       func() string { return "linux" },
	}

	if err := cmd.Run([]string{"--config", configPath}, &bytes.Buffer{}, &bytes.Buffer{}); err != nil {
		t.Fatalf("Run() error = %v", err)
	}

	if recorder.name != "psmux" {
		t.Fatalf("command = %s %#v, want psmux", recorder.name, recorder.args)
	}
	if !strings.Contains(string(writes[configPath]), "Used by native Windows `projmux.exe shell` with psmux") {
		t.Fatalf("config = %q, want psmux config", string(writes[configPath]))
	}
}

func TestShellBackendEnvCanKeepTmuxOnWindows(t *testing.T) {
	t.Setenv(muxBackendEnvVar, "tmux")

	home := t.TempDir()
	configPath := filepath.Join(home, "tmux.conf")
	recorder := &recordingShellRunner{}
	cmd := &shellCommand{
		executable: func() (string, error) { return "/tmp/projmux", nil },
		lookupEnv: func(name string) string {
			if name == "SHELL" {
				return "/bin/bash"
			}
			if name == "TMUX" {
				return ""
			}
			return os.Getenv(name)
		},
		homeDir:    func() (string, error) { return home, nil },
		writeFile:  os.WriteFile,
		runCommand: recorder.run,
		goos:       func() string { return "windows" },
	}

	if err := cmd.Run([]string{"--config", configPath}, &bytes.Buffer{}, &bytes.Buffer{}); err != nil {
		t.Fatalf("Run() error = %v", err)
	}

	if recorder.name != "tmux" {
		t.Fatalf("command = %s %#v, want tmux", recorder.name, recorder.args)
	}
	content, err := os.ReadFile(configPath)
	if err != nil {
		t.Fatal(err)
	}
	if !strings.Contains(string(content), "Generated by projmux. Used by `projmux shell`.") {
		t.Fatalf("config = %q, want tmux app config", string(content))
	}
}

func TestPSMuxAppConfigRendersPowerShellCallbackWithSensitiveBinaryPath(t *testing.T) {
	t.Parallel()

	binaryPath := "C:\\Program Files\\projmux chars ' \" " + "`" + " $ & | < > ; ( ) { } ^ % !\\projmux.exe"
	config, err := psmuxAppConfig(binaryPath)
	if err != nil {
		t.Fatalf("psmuxAppConfig() error = %v", err)
	}
	callback, err := intpsmux.ProjmuxCommand(binaryPath, "status", "git", "#{pane_current_path}").PowerShell()
	if err != nil {
		t.Fatalf("PowerShell() error = %v", err)
	}
	switchCallback, err := intpsmux.ProjmuxCommand(binaryPath, "switch", "--ui=sidebar").PowerShell()
	if err != nil {
		t.Fatalf("PowerShell() error = %v", err)
	}
	wantCallback := "#(" + strings.ReplaceAll(callback, `"`, `\"`) + ")"
	if !strings.Contains(config, wantCallback) {
		t.Fatalf("psmux config = %q, want PowerShell callback %q", config, wantCallback)
	}
	wantSwitchCallback := strings.Join([]string{
		powerShellEnvAssignment(muxBackendEnvVar, string(muxBackendPSMux)),
		powerShellEnvAssignment(intpicker.BackendEnv, string(intpicker.BackendNative)),
		powerShellEnvAssignment("PROJMUX_NATIVE_LINE_MODE", "1"),
		switchCallback,
	}, "; ")
	wantSwitch := "display-popup -d \"#{pane_current_path}\" -x 0 -y 0 -w 42% -h 100% -T \"Projects\" -E " + psmuxPowerShellCommandTail(wantSwitchCallback)
	if !strings.Contains(config, wantSwitch) {
		t.Fatalf("psmux config = %q, want project switch binding %q", config, wantSwitch)
	}
	for _, want := range []string{
		"C:\\Program Files\\projmux chars",
		"chars ''",
		`\"`,
		"`",
		"$",
		"&",
		"|",
		"<",
		">",
		";",
		"(",
		")",
		"{",
		"}",
		"^",
		"%",
		"!",
		"'status' 'git'",
		"'switch' '--ui=sidebar'",
		"'#{pane_current_path}'",
	} {
		if !strings.Contains(config, want) {
			t.Fatalf("psmux config = %q, want sensitive substring %q", config, want)
		}
	}
	for _, banned := range []string{
		"set-option -p",
		"sh -c",
		`'\''`,
	} {
		if strings.Contains(config, banned) {
			t.Fatalf("psmux config = %q, did not expect substring %q", config, banned)
		}
	}
}

func TestShellAppConfigFallsBackToPortableShell(t *testing.T) {
	t.Parallel()

	home := t.TempDir()
	recorder := &recordingShellRunner{}
	cmd := &shellCommand{
		executable: func() (string, error) { return "/tmp/projmux", nil },
		lookupEnv: func(name string) string {
			if name == "HOME" {
				return home
			}
			return ""
		},
		homeDir:    func() (string, error) { return home, nil },
		writeFile:  os.WriteFile,
		runCommand: recorder.run,
	}

	if err := cmd.Run(nil, &bytes.Buffer{}, &bytes.Buffer{}); err != nil {
		t.Fatalf("Run() error = %v", err)
	}

	configPath := filepath.Join(home, ".config", "projmux", "tmux.conf")
	content, err := os.ReadFile(configPath)
	if err != nil {
		t.Fatal(err)
	}
	if !strings.Contains(string(content), "set -g default-shell \"/bin/sh\"") {
		t.Fatalf("config = %q, want portable fallback shell", string(content))
	}
}

func TestDefaultInteractiveShellKeepsConfiguredZsh(t *testing.T) {
	t.Parallel()

	got := defaultInteractiveShell(func(name string) string {
		if name == "SHELL" {
			return "/usr/bin/zsh"
		}
		return ""
	})
	if got != "/usr/bin/zsh" {
		t.Fatalf("defaultInteractiveShell = %q, want configured zsh", got)
	}
}

func TestShellSupportsRuntimeOverrides(t *testing.T) {
	home := t.TempDir()
	recorder := &recordingShellRunner{}
	cmd := &shellCommand{
		executable: func() (string, error) { return "/tmp/projmux", nil },
		lookupEnv:  func(string) string { return "" },
		homeDir:    func() (string, error) { return home, nil },
		writeFile:  os.WriteFile,
		runCommand: recorder.run,
	}
	configPath := filepath.Join(home, "custom.conf")
	args := []string{"--socket", "pmx-dev", "--session", "dev", "--config", configPath, "--bin", "/opt/projmux", "--no-install"}
	if err := cmd.Run(args, &bytes.Buffer{}, &bytes.Buffer{}); err != nil {
		t.Fatalf("Run() error = %v", err)
	}
	if _, err := os.Stat(configPath); !os.IsNotExist(err) {
		t.Fatalf("config was written despite --no-install: %v", err)
	}
	wantArgs := []string{"-L", "pmx-dev", "-f", configPath, "new-session", "-A", "-s", "dev", "-c", home}
	if recorder.name != "tmux" || !reflect.DeepEqual(recorder.args, wantArgs) {
		t.Fatalf("command = %s %#v, want tmux %#v", recorder.name, recorder.args, wantArgs)
	}
}

func TestShellDefaultSessionUsesProjectContextIdentity(t *testing.T) {
	t.Parallel()

	home := t.TempDir()
	project := filepath.Join(home, "source", "repos", "projmux")
	nested := filepath.Join(project, "internal", "app")
	if err := os.MkdirAll(filepath.Join(project, ".git"), 0o755); err != nil {
		t.Fatal(err)
	}
	if err := os.MkdirAll(nested, 0o755); err != nil {
		t.Fatal(err)
	}
	recorder := &recordingShellRunner{}
	configPath := filepath.Join(home, ".config", "projmux", "tmux.conf")
	tmux := &scriptedShellTmuxRunner{
		errors: map[string]error{
			shellTmuxCallKey("tmux", "-L", "projmux", "-f", configPath, "has-session", "-t", "repos-projmux"): errors.New("can't find session: repos-projmux"),
		},
	}
	cmd := &shellCommand{
		executable: func() (string, error) { return "/tmp/projmux", nil },
		lookupEnv:  func(string) string { return "" },
		homeDir:    func() (string, error) { return home, nil },
		writeFile:  os.WriteFile,
		runCommand: recorder.run,
		tmuxRunner: tmux,
		getwd:      func() (string, error) { return nested, nil },
	}

	if err := cmd.Run([]string{"--no-install"}, &bytes.Buffer{}, &bytes.Buffer{}); err != nil {
		t.Fatalf("Run() error = %v", err)
	}

	wantArgs := []string{"-L", "projmux", "-f", configPath, "new-session", "-A", "-s", "repos-projmux", "-c", project}
	if recorder.name != "tmux" || !reflect.DeepEqual(recorder.args, wantArgs) {
		t.Fatalf("command = %s %#v, want tmux %#v", recorder.name, recorder.args, wantArgs)
	}
}

func TestShellDefaultSessionUsesPROJMUXCWDProjectContext(t *testing.T) {
	t.Parallel()

	home := t.TempDir()
	envProject := filepath.Join(home, "source", "repos", "projmux")
	wdProject := filepath.Join(home, "elsewhere", "other")
	if err := os.MkdirAll(envProject, 0o755); err != nil {
		t.Fatal(err)
	}
	if err := os.MkdirAll(filepath.Join(wdProject, ".git"), 0o755); err != nil {
		t.Fatal(err)
	}
	recorder := &recordingShellRunner{}
	configPath := filepath.Join(home, ".config", "projmux", "tmux.conf")
	tmux := &scriptedShellTmuxRunner{
		errors: map[string]error{
			shellTmuxCallKey("tmux", "-L", "projmux", "-f", configPath, "has-session", "-t", "repos-projmux"): errors.New("can't find session: repos-projmux"),
		},
	}
	cmd := &shellCommand{
		executable: func() (string, error) { return "/tmp/projmux", nil },
		lookupEnv: func(name string) string {
			if name == "PROJMUX_CWD" {
				return envProject
			}
			return ""
		},
		homeDir:    func() (string, error) { return home, nil },
		writeFile:  os.WriteFile,
		runCommand: recorder.run,
		tmuxRunner: tmux,
		getwd:      func() (string, error) { return wdProject, nil },
	}

	if err := cmd.Run([]string{"--no-install"}, &bytes.Buffer{}, &bytes.Buffer{}); err != nil {
		t.Fatalf("Run() error = %v", err)
	}

	wantArgs := []string{"-L", "projmux", "-f", configPath, "new-session", "-A", "-s", "repos-projmux", "-c", envProject}
	if recorder.name != "tmux" || !reflect.DeepEqual(recorder.args, wantArgs) {
		t.Fatalf("command = %s %#v, want tmux %#v", recorder.name, recorder.args, wantArgs)
	}
}

func TestShellDefaultSessionUsesHomeProjectIdentity(t *testing.T) {
	t.Parallel()

	home := t.TempDir()
	recorder := &recordingShellRunner{}
	cmd := &shellCommand{
		executable: func() (string, error) { return "/tmp/projmux", nil },
		lookupEnv:  func(string) string { return "" },
		homeDir:    func() (string, error) { return home, nil },
		writeFile:  os.WriteFile,
		runCommand: recorder.run,
	}

	if err := cmd.Run([]string{"--no-install"}, &bytes.Buffer{}, &bytes.Buffer{}); err != nil {
		t.Fatalf("Run() error = %v", err)
	}

	wantArgs := []string{"-L", "projmux", "-f", filepath.Join(home, ".config", "projmux", "tmux.conf"), "new-session", "-A", "-s", "home", "-c", home}
	if recorder.name != "tmux" || !reflect.DeepEqual(recorder.args, wantArgs) {
		t.Fatalf("command = %s %#v, want tmux %#v", recorder.name, recorder.args, wantArgs)
	}
}

func TestShellExplicitHomeSessionKeepsHomeTargetInsideProject(t *testing.T) {
	t.Parallel()

	home := t.TempDir()
	project := filepath.Join(home, "source", "repos", "projmux")
	if err := os.MkdirAll(filepath.Join(project, ".git"), 0o755); err != nil {
		t.Fatal(err)
	}
	store := sessionstate.NewStore(t.TempDir())
	saveShellSnapshot(t, store, "repos-projmux", project)
	foreground := &recordingShellRunner{}
	tmux := &scriptedShellTmuxRunner{}
	cmd := &shellCommand{
		executable:   func() (string, error) { return "/tmp/projmux", nil },
		lookupEnv:    func(string) string { return "" },
		homeDir:      func() (string, error) { return home, nil },
		writeFile:    os.WriteFile,
		runCommand:   foreground.run,
		tmuxRunner:   tmux,
		sessionStore: func() (sessionstate.Store, error) { return store, nil },
		getwd:        func() (string, error) { return project, nil },
		nativePicker: nativePickerFromCompatRunner(shellUpdateRunnerFunc(func(options intpickercompat.Options) (intpickercompat.Result, error) {
			t.Fatalf("startup picker should not use project snapshot for explicit home session: %#v", options)
			return intpickercompat.Result{}, nil
		})),
	}

	if err := cmd.Run([]string{"--no-install", "--session", "home"}, &bytes.Buffer{}, &bytes.Buffer{}); err != nil {
		t.Fatalf("Run() error = %v", err)
	}

	if len(tmux.calls) != 0 {
		t.Fatalf("tmux restore calls = %#v, want none for explicit home target", tmux.calls)
	}
	wantArgs := []string{"-L", "projmux", "-f", filepath.Join(home, ".config", "projmux", "tmux.conf"), "new-session", "-A", "-s", "home", "-c", home}
	if foreground.name != "tmux" || !reflect.DeepEqual(foreground.args, wantArgs) {
		t.Fatalf("command = %s %#v, want tmux %#v", foreground.name, foreground.args, wantArgs)
	}
}

func TestShellUsageOmitsStartupSelectors(t *testing.T) {
	t.Parallel()

	var usage bytes.Buffer
	printShellUsage(&usage)
	for _, removed := range []string{"--saved", "--layout", "--empty"} {
		if strings.Contains(usage.String(), removed) {
			t.Fatalf("usage = %q, want no %q", usage.String(), removed)
		}
	}
}

func TestShellTmuxExecRunnerStripsNestedTmuxEnv(t *testing.T) {
	t.Parallel()

	runner := shellTmuxExecRunner{env: func() []string {
		return []string{
			"PATH=" + os.Getenv("PATH"),
			"TMUX=/tmp/nested-tmux,123,0",
		}
	}}

	_, err := runner.Run(context.Background(), "/bin/sh", "-c", `if [ "${TMUX+x}" = x ]; then echo "inherited TMUX=$TMUX"; exit 7; fi`)
	if err != nil {
		t.Fatalf("Run() error = %v, want TMUX stripped", err)
	}
}

func TestShellWelcomeShowsUntilSkippedForVersion(t *testing.T) {
	now := time.Date(2026, 5, 10, 12, 34, 56, 0, time.UTC)
	update, _ := testUpdateCommand(t, now)
	home := t.TempDir()
	recorder := &recordingShellRunner{}
	cmd := &shellCommand{
		executable:   func() (string, error) { return "/tmp/projmux", nil },
		lookupEnv:    func(string) string { return "" },
		homeDir:      func() (string, error) { return home, nil },
		welcomeInput: strings.NewReader("\n"),
		writeFile:    os.WriteFile,
		runCommand:   recorder.run,
		update:       update,
	}

	var first, stderr bytes.Buffer
	if err := cmd.Run([]string{"--no-install"}, &first, &stderr); err != nil {
		t.Fatalf("first Run() error = %v", err)
	}
	assertWelcomeOutput(t, "first stdout", first.String())
	if stderr.Len() != 0 {
		t.Fatalf("first stderr = %q, want empty", stderr.String())
	}
	path, err := cmd.welcomeStatePath(version.String())
	if err != nil {
		t.Fatal(err)
	}
	data, err := os.ReadFile(path)
	if err != nil {
		t.Fatal(err)
	}
	if !strings.Contains(string(data), version.String()) {
		t.Fatalf("welcome state = %s, want current version", data)
	}
	var state shellWelcomeState
	if err := json.Unmarshal(data, &state); err != nil {
		t.Fatalf("json.Unmarshal(welcome state) error = %v", err)
	}
	if state.PendingAttachWelcome {
		t.Fatalf("welcome state = %+v, did not want pending attach popup after shell prompt", state)
	}
	if state.SkipVersion != "" {
		t.Fatalf("welcome state = %+v, did not want skip_version after Enter", state)
	}

	cmd.welcomeInput = strings.NewReader("\n")
	var second, secondStderr bytes.Buffer
	if err := cmd.Run([]string{"--no-install"}, &second, &secondStderr); err != nil {
		t.Fatalf("second Run() error = %v", err)
	}
	assertWelcomeOutput(t, "second stdout", second.String())
	if secondStderr.Len() != 0 {
		t.Fatalf("second stderr = %q, want empty", secondStderr.String())
	}
}

func TestShellWelcomeLegacySkipVersionDoesNotSuppressShellEntry(t *testing.T) {
	home := t.TempDir()
	cmd := &shellCommand{
		executable: func() (string, error) { return "/tmp/projmux", nil },
		lookupEnv:  func(string) string { return "" },
		homeDir:    func() (string, error) { return home, nil },
		writeFile:  os.WriteFile,
		runCommand: (&recordingShellRunner{}).run,
	}
	writeShellWelcomeState(t, cmd, shellWelcomeState{
		Version:             welcomeStateVersion,
		LastWelcomedVersion: version.String(),
		WelcomedAt:          time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC),
		SkipVersion:         version.String(),
		SkippedAt:           testTimePtr(time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)),
	})

	var stdout bytes.Buffer
	if err := cmd.Run([]string{"--no-install"}, &stdout, &bytes.Buffer{}); err != nil {
		t.Fatalf("Run() error = %v", err)
	}
	assertWelcomeOutput(t, "stdout", stdout.String())
}

func TestShellWelcomeShowsWhenSkipVersionDiffers(t *testing.T) {
	home := t.TempDir()
	cmd := &shellCommand{
		executable:   func() (string, error) { return "/tmp/projmux", nil },
		lookupEnv:    func(string) string { return "" },
		homeDir:      func() (string, error) { return home, nil },
		welcomeInput: strings.NewReader("\n"),
		writeFile:    os.WriteFile,
		runCommand:   (&recordingShellRunner{}).run,
	}
	writeShellWelcomeState(t, cmd, shellWelcomeState{
		Version:             welcomeStateVersion,
		LastWelcomedVersion: "0.0.1",
		WelcomedAt:          time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC),
		SkipVersion:         "0.0.1",
		SkippedAt:           testTimePtr(time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)),
	})

	var stdout bytes.Buffer
	if err := cmd.Run([]string{"--no-install"}, &stdout, &bytes.Buffer{}); err != nil {
		t.Fatalf("Run() error = %v", err)
	}
	assertWelcomeOutput(t, "stdout", stdout.String())
}

func TestShellWelcomeLegacyLastWelcomedVersionIsNotSkip(t *testing.T) {
	home := t.TempDir()
	cmd := &shellCommand{
		executable:   func() (string, error) { return "/tmp/projmux", nil },
		lookupEnv:    func(string) string { return "" },
		homeDir:      func() (string, error) { return home, nil },
		welcomeInput: strings.NewReader("\n"),
		writeFile:    os.WriteFile,
		runCommand:   (&recordingShellRunner{}).run,
	}
	writeShellWelcomeState(t, cmd, shellWelcomeState{
		Version:             welcomeStateVersion,
		LastWelcomedVersion: version.String(),
		WelcomedAt:          time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC),
	})

	var stdout bytes.Buffer
	if err := cmd.Run([]string{"--no-install"}, &stdout, &bytes.Buffer{}); err != nil {
		t.Fatalf("Run() error = %v", err)
	}
	assertWelcomeOutput(t, "stdout", stdout.String())
}

func TestShellWelcomeSkipInputStoresLatestUpdateTag(t *testing.T) {
	now := time.Date(2026, 5, 10, 12, 34, 56, 0, time.UTC)
	update, cacheDir := testUpdateCommand(t, now)
	update.getenv = func(name string) string {
		if name == "PROJMUX_INSTALLER" {
			return "npm"
		}
		return ""
	}
	latest := testVersionTag(t, 1)
	writeUpdateCacheFixture(t, cacheDir, updateCache{
		Version:     1,
		CheckedAt:   now,
		TagName:     latest,
		PublishedAt: now,
	})

	home := t.TempDir()
	cmd := &shellCommand{
		executable:   func() (string, error) { return "/tmp/projmux", nil },
		lookupEnv:    func(string) string { return "" },
		homeDir:      func() (string, error) { return home, nil },
		welcomeInput: strings.NewReader("s\n"),
		writeFile:    os.WriteFile,
		runCommand:   (&recordingShellRunner{}).run,
		update:       update,
	}

	var stdout bytes.Buffer
	if err := cmd.Run([]string{"--no-install"}, &stdout, &bytes.Buffer{}); err != nil {
		t.Fatalf("Run() error = %v", err)
	}
	skipPath, err := cmd.updateSkipPath()
	if err != nil {
		t.Fatal(err)
	}
	data, err := os.ReadFile(skipPath)
	if err != nil {
		t.Fatal(err)
	}
	if !strings.Contains(string(data), latest) {
		t.Fatalf("skip state = %s, want %s", data, latest)
	}
	if !strings.Contains(stdout.String(), "Skipped "+latest+" until the next release.") {
		t.Fatalf("stdout = %q, want skip confirmation", stdout.String())
	}
}

func TestShellWelcomeEnterInputDoesNotStoreSkip(t *testing.T) {
	home := t.TempDir()
	cmd := &shellCommand{
		executable:   func() (string, error) { return "/tmp/projmux", nil },
		lookupEnv:    func(string) string { return "" },
		homeDir:      func() (string, error) { return home, nil },
		welcomeInput: strings.NewReader("\n"),
		writeFile:    os.WriteFile,
		runCommand:   (&recordingShellRunner{}).run,
	}

	if err := cmd.Run([]string{"--no-install"}, &bytes.Buffer{}, &bytes.Buffer{}); err != nil {
		t.Fatalf("Run() error = %v", err)
	}
	state := readShellWelcomeState(t, cmd)
	if state.SkipVersion != "" {
		t.Fatalf("state = %+v, did not want skip_version after Enter", state)
	}
}

func TestShellWelcomeDoesNotScheduleRedundantAttachPopup(t *testing.T) {
	home := t.TempDir()
	cmd := &shellCommand{
		executable:   func() (string, error) { return "/tmp/projmux", nil },
		lookupEnv:    func(string) string { return "" },
		homeDir:      func() (string, error) { return home, nil },
		welcomeInput: strings.NewReader("\n"),
		writeFile:    os.WriteFile,
		runCommand:   (&recordingShellRunner{}).run,
	}

	if err := cmd.Run([]string{"--no-install"}, &bytes.Buffer{}, &bytes.Buffer{}); err != nil {
		t.Fatalf("Run() error = %v", err)
	}

	welcome := testWelcomePopupCommand(t, home)
	if err := welcome.Run([]string{"--popup"}, &bytes.Buffer{}, &bytes.Buffer{}); err != nil {
		t.Fatalf("Run(--popup) error = %v", err)
	}
	if calls := welcome.runner.(*recordingTmuxRunner).calls; len(calls) != 0 {
		t.Fatalf("attach popup calls = %#v, want none after shell prompt", calls)
	}
}

func TestShellWelcomeCorruptStateDoesNotBlockStartup(t *testing.T) {
	home := t.TempDir()
	recorder := &recordingShellRunner{}
	cmd := &shellCommand{
		executable: func() (string, error) { return "/tmp/projmux", nil },
		lookupEnv:  func(string) string { return "" },
		homeDir:    func() (string, error) { return home, nil },
		writeFile:  os.WriteFile,
		runCommand: recorder.run,
	}
	path, err := cmd.welcomeStatePath(version.String())
	if err != nil {
		t.Fatal(err)
	}
	if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(path, []byte("{not json"), 0o644); err != nil {
		t.Fatal(err)
	}

	var stdout bytes.Buffer
	if err := cmd.Run([]string{"--no-install"}, &stdout, &bytes.Buffer{}); err != nil {
		t.Fatalf("Run() error = %v", err)
	}
	assertWelcomeOutput(t, "stdout", stdout.String())
	if recorder.name != "tmux" {
		t.Fatalf("shell did not continue into tmux, command = %q", recorder.name)
	}
}

func TestShellWelcomeAppliesInlineUpdate(t *testing.T) {
	now := time.Date(2026, 5, 10, 12, 34, 56, 0, time.UTC)
	update, cacheDir := testUpdateCommand(t, now)
	update.getenv = func(name string) string {
		if name == "PROJMUX_INSTALLER" {
			return "npm"
		}
		return ""
	}
	var updateCommands []string
	update.runExternal = func(name string, args []string, stdout, stderr io.Writer) error {
		updateCommands = append(updateCommands, updateApplyCommand{Name: name, Args: args}.String())
		return nil
	}
	latest := testVersionTag(t, 1)
	writeUpdateCacheFixture(t, cacheDir, updateCache{
		Version:     1,
		CheckedAt:   now,
		TagName:     latest,
		PublishedAt: now,
	})

	home := t.TempDir()
	cmd := &shellCommand{
		executable:   func() (string, error) { return "/tmp/projmux", nil },
		lookupEnv:    func(string) string { return "" },
		homeDir:      func() (string, error) { return home, nil },
		welcomeInput: strings.NewReader("u\n"),
		writeFile:    os.WriteFile,
		runCommand:   (&recordingShellRunner{}).run,
		update:       update,
		nativePicker: nativePickerFromCompatRunner(shellUpdateRunnerFunc(func(options intpickercompat.Options) (intpickercompat.Result, error) {
			t.Fatalf("unexpected separate update prompt: %#v", options)
			return intpickercompat.Result{}, nil
		})),
	}

	var stdout bytes.Buffer
	if err := cmd.Run([]string{"--no-install"}, &stdout, &bytes.Buffer{}); err != nil {
		t.Fatalf("Run() error = %v", err)
	}
	wantCommands := []string{"npm update -g projmux", "projmux tmux apply"}
	if !reflect.DeepEqual(updateCommands, wantCommands) {
		t.Fatalf("update commands = %#v, want %#v", updateCommands, wantCommands)
	}
	assertWelcomeOutput(t, "stdout", stdout.String())
	for _, want := range []string{"Enter=Continue", "u=Upgrade", "s=Skip until next"} {
		if !strings.Contains(stdout.String(), want) {
			t.Fatalf("stdout = %q, want inline prompt token %q", stdout.String(), want)
		}
	}
	if strings.Contains(stdout.String(), "d=") {
		t.Fatalf("stdout = %q, did not want retired daily skip action", stdout.String())
	}
}

func TestShellWelcomeUnknownActionContinuesWithoutUpdating(t *testing.T) {
	now := time.Date(2026, 5, 10, 12, 34, 56, 0, time.UTC)
	update, cacheDir := testUpdateCommand(t, now)
	update.getenv = func(name string) string {
		if name == "PROJMUX_INSTALLER" {
			return "npm"
		}
		return ""
	}
	update.runExternal = func(name string, args []string, stdout, stderr io.Writer) error {
		t.Fatalf("unexpected update command: %s %#v", name, args)
		return nil
	}
	writeUpdateCacheFixture(t, cacheDir, updateCache{
		Version:     1,
		CheckedAt:   now,
		TagName:     testVersionTag(t, 1),
		PublishedAt: now,
	})

	home := t.TempDir()
	cmd := &shellCommand{
		executable:   func() (string, error) { return "/tmp/projmux", nil },
		lookupEnv:    func(string) string { return "" },
		homeDir:      func() (string, error) { return home, nil },
		welcomeInput: strings.NewReader("n\n"),
		writeFile:    os.WriteFile,
		runCommand:   (&recordingShellRunner{}).run,
		update:       update,
		nativePicker: nativePickerFromCompatRunner(shellUpdateRunnerFunc(func(options intpickercompat.Options) (intpickercompat.Result, error) {
			t.Fatalf("unexpected separate update prompt: %#v", options)
			return intpickercompat.Result{}, nil
		})),
	}

	var stdout bytes.Buffer
	if err := cmd.Run([]string{"--no-install"}, &stdout, &bytes.Buffer{}); err != nil {
		t.Fatalf("Run() error = %v", err)
	}
	if strings.Contains(stdout.String(), "Run `projmux update apply` to upgrade.") {
		t.Fatalf("stdout = %q, did not want retired manual-command decline copy", stdout.String())
	}
}

func TestShellWelcomeSkipUntilNextWritesUpdateSkipState(t *testing.T) {
	now := time.Date(2026, 5, 10, 12, 34, 56, 0, time.UTC)
	update, cacheDir := testUpdateCommand(t, now)
	update.getenv = func(name string) string {
		if name == "PROJMUX_INSTALLER" {
			return "npm"
		}
		return ""
	}
	latest := testVersionTag(t, 1)
	writeUpdateCacheFixture(t, cacheDir, updateCache{
		Version:     1,
		CheckedAt:   now,
		TagName:     latest,
		PublishedAt: now,
	})

	home := t.TempDir()
	cmd := &shellCommand{
		executable:   func() (string, error) { return "/tmp/projmux", nil },
		lookupEnv:    func(string) string { return "" },
		homeDir:      func() (string, error) { return home, nil },
		welcomeInput: strings.NewReader("s\n"),
		writeFile:    os.WriteFile,
		runCommand:   (&recordingShellRunner{}).run,
		update:       update,
	}

	var stdout bytes.Buffer
	if err := cmd.Run([]string{"--no-install"}, &stdout, &bytes.Buffer{}); err != nil {
		t.Fatalf("Run() error = %v", err)
	}
	skipPath, err := cmd.updateSkipPath()
	if err != nil {
		t.Fatal(err)
	}
	data, err := os.ReadFile(skipPath)
	if err != nil {
		t.Fatal(err)
	}
	if !strings.Contains(string(data), latest) {
		t.Fatalf("skip state = %s, want %s", data, latest)
	}
	if !strings.Contains(stdout.String(), "Skipped "+latest+" until the next release.") {
		t.Fatalf("stdout = %q, want skip confirmation", stdout.String())
	}
}

func TestShellWelcomeSkipsStaleUpdateRow(t *testing.T) {
	now := time.Date(2026, 5, 10, 12, 34, 56, 0, time.UTC)
	update, cacheDir := testUpdateCommand(t, now)
	update.getenv = func(name string) string {
		if name == "PROJMUX_INSTALLER" {
			return "npm"
		}
		return ""
	}
	writeUpdateCacheFixture(t, cacheDir, updateCache{
		Version:     1,
		CheckedAt:   now.Add(-25 * time.Hour),
		TagName:     testVersionTag(t, 1),
		PublishedAt: now.Add(-25 * time.Hour),
	})

	home := t.TempDir()
	cmd := &shellCommand{
		executable: func() (string, error) { return "/tmp/projmux", nil },
		lookupEnv:  func(string) string { return "" },
		homeDir:    func() (string, error) { return home, nil },
		writeFile:  os.WriteFile,
		runCommand: (&recordingShellRunner{}).run,
		update:     update,
	}

	var stdout bytes.Buffer
	if err := cmd.Run([]string{"--no-install"}, &stdout, &bytes.Buffer{}); err != nil {
		t.Fatalf("Run() error = %v", err)
	}
	if strings.Contains(stdout.String(), "Update:") {
		t.Fatalf("stdout = %q, did not expect stale update row", stdout.String())
	}
}

func TestShellDoesNotRunSeparateUpdatePicker(t *testing.T) {
	now := time.Date(2026, 5, 7, 4, 30, 0, 0, time.UTC)
	update, cacheDir := testUpdateCommand(t, now)
	update.getenv = func(name string) string {
		if name == "PROJMUX_INSTALLER" {
			return "npm"
		}
		return ""
	}
	var updateCommands []string
	update.runExternal = func(name string, args []string, stdout, stderr io.Writer) error {
		updateCommands = append(updateCommands, updateApplyCommand{Name: name, Args: args}.String())
		return nil
	}
	latest := testVersionTag(t, 1)
	writeUpdateCacheFixture(t, cacheDir, updateCache{
		Version:     1,
		CheckedAt:   now,
		TagName:     latest,
		HTMLURL:     "https://github.com/crevissepartners/projmux/releases/tag/" + latest,
		PublishedAt: now,
	})

	home := t.TempDir()
	recorder := &recordingShellRunner{}
	cmd := &shellCommand{
		executable:   func() (string, error) { return "/tmp/projmux", nil },
		lookupEnv:    func(string) string { return "" },
		homeDir:      func() (string, error) { return home, nil },
		welcomeInput: strings.NewReader("\n"),
		writeFile:    os.WriteFile,
		runCommand:   recorder.run,
		update:       update,
		nativePicker: nativePickerFromCompatRunner(shellUpdateRunnerFunc(func(options intpickercompat.Options) (intpickercompat.Result, error) {
			t.Fatalf("unexpected separate update prompt: %#v", options)
			return intpickercompat.Result{}, nil
		})),
	}
	markShellWelcomed(t, cmd)

	var stdout bytes.Buffer
	if err := cmd.Run(nil, &stdout, &bytes.Buffer{}); err != nil {
		t.Fatalf("Run() error = %v", err)
	}
	if recorder.name != "tmux" {
		t.Fatalf("shell did not continue into tmux, command = %q", recorder.name)
	}
	if len(updateCommands) != 0 {
		t.Fatalf("update commands = %#v, want none without inline u action", updateCommands)
	}
	if !strings.Contains(stdout.String(), latest) {
		t.Fatalf("stdout = %q, want inline release prompt for %s", stdout.String(), latest)
	}
}

func TestShellWelcomeUnsupportedInstallerShowsGuidanceAndContinues(t *testing.T) {
	now := time.Date(2026, 5, 7, 4, 30, 0, 0, time.UTC)
	update, cacheDir := testUpdateCommand(t, now)
	update.getenv = func(name string) string {
		if name == "PROJMUX_INSTALLER" {
			return "source"
		}
		return ""
	}
	update.runExternal = func(name string, args []string, stdout, stderr io.Writer) error {
		t.Fatalf("unexpected update command: %s %#v", name, args)
		return nil
	}
	writeUpdateCacheFixture(t, cacheDir, updateCache{
		Version:     1,
		CheckedAt:   now,
		TagName:     testVersionTag(t, 1),
		PublishedAt: now,
	})

	home := t.TempDir()
	recorder := &recordingShellRunner{}
	cmd := &shellCommand{
		executable:   func() (string, error) { return "/tmp/projmux", nil },
		lookupEnv:    func(string) string { return "" },
		homeDir:      func() (string, error) { return home, nil },
		welcomeInput: strings.NewReader("u\n"),
		writeFile:    os.WriteFile,
		runCommand:   recorder.run,
		update:       update,
		nativePicker: nativePickerFromCompatRunner(shellUpdateRunnerFunc(func(options intpickercompat.Options) (intpickercompat.Result, error) {
			t.Fatalf("unexpected separate update prompt: %#v", options)
			return intpickercompat.Result{}, nil
		})),
	}
	markShellWelcomed(t, cmd)

	var stdout bytes.Buffer
	if err := cmd.Run(nil, &stdout, &bytes.Buffer{}); err != nil {
		t.Fatalf("Run() error = %v", err)
	}
	if recorder.name != "tmux" {
		t.Fatalf("shell did not continue into tmux, command = %q", recorder.name)
	}
	for _, want := range []string{"Upgrade guidance", "source", "update from the source checkout"} {
		if !strings.Contains(stdout.String(), want) {
			t.Fatalf("stdout = %q, want %q", stdout.String(), want)
		}
	}
}

func TestShellWelcomeSkippedUpdateTagSuppressesActionsUntilNextTag(t *testing.T) {
	now := time.Date(2026, 5, 7, 4, 30, 0, 0, time.UTC)
	update, cacheDir := testUpdateCommand(t, now)
	update.getenv = func(name string) string {
		if name == "PROJMUX_INSTALLER" {
			return "npm"
		}
		return ""
	}
	latest := testVersionTag(t, 1)
	writeUpdateCacheFixture(t, cacheDir, updateCache{
		Version:     1,
		CheckedAt:   now,
		TagName:     latest,
		PublishedAt: now,
	})

	home := t.TempDir()
	recorder := &recordingShellRunner{}
	cmd := &shellCommand{
		executable:   func() (string, error) { return "/tmp/projmux", nil },
		lookupEnv:    func(string) string { return "" },
		homeDir:      func() (string, error) { return home, nil },
		welcomeInput: strings.NewReader("s\n"),
		writeFile:    os.WriteFile,
		runCommand:   recorder.run,
		update:       update,
		nativePicker: nativePickerFromCompatRunner(shellUpdateRunnerFunc(func(options intpickercompat.Options) (intpickercompat.Result, error) {
			t.Fatalf("unexpected separate update prompt: %#v", options)
			return intpickercompat.Result{}, nil
		})),
	}
	markShellWelcomed(t, cmd)

	if err := cmd.Run(nil, &bytes.Buffer{}, &bytes.Buffer{}); err != nil {
		t.Fatalf("first Run() error = %v", err)
	}
	skipPath, err := cmd.updateSkipPath()
	if err != nil {
		t.Fatal(err)
	}
	data, err := os.ReadFile(skipPath)
	if err != nil {
		t.Fatal(err)
	}
	if !strings.Contains(string(data), latest) {
		t.Fatalf("skip state = %s, want %s", data, latest)
	}

	cmd.welcomeInput = strings.NewReader("u\n")
	var stdout bytes.Buffer
	if err := cmd.Run(nil, &stdout, &bytes.Buffer{}); err != nil {
		t.Fatalf("second Run() error = %v", err)
	}
	if strings.Contains(stdout.String(), "u=Upgrade") {
		t.Fatalf("stdout = %q, did not want upgrade action after skip", stdout.String())
	}
	if strings.Contains(stdout.String(), ">> running:") {
		t.Fatalf("stdout = %q, did not want update apply after skip", stdout.String())
	}
}

func TestShellWelcomeRefreshesStaleUpdateCacheBestEffort(t *testing.T) {
	now := time.Date(2026, 5, 7, 4, 30, 0, 0, time.UTC)
	update, cacheDir := testUpdateCommand(t, now)
	update.getenv = func(name string) string {
		if name == "PROJMUX_INSTALLER" {
			return "npm"
		}
		return ""
	}
	latest := testVersionTag(t, 2)
	update.client = &http.Client{Transport: updateRoundTripFunc(func(req *http.Request) (*http.Response, error) {
		body := fmt.Sprintf(`{"tag_name":%q,"name":%q,"html_url":"https://github.com/crevissepartners/projmux/releases/tag/%s","published_at":"2026-05-06T10:00:00Z"}`, latest, latest, latest)
		return &http.Response{
			StatusCode: http.StatusOK,
			Body:       io.NopCloser(strings.NewReader(body)),
			Header:     make(http.Header),
		}, nil
	})}
	writeUpdateCacheFixture(t, cacheDir, updateCache{
		Version:     1,
		CheckedAt:   now.Add(-25 * time.Hour),
		TagName:     testVersionTag(t, 1),
		PublishedAt: now.Add(-25 * time.Hour),
	})
	home := t.TempDir()
	recorder := &recordingShellRunner{}
	cmd := &shellCommand{
		executable:   func() (string, error) { return "/tmp/projmux", nil },
		lookupEnv:    func(string) string { return "" },
		homeDir:      func() (string, error) { return home, nil },
		welcomeInput: strings.NewReader("\n"),
		writeFile:    os.WriteFile,
		runCommand:   recorder.run,
		update:       update,
	}

	var stdout bytes.Buffer
	if err := cmd.Run(nil, &stdout, &bytes.Buffer{}); err != nil {
		t.Fatalf("Run() error = %v", err)
	}
	if recorder.name != "tmux" {
		t.Fatalf("shell did not continue into tmux, command = %q", recorder.name)
	}
	if !strings.Contains(stdout.String(), latest) {
		t.Fatalf("stdout = %q, want refreshed latest %s", stdout.String(), latest)
	}
}

func TestShellRejectsNestedProjmuxSocket(t *testing.T) {
	t.Setenv("TMUX", "/tmp/tmux-1000/projmux,123,0")

	home := t.TempDir()
	recorder := &recordingShellRunner{}
	cmd := &shellCommand{
		executable: func() (string, error) { return "/tmp/projmux", nil },
		lookupEnv:  os.Getenv,
		homeDir:    func() (string, error) { return home, nil },
		writeFile:  os.WriteFile,
		runCommand: recorder.run,
	}

	err := cmd.Run(nil, &bytes.Buffer{}, &bytes.Buffer{})
	if err == nil {
		t.Fatal("expected nested shell error")
	}
	if !strings.Contains(err.Error(), "cannot run inside") {
		t.Fatalf("error = %v, want nested shell guard", err)
	}
	if recorder.name != "" {
		t.Fatalf("tmux command ran despite nested guard: %s %#v", recorder.name, recorder.args)
	}
	configPath := filepath.Join(home, ".config", "projmux", "tmux.conf")
	if _, err := os.Stat(configPath); !os.IsNotExist(err) {
		t.Fatalf("config was written despite nested guard: %v", err)
	}
}

func TestShellRejectsNestedCustomSocket(t *testing.T) {
	t.Setenv("TMUX", "/tmp/tmux-1000/pmx-dev,123,0")

	cmd := &shellCommand{lookupEnv: os.Getenv}
	err := cmd.Run([]string{"--socket", "pmx-dev"}, &bytes.Buffer{}, &bytes.Buffer{})
	if err == nil {
		t.Fatal("expected nested custom socket error")
	}
	if !strings.Contains(err.Error(), `"pmx-dev"`) {
		t.Fatalf("error = %v, want socket name", err)
	}
}

func TestShellRejectsInvalidUsage(t *testing.T) {
	t.Parallel()

	var stderr bytes.Buffer
	err := (&shellCommand{}).Run([]string{"extra"}, &bytes.Buffer{}, &stderr)
	if err == nil {
		t.Fatal("expected error")
	}
	if !strings.Contains(err.Error(), "shell does not accept positional arguments") {
		t.Fatalf("error = %v", err)
	}
	if !strings.Contains(stderr.String(), "Usage:") {
		t.Fatalf("stderr = %q, want usage", stderr.String())
	}
}

type recordingShellRunner struct {
	env  []string
	name string
	args []string
}

func (r *recordingShellRunner) run(_ context.Context, env []string, name string, args ...string) error {
	r.env = append([]string(nil), env...)
	r.name = name
	r.args = append([]string(nil), args...)
	return nil
}

func recordShellWrite(writes map[string][]byte) func(string, []byte, os.FileMode) error {
	return func(path string, data []byte, _ os.FileMode) error {
		writes[path] = append([]byte(nil), data...)
		return nil
	}
}

type scriptedShellTmuxRunner struct {
	outputs map[string][]byte
	errors  map[string]error
	calls   []recordedTmuxCall
	onRun   func(recordedTmuxCall)
}

func (r *scriptedShellTmuxRunner) Run(_ context.Context, name string, args ...string) ([]byte, error) {
	call := recordedTmuxCall{name: name, args: append([]string(nil), args...)}
	r.calls = append(r.calls, call)
	if r.onRun != nil {
		r.onRun(call)
	}
	key := shellTmuxCallKey(name, args...)
	if err, ok := r.errors[key]; ok {
		return nil, err
	}
	if output, ok := r.outputs[key]; ok {
		return output, nil
	}
	return nil, nil
}

func shellTmuxCallKey(name string, args ...string) string {
	return strings.Join(append([]string{name}, args...), "\x00")
}

func saveShellSnapshot(t *testing.T, store sessionstate.Store, sessionName, cwd string) {
	t.Helper()
	saveShellSnapshotSource(t, store, sessionName, cwd, "")
}

func saveShellSnapshotSource(t *testing.T, store sessionstate.Store, sessionName, cwd, source string) {
	t.Helper()
	if err := store.Save(sessionstate.Snapshot{
		Version:    sessionstate.Version,
		Session:    sessionName,
		Source:     source,
		DefaultCWD: cwd,
		SavedAt:    time.Date(2026, 5, 12, 1, 2, 3, 0, time.UTC),
		Windows: []sessionstate.Window{{
			Index:           0,
			Name:            "main",
			Layout:          "layout",
			ActivePaneIndex: 0,
			Panes:           []sessionstate.Pane{{Index: 0, CWD: cwd, Recipe: sessionstate.ShellRecipe()}},
		}},
	}); err != nil {
		t.Fatal(err)
	}
}

type shellUpdateRunnerFunc func(options intpickercompat.Options) (intpickercompat.Result, error)

func (f shellUpdateRunnerFunc) Run(options intpickercompat.Options) (intpickercompat.Result, error) {
	return f(options)
}

func markShellWelcomed(t *testing.T, cmd *shellCommand) {
	t.Helper()
	writeShellWelcomeState(t, cmd, shellWelcomeState{
		Version:             welcomeStateVersion,
		LastWelcomedVersion: version.String(),
		WelcomedAt:          time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC),
		SkipVersion:         version.String(),
		SkippedAt:           testTimePtr(time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)),
	})
}

func testTimePtr(value time.Time) *time.Time {
	return &value
}

func writeShellWelcomeState(t *testing.T, cmd *shellCommand, state shellWelcomeState) {
	t.Helper()
	path, err := cmd.welcomeStatePath(version.String())
	if err != nil {
		t.Fatal(err)
	}
	if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
		t.Fatal(err)
	}
	data, err := json.MarshalIndent(state, "", "  ")
	if err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(path, data, 0o644); err != nil {
		t.Fatal(err)
	}
}

func readShellWelcomeState(t *testing.T, cmd *shellCommand) shellWelcomeState {
	t.Helper()
	path, err := cmd.welcomeStatePath(version.String())
	if err != nil {
		t.Fatal(err)
	}
	data, err := os.ReadFile(path)
	if err != nil {
		t.Fatal(err)
	}
	var state shellWelcomeState
	if err := json.Unmarshal(data, &state); err != nil {
		t.Fatal(err)
	}
	return state
}
