package tmux

import (
	"context"
	"errors"
	"fmt"
	"os"
	"os/exec"
	"sort"
	"strconv"
	"strings"
	"time"

	"github.com/crevissepartners/projmux/internal/core/lifecycle"
	"github.com/crevissepartners/projmux/internal/integrations/hooks"
	intmux "github.com/crevissepartners/projmux/internal/integrations/mux"
)

const SwitchTargetClientEnv = "PROJMUX_SWITCH_TARGET_CLIENT"

var (
	errCurrentPanePathUnavailable = errors.New("tmux current pane path is unavailable")
	errCurrentSessionUnavailable  = errors.New("tmux current session is unavailable")
	errSessionNameRequired        = errors.New("tmux session name is required")
	errSessionCWDRequired         = errors.New("tmux session cwd is required")
	errPopupCommandRequired       = intmux.ErrPopupCommandRequired
	errPopupCloseBehaviorInvalid  = intmux.ErrPopupCloseBehaviorInvalid
	errWindowIndexRequired        = errors.New("tmux window index is required when pane index is set")
	errSessionActivityInvalid     = errors.New("tmux session activity is invalid")
	errSessionAttachedInvalid     = errors.New("tmux session attached flag is invalid")
	errSessionEphemeralInvalid    = errors.New("tmux session ephemeral flag is invalid")
	errWindowIndexInvalid         = errors.New("tmux window index is invalid")
	errWindowPaneCountInvalid     = errors.New("tmux window pane count is invalid")
	errPaneIndexInvalid           = errors.New("tmux pane index is invalid")
	errActiveFlagInvalid          = errors.New("tmux active flag is invalid")
)

const (
	tmuxFieldSep        = "\x1f"
	tmuxEscapedFieldSep = "\\037"
)

type commandRunner interface {
	Run(ctx context.Context, name string, args ...string) ([]byte, error)
}

// ExecRunner shells out to external commands.
type ExecRunner struct{}

// Run executes a command and returns its combined output.
func (ExecRunner) Run(ctx context.Context, name string, args ...string) ([]byte, error) {
	cmd := exec.CommandContext(ctx, name, args...)
	if name == "tmux" && len(args) > 0 && (args[0] == "attach-session" || args[0] == "switch-client") {
		cmd.Stdin = os.Stdin
		cmd.Stdout = os.Stdout
		cmd.Stderr = os.Stderr
		if err := cmd.Run(); err != nil {
			return nil, fmt.Errorf("%s %s: %w", name, strings.Join(args, " "), err)
		}
		return nil, nil
	}
	output, err := cmd.CombinedOutput()
	if err != nil {
		trimmed := strings.TrimSpace(string(output))
		if trimmed != "" {
			return nil, fmt.Errorf("%s %s: %w: %s", name, strings.Join(args, " "), err, trimmed)
		}
		return nil, fmt.Errorf("%s %s: %w", name, strings.Join(args, " "), err)
	}
	return output, nil
}

// postCreateRunner is the subset of *hooks.PostCreateRunner that the tmux
// client invokes after creating a new session. The interface keeps the
// dependency narrow and lets tests stub it without spinning up real exec.
type postCreateRunner interface {
	Run(ctx context.Context, c hooks.PostCreateContext)
}

// lifecycleHookRunner is the event-oriented hook surface the tmux client uses
// for lifecycle events beyond the legacy post-create callback.
type lifecycleHookRunner interface {
	Run(ctx context.Context, event hooks.Event, c hooks.Context) (hooks.RunResult, error)
}

type lifecycleHookInspector interface {
	HasHooks(event hooks.Event, cwd string) bool
}

type lifecycleSessionEnvProvider interface {
	ProjectSessionEnv(cwd string) map[string]string
}

type startupCommandProvider interface {
	StartupCommand(cwd string) (string, bool)
}

// Client exposes typed tmux queries used by CLI commands.
type Client struct {
	runner     commandRunner
	lookupEnv  func(string) string
	readFile   func(string) ([]byte, error)
	postCreate postCreateRunner
	lifecycle  lifecycleHookRunner
	socket     string
}

// ClientOption configures optional Client behavior.
type ClientOption func(*Client)

// WithPostCreateRunner attaches a hook runner that fires after a tmux
// session is newly created. Pass nil to disable.
func WithPostCreateRunner(r *hooks.PostCreateRunner) ClientOption {
	return func(c *Client) {
		if r == nil {
			c.postCreate = nil
			return
		}
		c.postCreate = r
	}
}

// WithLifecycleHookRunner attaches the generic lifecycle hook runner.
func WithLifecycleHookRunner(r *hooks.Runner) ClientOption {
	return func(c *Client) {
		if r == nil {
			c.lifecycle = nil
			return
		}
		c.lifecycle = r
	}
}

// WithSocketName records the tmux -L socket name (if any) the caller is using
// so it can be propagated to hook scripts via PROJMUX_SOCKET. The Client
// itself does not currently shell out with -L; this is metadata only.
func WithSocketName(socket string) ClientOption {
	return func(c *Client) {
		c.socket = strings.TrimSpace(socket)
	}
}

// WithFileReader replaces file reads used by the client. It is primarily for
// tests that need deterministic local file metadata without touching tmux.
func WithFileReader(readFile func(string) ([]byte, error)) ClientOption {
	return func(c *Client) {
		if readFile == nil {
			c.readFile = os.ReadFile
			return
		}
		c.readFile = readFile
	}
}

// Window describes a tmux window inventory row for a session.
type Window struct {
	Index     int
	Name      string
	PaneCount int
	Path      string
	Active    bool
}

// Pane describes a tmux pane inventory row.
type Pane struct {
	ID                  string
	SessionName         string
	WindowIndex         int
	PaneIndex           int
	Title               string
	AttentionState      string
	AIState             string
	AIBadgeKind         string
	AIAgent             string
	AITopic             string
	AttentionAck        string
	AttentionFocusArmed string
	Command             string
	Path                string
	Active              bool
}

// WindowPane describes a tmux pane inventory row scoped to a single window.
type WindowPane struct {
	Index  int
	Active bool
}

type PopupCloseBehavior = intmux.PopupCloseBehavior

const (
	PopupCloseOnExit = intmux.PopupCloseOnExit
	PopupKeepOpen    = intmux.PopupKeepOpen
)

type PopupOptions = intmux.PopupOptions

// RecentSessionSummary describes one recent tmux session with lightweight row
// metadata for session pickers.
type RecentSessionSummary struct {
	Name        string
	Attached    bool
	WindowCount int
	PaneCount   int
	Path        string
	Activity    int64
}

// NewClient builds a tmux client over the provided runner with optional
// configuration.
func NewClient(runner commandRunner, opts ...ClientOption) *Client {
	c := &Client{
		runner:    runner,
		lookupEnv: os.Getenv,
		readFile:  os.ReadFile,
	}
	for _, opt := range opts {
		opt(c)
	}
	return c
}

func newClientWithEnv(runner commandRunner, lookupEnv func(string) string, opts ...ClientOption) *Client {
	c := &Client{
		runner:    runner,
		lookupEnv: lookupEnv,
		readFile:  os.ReadFile,
	}
	for _, opt := range opts {
		opt(c)
	}
	return c
}

// CurrentPanePath returns the current tmux pane path for the active client.
func (c *Client) CurrentPanePath(ctx context.Context) (string, error) {
	output, err := c.runner.Run(ctx, "tmux", "display-message", "-p", "-F", "#{pane_current_path}")
	if err != nil {
		return "", fmt.Errorf("resolve current tmux pane path: %w", err)
	}

	path := strings.TrimSpace(string(output))
	if path == "" {
		return "", errCurrentPanePathUnavailable
	}

	return path, nil
}

// CurrentSessionName returns the current tmux session name for the active client.
func (c *Client) CurrentSessionName(ctx context.Context) (string, error) {
	output, err := c.runner.Run(ctx, "tmux", "display-message", "-p", "-F", "#{session_name}")
	if err != nil {
		return "", fmt.Errorf("resolve current tmux session: %w", err)
	}

	sessionName := strings.TrimSpace(string(output))
	if sessionName == "" {
		return "", errCurrentSessionUnavailable
	}

	return sessionName, nil
}

// RecentSessions lists tmux session names ordered by most-recent activity first.
func (c *Client) RecentSessions(ctx context.Context) ([]string, error) {
	output, err := c.runner.Run(ctx, "tmux", "list-sessions", "-F", tmuxFormat("#{session_activity}", "#{session_name}", "#{session_attached}", "#{session_windows}"))
	if err != nil {
		return nil, fmt.Errorf("list recent tmux sessions: %w", err)
	}

	return parseRecentSessions(output)
}

// ExistingSessions returns the current tmux session-name inventory as a set.
func (c *Client) ExistingSessions(ctx context.Context) (map[string]bool, error) {
	output, err := c.runner.Run(ctx, "tmux", "list-sessions", "-F", "#{session_name}")
	if err != nil {
		if isNoServerError(err) {
			return map[string]bool{}, nil
		}
		return nil, fmt.Errorf("list tmux sessions: %w", err)
	}
	return parseExistingSessionSet(output), nil
}

// RecentSessionSummaries lists tmux session rows ordered by most-recent
// activity first, enriched with attachment and pane metadata.
func (c *Client) RecentSessionSummaries(ctx context.Context) ([]RecentSessionSummary, error) {
	output, err := c.runner.Run(ctx, "tmux", "list-sessions", "-F", tmuxFormat("#{session_activity}", "#{session_name}", "#{session_attached}", "#{session_windows}"))
	if err != nil {
		return nil, fmt.Errorf("list recent tmux sessions: %w", err)
	}

	rows, err := parseRecentSessionRows(output)
	if err != nil {
		return nil, fmt.Errorf("list recent tmux sessions: %w", err)
	}
	if len(rows) == 0 {
		return nil, nil
	}

	panes, err := c.ListAllPanes(ctx)
	if err != nil {
		return nil, err
	}

	bySession := summarizePanesBySession(panes)
	summaries := make([]RecentSessionSummary, 0, len(rows))
	for _, row := range rows {
		summary := RecentSessionSummary{
			Name:        row.name,
			Attached:    row.attached,
			WindowCount: row.windows,
			Activity:    row.activity,
		}
		if paneSummary, ok := bySession[row.name]; ok {
			summary.PaneCount = paneSummary.paneCount
			summary.Path = paneSummary.path
		}
		summaries = append(summaries, summary)
	}

	return summaries, nil
}

// ListEphemeralSessions lists tmux sessions with the lifecycle metadata needed
// for auto-attach reuse and stale-session pruning decisions.
func (c *Client) ListEphemeralSessions(ctx context.Context) ([]lifecycle.SessionInventory, error) {
	output, err := c.runner.Run(ctx, "tmux", "list-sessions", "-F", "#{session_name}\t#{session_attached}\t#{session_last_attached}\t#{@projmux_ephemeral}")
	if err != nil {
		if isNoServerError(err) {
			return nil, nil
		}
		return nil, fmt.Errorf("list ephemeral tmux sessions: %w", err)
	}

	sessions, err := parseEphemeralSessions(output)
	if err != nil {
		return nil, fmt.Errorf("list ephemeral tmux sessions: %w", err)
	}

	return sessions, nil
}

func isNoServerError(err error) bool {
	if err == nil {
		return false
	}

	message := err.Error()
	return strings.Contains(message, "no server running on") ||
		strings.Contains(message, "failed to connect to server") ||
		strings.Contains(message, "error connecting to ") && strings.Contains(message, "(No such file or directory)")
}

// ListSessionWindows lists the windows in a tmux session with active hints.
func (c *Client) ListSessionWindows(ctx context.Context, sessionName string) ([]Window, error) {
	if strings.TrimSpace(sessionName) == "" {
		return nil, errSessionNameRequired
	}

	output, err := c.runner.Run(ctx, "tmux", "list-windows", "-t", sessionName, "-F", tmuxFormat("#{window_index}", "#{?window_active,1,0}", "#{window_name}", "#{window_panes}", "#{pane_current_path}"))
	if err != nil {
		return nil, fmt.Errorf("list tmux windows for session %q: %w", sessionName, err)
	}

	windows, err := parseSessionWindows(output)
	if err != nil {
		return nil, fmt.Errorf("list tmux windows for session %q: %w", sessionName, err)
	}

	return windows, nil
}

// ListAllPanes lists tmux panes across all sessions with active hints.
func (c *Client) ListAllPanes(ctx context.Context) ([]Pane, error) {
	output, err := c.runner.Run(ctx, "tmux", "list-panes", "-a", "-F", tmuxFormat(
		"#{session_name}",
		"#{pane_id}",
		"#{window_index}",
		"#{pane_index}",
		"#{?pane_active,1,0}",
		"#{pane_title}",
		"#{@projmux_attention_state}",
		"#{@projmux_ai_state}",
		"#{@projmux_ai_badge_kind}",
		"#{@projmux_ai_agent}",
		"#{@projmux_ai_topic}",
		"#{@projmux_attention_ack}",
		"#{@projmux_attention_focus_armed}",
		"#{pane_current_command}",
		"#{pane_current_path}",
	))
	if err != nil {
		return nil, fmt.Errorf("list tmux panes: %w", err)
	}

	panes, err := parseAllPanes(output)
	if err != nil {
		return nil, fmt.Errorf("list tmux panes: %w", err)
	}

	return panes, nil
}

// CapturePane returns visible text from a tmux pane starting at the requested
// history offset.
func (c *Client) CapturePane(ctx context.Context, paneTarget string, startLine int) (string, error) {
	paneTarget = strings.TrimSpace(paneTarget)
	if paneTarget == "" {
		return "", errPaneIndexInvalid
	}

	output, err := c.runner.Run(ctx, "tmux", "capture-pane", "-p", "-t", paneTarget, "-S", strconv.Itoa(startLine))
	if err != nil {
		return "", fmt.Errorf("capture tmux pane %q: %w", paneTarget, err)
	}
	return strings.TrimRight(string(output), "\r\n"), nil
}

// ListWindowPanes lists panes for a tmux session window with active hints.
func (c *Client) ListWindowPanes(ctx context.Context, sessionName string, windowIndex int) ([]WindowPane, error) {
	if strings.TrimSpace(sessionName) == "" {
		return nil, errSessionNameRequired
	}
	if windowIndex < 0 {
		return nil, errWindowIndexInvalid
	}

	target := fmt.Sprintf("%s:%d", sessionName, windowIndex)
	output, err := c.runner.Run(ctx, "tmux", "list-panes", "-t", target, "-F", tmuxFormat("#{pane_index}", "#{?pane_active,1,0}"))
	if err != nil {
		return nil, fmt.Errorf("list tmux panes for session %q window %d: %w", sessionName, windowIndex, err)
	}

	panes, err := parseWindowPanes(output)
	if err != nil {
		return nil, fmt.Errorf("list tmux panes for session %q window %d: %w", sessionName, windowIndex, err)
	}

	return panes, nil
}

// EnsureSession creates the target session when it is missing.
func (c *Client) EnsureSession(ctx context.Context, sessionName, cwd string) error {
	if strings.TrimSpace(sessionName) == "" {
		return errSessionNameRequired
	}
	if strings.TrimSpace(cwd) == "" {
		return errSessionCWDRequired
	}

	exists, err := c.sessionExists(ctx, sessionName)
	if err != nil {
		return err
	}
	if exists {
		return nil
	}

	if err := c.runPreCreate(ctx, sessionName, cwd, "persistent"); err != nil {
		return err
	}

	sessionEnv := c.projectSessionEnv(cwd)
	paneID, err := c.createDetachedSession(ctx, sessionName, cwd, sessionEnv)
	if err != nil {
		return fmt.Errorf("create tmux session %q: %w", sessionName, err)
	}

	c.applyProjectSessionEnv(ctx, sessionName, sessionEnv)
	c.runPostCreate(ctx, sessionName, cwd, "persistent")
	c.runStartupCommand(ctx, sessionName, cwd, "persistent", paneID)
	return nil
}

// CreateEphemeralSession creates a detached tmux session and marks it as a
// CreateEphemeralSession creates a projmux-managed ephemeral session.
func (c *Client) CreateEphemeralSession(ctx context.Context, sessionName, cwd string) error {
	if strings.TrimSpace(sessionName) == "" {
		return errSessionNameRequired
	}
	if strings.TrimSpace(cwd) == "" {
		return errSessionCWDRequired
	}

	if err := c.runPreCreate(ctx, sessionName, cwd, "ephemeral"); err != nil {
		return err
	}

	sessionEnv := c.projectSessionEnv(cwd)
	paneID, err := c.createDetachedSession(ctx, sessionName, cwd, sessionEnv)
	if err != nil {
		return fmt.Errorf("create tmux ephemeral session %q: %w", sessionName, err)
	}
	c.applyProjectSessionEnv(ctx, sessionName, sessionEnv)
	if _, err := c.runner.Run(ctx, "tmux", "set-option", "-t", sessionName, "-q", "@projmux_ephemeral", "1"); err != nil {
		// set-option failure is intentionally swallowed; the session is still
		// usable. The post-create hook still runs so the session gets the same
		// lifecycle treatment as one whose marker stuck.
	}
	c.runPostCreate(ctx, sessionName, cwd, "ephemeral")
	c.runStartupCommand(ctx, sessionName, cwd, "ephemeral", paneID)

	return nil
}

func (c *Client) createDetachedSession(ctx context.Context, sessionName, cwd string, env map[string]string) (string, error) {
	return intmux.NewRunner(c.runner).NewSession(ctx, intmux.NewSessionOptions{
		Detached:     true,
		Session:      sessionName,
		Cwd:          cwd,
		Env:          env,
		ReturnPaneID: c.lifecycle != nil,
	})
}

func (c *Client) projectSessionEnv(cwd string) map[string]string {
	if c.lifecycle == nil {
		return nil
	}
	provider, ok := c.lifecycle.(lifecycleSessionEnvProvider)
	if !ok {
		return nil
	}
	return provider.ProjectSessionEnv(cwd)
}

func (c *Client) applyProjectSessionEnv(ctx context.Context, sessionName string, env map[string]string) {
	for _, key := range sortedMapKeys(env) {
		_, _ = c.runner.Run(ctx, "tmux", "set-environment", "-t", sessionName, key, env[key])
	}
}

func (c *Client) runPreCreate(ctx context.Context, sessionName, cwd, kind string) error {
	if c.lifecycle == nil {
		return nil
	}
	_, err := c.lifecycle.Run(ctx, hooks.EventPreCreate, hooks.Context{
		SessionName: sessionName,
		CWD:         cwd,
		Kind:        kind,
		Socket:      c.socket,
	})
	if err != nil {
		return fmt.Errorf("pre-create hook for tmux session %q: %w", sessionName, err)
	}
	return nil
}

func (c *Client) runPostCreate(ctx context.Context, sessionName, cwd, kind string) {
	context := hooks.Context{
		SessionName: sessionName,
		CWD:         cwd,
		Kind:        kind,
		Socket:      c.socket,
	}
	if c.lifecycle != nil {
		_, _ = c.lifecycle.Run(ctx, hooks.EventPostCreate, context)
		return
	}
	if c.postCreate == nil {
		return
	}
	c.postCreate.Run(ctx, hooks.PostCreateContext{
		SessionName: sessionName,
		CWD:         cwd,
		Kind:        kind,
		Socket:      c.socket,
	})
}

func (c *Client) runStartupCommand(ctx context.Context, sessionName, cwd, kind, paneID string) {
	if c.lifecycle == nil || strings.TrimSpace(paneID) == "" {
		return
	}
	provider, ok := c.lifecycle.(startupCommandProvider)
	if !ok {
		return
	}
	command, ok := provider.StartupCommand(cwd)
	command = strings.TrimSpace(command)
	if !ok || command == "" {
		return
	}
	if err := c.waitForPaneShellReady(ctx, paneID, 2*time.Second, 50*time.Millisecond); err != nil {
		return
	}
	if _, err := c.runner.Run(ctx, "tmux", "send-keys", "-t", paneID, command, "Enter"); err != nil {
		return
	}
	c.markStartupPane(ctx, paneID, command)
}

func (c *Client) markStartupPane(ctx context.Context, paneID, command string) {
	_, _ = c.runner.Run(ctx, "tmux", "set-option", "-p", "-t", paneID, "@projmux_recipe_kind", "startup")
	_, _ = c.runner.Run(ctx, "tmux", "set-option", "-p", "-t", paneID, "@projmux_startup_command", command)
}

func (c *Client) waitForPaneShellReady(ctx context.Context, paneID string, timeout, interval time.Duration) error {
	deadline := time.Now().Add(timeout)
	for {
		output, err := c.runner.Run(ctx, "tmux", "display-message", "-p", "-t", paneID, "-F", "#{pane_current_command}")
		if err != nil {
			return err
		}
		if c.isShellCommand(strings.TrimSpace(string(output))) {
			return nil
		}
		if time.Now().After(deadline) {
			return fmt.Errorf("pane %s shell did not become ready before %s", paneID, timeout)
		}
		select {
		case <-ctx.Done():
			return ctx.Err()
		case <-time.After(interval):
		}
	}
}

func (c *Client) isShellCommand(command string) bool {
	command = filepathBase(command)
	switch command {
	case "sh", "bash", "zsh", "fish", "nu", "xonsh", "dash", "ksh", "tcsh", "csh", "elvish":
		return true
	}
	if c.lookupEnv == nil {
		return false
	}
	return command != "" && command == filepathBase(c.lookupEnv("SHELL"))
}

func filepathBase(command string) string {
	if idx := strings.LastIndex(command, "/"); idx >= 0 {
		return command[idx+1:]
	}
	return command
}

// SessionExists reports whether the named tmux session already exists.
func (c *Client) SessionExists(ctx context.Context, sessionName string) (bool, error) {
	if strings.TrimSpace(sessionName) == "" {
		return false, errSessionNameRequired
	}

	return c.sessionExists(ctx, sessionName)
}

// OpenSession switches the current client when already inside tmux and attaches otherwise.
func (c *Client) OpenSession(ctx context.Context, sessionName string) error {
	if strings.TrimSpace(sessionName) == "" {
		return errSessionNameRequired
	}

	target := exactSessionTarget(sessionName)
	command := []string{"attach-session", "-t", target}
	action := "attach"
	inside := c.InsideSession()
	if inside {
		command = c.switchClientCommand(target)
		action = "switch"
	}

	if _, err := c.runner.Run(ctx, "tmux", command...); err != nil {
		return fmt.Errorf("%s tmux session %q: %w", action, sessionName, err)
	}

	if inside {
		c.runPostAttach(ctx, sessionName, target)
	}
	return nil
}

// OpenSessionTarget opens a stored preview target. Outside tmux, pane targeting
// degrades to session/window because attach-session cannot target a pane.
func (c *Client) OpenSessionTarget(ctx context.Context, sessionName, windowIndex, paneIndex string) error {
	sessionName = strings.TrimSpace(sessionName)
	windowIndex = strings.TrimSpace(windowIndex)
	paneIndex = strings.TrimSpace(paneIndex)

	if sessionName == "" {
		return errSessionNameRequired
	}
	if paneIndex != "" && windowIndex == "" {
		return errWindowIndexRequired
	}

	target := sessionName
	action := "attach"
	command := []string{"attach-session", "-t", target}
	inside := c.InsideSession()

	if inside {
		target = sessionPaneTarget(sessionName, windowIndex, paneIndex)
		action = "switch"
		command = c.switchClientCommand(target)
	} else if windowIndex != "" {
		target = sessionWindowTarget(sessionName, windowIndex)
		command = []string{"attach-session", "-t", target}
	}

	if _, err := c.runner.Run(ctx, "tmux", command...); err != nil {
		return fmt.Errorf("%s tmux target %q: %w", action, target, err)
	}

	if inside {
		c.runPostAttach(ctx, sessionName, target)
	}
	return nil
}

func (c *Client) switchClientCommand(target string) []string {
	command := []string{"switch-client"}
	if c.lookupEnv != nil {
		if client := strings.TrimSpace(c.lookupEnv(SwitchTargetClientEnv)); client != "" {
			command = append(command, "-c", client)
		}
	}
	return append(command, "-t", target)
}

func (c *Client) runPostAttach(ctx context.Context, sessionName, target string) {
	if c.lifecycle == nil {
		return
	}
	cwd := c.resolveTargetCWD(ctx, target)
	_, _ = c.lifecycle.Run(ctx, hooks.EventPostAttach, hooks.Context{
		SessionName: sessionName,
		CWD:         cwd,
		Socket:      c.socket,
	})
}

func (c *Client) resolveTargetCWD(ctx context.Context, target string) string {
	output, err := c.runner.Run(ctx, "tmux", "display-message", "-p", "-t", target, "-F", "#{pane_current_path}")
	if err != nil {
		return ""
	}
	return strings.TrimSpace(string(output))
}

func sortedMapKeys(values map[string]string) []string {
	keys := make([]string, 0, len(values))
	for key := range values {
		keys = append(keys, key)
	}
	sort.Strings(keys)
	return keys
}

// SwitchClient switches the active tmux client to the target session.
func (c *Client) SwitchClient(ctx context.Context, sessionName string) error {
	if strings.TrimSpace(sessionName) == "" {
		return errSessionNameRequired
	}

	if _, err := c.runner.Run(ctx, "tmux", "switch-client", "-t", exactSessionTarget(sessionName)); err != nil {
		return fmt.Errorf("switch tmux client to session %q: %w", sessionName, err)
	}

	return nil
}

// KillSession terminates the named tmux session.
func (c *Client) KillSession(ctx context.Context, sessionName string) error {
	if strings.TrimSpace(sessionName) == "" {
		return errSessionNameRequired
	}

	if _, err := c.runner.Run(ctx, "tmux", "kill-session", "-t", exactSessionTarget(sessionName)); err != nil {
		return fmt.Errorf("kill tmux session %q: %w", sessionName, err)
	}

	return nil
}

// DisplayPopup opens a tmux popup and executes the provided shell command.
func (c *Client) DisplayPopup(ctx context.Context, command string) error {
	return c.DisplayPopupWithOptions(ctx, command, PopupOptions{})
}

// DisplayPopupWithOptions opens a tmux popup and executes the provided shell command.
func (c *Client) DisplayPopupWithOptions(ctx context.Context, command string, options PopupOptions) error {
	args, err := BuildDisplayPopupArgs(command, options)
	if err != nil {
		return err
	}

	if _, err := c.runner.Run(ctx, "tmux", args...); err != nil {
		return fmt.Errorf("display tmux popup: %w", err)
	}

	return nil
}

// BuildDisplayPopupArgs maps structured popup options to tmux display-popup args.
func BuildDisplayPopupArgs(command string, options PopupOptions) ([]string, error) {
	return intmux.BuildDisplayPopupArgs(command, options)
}

// InsideSession reports whether the caller is already running inside tmux.
func (c *Client) InsideSession() bool {
	if c.lookupEnv == nil {
		return false
	}

	return strings.TrimSpace(c.lookupEnv("TMUX")) != ""
}

func (c *Client) sessionExists(ctx context.Context, sessionName string) (bool, error) {
	if _, err := c.runner.Run(ctx, "tmux", "has-session", "-t", exactSessionTarget(sessionName)); err != nil {
		if isExitCode(err, 1) {
			return false, nil
		}
		return false, fmt.Errorf("check tmux session %q: %w", sessionName, err)
	}

	return true, nil
}

func isExitCode(err error, code int) bool {
	var exitErr *exec.ExitError
	if !errors.As(err, &exitErr) {
		return false
	}

	return exitErr.ExitCode() == code
}

func parseEphemeralSessions(output []byte) ([]lifecycle.SessionInventory, error) {
	trimmed := strings.TrimSpace(string(output))
	if trimmed == "" {
		return nil, nil
	}

	lines := strings.Split(trimmed, "\n")
	sessions := make([]lifecycle.SessionInventory, 0, len(lines))
	for _, line := range lines {
		fields := strings.Split(line, "\t")
		if len(fields) == 3 {
			fields = append(fields, "")
		}
		if len(fields) != 4 {
			return nil, fmt.Errorf("parse ephemeral tmux sessions: malformed row %q", line)
		}

		name := strings.TrimSpace(fields[0])
		if name == "" {
			return nil, errSessionNameRequired
		}

		attached, err := parseAttachedFlag(fields[1])
		if err != nil {
			return nil, err
		}
		lastAttached, err := strconv.ParseInt(strings.TrimSpace(fields[2]), 10, 64)
		if err != nil {
			return nil, errSessionActivityInvalid
		}
		ephemeral, err := parseOptionalBinaryFlag(fields[3], errSessionEphemeralInvalid)
		if err != nil {
			return nil, err
		}

		sessions = append(sessions, lifecycle.SessionInventory{
			Name:         name,
			Attached:     attached,
			LastAttached: lastAttached,
			Ephemeral:    ephemeral,
		})
	}

	return sessions, nil
}

func parseBinaryFlag(value string, invalid error) (bool, error) {
	switch strings.TrimSpace(value) {
	case "0":
		return false, nil
	case "1":
		return true, nil
	default:
		return false, invalid
	}
}

func parseAttachedFlag(value string) (bool, error) {
	trimmed := strings.TrimSpace(value)
	count, err := strconv.Atoi(trimmed)
	if err != nil || count < 0 {
		return false, errSessionAttachedInvalid
	}

	return count > 0, nil
}

func parseOptionalBinaryFlag(value string, invalid error) (bool, error) {
	if strings.TrimSpace(value) == "" {
		return false, nil
	}

	return parseBinaryFlag(value, invalid)
}

// BuildPopupPreviewCommand builds the shell command used inside a tmux popup
// for the existing `projmux session-popup preview <session>` flow.
func BuildPopupPreviewCommand(binaryPath, sessionName string) (string, error) {
	binaryPath = strings.TrimSpace(binaryPath)
	if binaryPath == "" {
		return "", errors.New("popup preview binary path is required")
	}

	sessionName = strings.TrimSpace(sessionName)
	if sessionName == "" {
		return "", errSessionNameRequired
	}

	return buildExecCommand(binaryPath, "session-popup", "preview", sessionName), nil
}

// BuildPopupSwitchCommand builds the shell command used inside a tmux popup
// for the existing `projmux switch --ui=popup` flow.
func BuildPopupSwitchCommand(binaryPath, cwd string) (string, error) {
	binaryPath = strings.TrimSpace(binaryPath)
	if binaryPath == "" {
		return "", errors.New("popup switch binary path is required")
	}

	cwd = strings.TrimSpace(cwd)
	if cwd == "" {
		return "", errors.New("popup switch working directory is required")
	}

	return "cd -- " + shellQuote(cwd) + " && " + buildExecCommand(binaryPath, "switch", "--ui=popup"), nil
}

// BuildPopupSessionsCommand builds the shell command used inside a tmux popup
// for the existing `projmux sessions --ui=popup` flow.
func BuildPopupSessionsCommand(binaryPath string) (string, error) {
	binaryPath = strings.TrimSpace(binaryPath)
	if binaryPath == "" {
		return "", errors.New("popup sessions binary path is required")
	}

	return buildExecCommand(binaryPath, "sessions", "--ui=popup"), nil
}

// BuildSessionPopupPreviewCommand builds the shell command used by fzf preview
// panes for the existing `projmux session-popup preview {2}` flow.
func BuildSessionPopupPreviewCommand(binaryPath string) (string, error) {
	binaryPath = strings.TrimSpace(binaryPath)
	if binaryPath == "" {
		return "", errors.New("session popup preview binary path is required")
	}

	return buildExecCommand(binaryPath, "session-popup", "preview") + " {2}", nil
}

// BuildSessionPopupCycleCommand builds the shell command used by fzf bindings
// to move popup preview selection for the focused tmux session.
func BuildSessionPopupCycleCommand(binaryPath, subcommand, direction string) (string, error) {
	binaryPath = strings.TrimSpace(binaryPath)
	if binaryPath == "" {
		return "", errors.New("session popup cycle binary path is required")
	}

	subcommand = strings.TrimSpace(subcommand)
	if subcommand == "" {
		return "", errors.New("session popup cycle subcommand is required")
	}

	direction = strings.TrimSpace(direction)
	if direction == "" {
		return "", errors.New("session popup cycle direction is required")
	}

	return buildExecCommand(binaryPath, "session-popup", subcommand) + " {2} " + shellQuote(direction), nil
}

// BuildSwitchPreviewCommand builds the shell command used by fzf preview panes
// for the existing `projmux switch preview {2}` flow.
func BuildSwitchPreviewCommand(binaryPath, ui string) (string, error) {
	binaryPath = strings.TrimSpace(binaryPath)
	if binaryPath == "" {
		return "", errors.New("switch preview binary path is required")
	}

	ui = strings.TrimSpace(ui)
	if ui == "" {
		ui = "popup"
	}

	return buildExecCommand(binaryPath, "switch", "preview", "--ui="+ui) + " {2}", nil
}

// BuildSwitchCycleWindowCommand builds the shell command used by fzf bindings
// to move switch preview window selection for the focused candidate.
func BuildSwitchCycleWindowCommand(binaryPath, direction string) (string, error) {
	binaryPath = strings.TrimSpace(binaryPath)
	if binaryPath == "" {
		return "", errors.New("switch cycle window binary path is required")
	}

	direction = strings.TrimSpace(direction)
	if direction == "" {
		return "", errors.New("switch cycle window direction is required")
	}

	return buildExecCommand(binaryPath, "switch", "cycle-window") + " {2} " + shellQuote(direction), nil
}

// BuildSwitchCyclePaneCommand builds the shell command used by fzf bindings to
// move switch preview pane selection for the focused candidate.
func BuildSwitchCyclePaneCommand(binaryPath, direction string) (string, error) {
	binaryPath = strings.TrimSpace(binaryPath)
	if binaryPath == "" {
		return "", errors.New("switch cycle pane binary path is required")
	}

	direction = strings.TrimSpace(direction)
	if direction == "" {
		return "", errors.New("switch cycle pane direction is required")
	}

	return buildExecCommand(binaryPath, "switch", "cycle-pane") + " {2} " + shellQuote(direction), nil
}

// BuildSwitchSidebarFocusCommand builds the shell command used by fzf sidebar
// focus bindings to jump to an already-existing session for the focused path.
func BuildSwitchSidebarFocusCommand(binaryPath string) (string, error) {
	binaryPath = strings.TrimSpace(binaryPath)
	if binaryPath == "" {
		return "", errors.New("switch sidebar focus binary path is required")
	}

	return buildExecCommand(binaryPath, "switch", "sidebar-focus") + " {2}", nil
}

func buildExecCommand(binaryPath string, args ...string) string {
	quoted := make([]string, 0, len(args)+2)
	quoted = append(quoted, "exec", shellQuote(binaryPath))
	for _, arg := range args {
		quoted = append(quoted, shellQuote(arg))
	}
	return strings.Join(quoted, " ")
}

func shellQuote(value string) string {
	if value == "" {
		return "''"
	}
	return "'" + strings.ReplaceAll(value, "'", `'\''`) + "'"
}

func sessionWindowTarget(sessionName, windowIndex string) string {
	if strings.TrimSpace(windowIndex) == "" {
		return exactSessionTarget(sessionName)
	}

	return fmt.Sprintf("%s:%s", exactSessionTarget(sessionName), strings.TrimSpace(windowIndex))
}

func sessionPaneTarget(sessionName, windowIndex, paneIndex string) string {
	target := sessionWindowTarget(sessionName, windowIndex)
	if strings.TrimSpace(paneIndex) == "" {
		return target
	}

	return fmt.Sprintf("%s.%s", target, strings.TrimSpace(paneIndex))
}

func exactSessionTarget(sessionName string) string {
	sessionName = strings.TrimSpace(sessionName)
	if sessionName == "" || strings.HasPrefix(sessionName, "=") {
		return sessionName
	}
	return "=" + sessionName
}

func tmuxFormat(fields ...string) string {
	return strings.Join(fields, tmuxFieldSep)
}

type recentSession struct {
	name     string
	attached bool
	windows  int
	activity int64
	order    int
}

func parseRecentSessionRows(output []byte) ([]recentSession, error) {
	if strings.TrimSpace(string(output)) == "" {
		return nil, nil
	}

	lines := strings.Split(string(output), "\n")
	sessions := make([]recentSession, 0, len(lines))
	for index, rawLine := range lines {
		if strings.TrimSpace(rawLine) == "" {
			continue
		}

		fields := recentSessionFields(rawLine)
		if len(fields) != 4 {
			return nil, fmt.Errorf("parse recent tmux sessions: malformed row %q", rawLine)
		}

		activity, err := strconv.ParseInt(strings.TrimSpace(fields[0]), 10, 64)
		if err != nil {
			return nil, fmt.Errorf("parse recent tmux sessions for %q: %w", strings.TrimSpace(fields[1]), errSessionActivityInvalid)
		}
		attached, err := parseAttachedFlag(fields[2])
		if err != nil {
			return nil, fmt.Errorf("parse recent tmux sessions for %q: %w", strings.TrimSpace(fields[1]), err)
		}
		windows, err := strconv.Atoi(strings.TrimSpace(fields[3]))
		if err != nil {
			return nil, errWindowPaneCountInvalid
		}

		sessionName := strings.TrimSpace(fields[1])
		if sessionName == "" {
			return nil, fmt.Errorf("parse recent tmux sessions: %w", errSessionNameRequired)
		}

		sessions = append(sessions, recentSession{
			name:     sessionName,
			attached: attached,
			windows:  windows,
			activity: activity,
			order:    index,
		})
	}

	sort.SliceStable(sessions, func(i, j int) bool {
		if sessions[i].activity == sessions[j].activity {
			return sessions[i].order < sessions[j].order
		}
		return sessions[i].activity > sessions[j].activity
	})

	return sessions, nil
}

func recentSessionFields(rawLine string) []string {
	if fields := splitTmuxFields(rawLine, 4); len(fields) == 4 {
		return fields
	}

	// Some tmux/script combinations render literal tab separators as
	// underscores in `list-sessions -F` output. Session names can also contain
	// underscores, so preserve the first field and the final two fields, then
	// rejoin the middle as the session name.
	parts := strings.Split(rawLine, "_")
	if len(parts) < 4 {
		return parts
	}
	return []string{
		parts[0],
		strings.Join(parts[1:len(parts)-2], "_"),
		parts[len(parts)-2],
		parts[len(parts)-1],
	}
}

func splitTmuxFields(rawLine string, expected int) []string {
	for _, sep := range []string{tmuxFieldSep, tmuxEscapedFieldSep, "\t"} {
		fields := strings.SplitN(rawLine, sep, expected)
		if len(fields) == expected || len(fields) > 1 {
			return fields
		}
	}
	return strings.Split(rawLine, "\t")
}

func parseRecentSessions(output []byte) ([]string, error) {
	rows, err := parseRecentSessionRows(output)
	if err != nil {
		return nil, err
	}

	names := make([]string, 0, len(rows))
	for _, session := range rows {
		names = append(names, session.name)
	}

	return names, nil
}

func parseExistingSessionSet(output []byte) map[string]bool {
	sessions := map[string]bool{}
	for line := range strings.SplitSeq(string(output), "\n") {
		sessionName := strings.TrimSpace(line)
		if sessionName == "" {
			continue
		}
		sessions[sessionName] = true
	}
	return sessions
}

type paneSessionSummary struct {
	paneCount int
	path      string
}

func summarizePanesBySession(panes []Pane) map[string]paneSessionSummary {
	bySession := make(map[string]paneSessionSummary, len(panes))
	for _, pane := range panes {
		name := strings.TrimSpace(pane.SessionName)
		if name == "" {
			continue
		}

		summary := bySession[name]
		summary.paneCount++

		path := strings.TrimSpace(pane.Path)
		if pane.Active && path != "" {
			summary.path = path
		} else if summary.path == "" && path != "" {
			summary.path = path
		}

		bySession[name] = summary
	}

	return bySession
}

func parseSessionWindows(output []byte) ([]Window, error) {
	if strings.TrimSpace(string(output)) == "" {
		return nil, nil
	}

	lines := strings.Split(string(output), "\n")
	windows := make([]Window, 0, len(lines))
	for _, rawLine := range lines {
		if strings.TrimSpace(rawLine) == "" {
			continue
		}

		fields := splitTmuxFields(rawLine, 5)
		if len(fields) != 5 {
			return nil, fmt.Errorf("parse tmux windows: malformed row %q", rawLine)
		}

		index, err := strconv.Atoi(strings.TrimSpace(fields[0]))
		if err != nil {
			return nil, errWindowIndexInvalid
		}
		active, err := parseActiveFlag(fields[1])
		if err != nil {
			return nil, err
		}
		paneCount, err := strconv.Atoi(strings.TrimSpace(fields[3]))
		if err != nil {
			return nil, errWindowPaneCountInvalid
		}

		windows = append(windows, Window{
			Index:     index,
			Name:      strings.TrimSpace(fields[2]),
			PaneCount: paneCount,
			Path:      strings.TrimSpace(fields[4]),
			Active:    active,
		})
	}

	return windows, nil
}

func parseAllPanes(output []byte) ([]Pane, error) {
	if strings.TrimSpace(string(output)) == "" {
		return nil, nil
	}

	lines := strings.Split(string(output), "\n")
	panes := make([]Pane, 0, len(lines))
	for _, rawLine := range lines {
		if strings.TrimSpace(rawLine) == "" {
			continue
		}

		fields := normalizeAllPaneFields(splitTmuxFields(rawLine, 15))
		if len(fields) != 15 {
			return nil, fmt.Errorf("parse tmux panes: malformed row %q", rawLine)
		}

		sessionName := strings.TrimSpace(fields[0])
		if sessionName == "" {
			return nil, errSessionNameRequired
		}

		windowIndex, err := strconv.Atoi(strings.TrimSpace(fields[2]))
		if err != nil {
			return nil, errWindowIndexInvalid
		}
		paneIndex, err := strconv.Atoi(strings.TrimSpace(fields[3]))
		if err != nil {
			return nil, errPaneIndexInvalid
		}
		active, err := parseActiveFlag(fields[4])
		if err != nil {
			return nil, err
		}

		panes = append(panes, Pane{
			ID:                  strings.TrimSpace(fields[1]),
			SessionName:         sessionName,
			WindowIndex:         windowIndex,
			PaneIndex:           paneIndex,
			Title:               strings.TrimSpace(fields[5]),
			AttentionState:      strings.TrimSpace(fields[6]),
			AIState:             strings.TrimSpace(fields[7]),
			AIBadgeKind:         strings.TrimSpace(fields[8]),
			AIAgent:             strings.TrimSpace(fields[9]),
			AITopic:             strings.TrimSpace(fields[10]),
			AttentionAck:        strings.TrimSpace(fields[11]),
			AttentionFocusArmed: strings.TrimSpace(fields[12]),
			Command:             strings.TrimSpace(fields[13]),
			Path:                strings.TrimSpace(fields[14]),
			Active:              active,
		})
	}

	return panes, nil
}

func normalizeAllPaneFields(fields []string) []string {
	switch len(fields) {
	case 8:
		fields = append(fields[:6], append([]string{""}, fields[6:]...)...)
		fallthrough
	case 9:
		return append(fields[:7], append([]string{"", "", "", "", "", ""}, fields[7:]...)...)
	case 14:
		return append(fields[:8], append([]string{""}, fields[8:]...)...)
	default:
		return fields
	}
}

func parseWindowPanes(output []byte) ([]WindowPane, error) {
	if strings.TrimSpace(string(output)) == "" {
		return nil, nil
	}

	lines := strings.Split(string(output), "\n")
	panes := make([]WindowPane, 0, len(lines))
	for _, rawLine := range lines {
		if strings.TrimSpace(rawLine) == "" {
			continue
		}

		fields := splitTmuxFields(rawLine, 2)
		if len(fields) != 2 {
			return nil, fmt.Errorf("parse tmux window panes: malformed row %q", rawLine)
		}

		index, err := strconv.Atoi(strings.TrimSpace(fields[0]))
		if err != nil {
			return nil, errPaneIndexInvalid
		}
		active, err := parseActiveFlag(fields[1])
		if err != nil {
			return nil, err
		}

		panes = append(panes, WindowPane{
			Index:  index,
			Active: active,
		})
	}

	return panes, nil
}

func parseActiveFlag(raw string) (bool, error) {
	switch strings.TrimSpace(raw) {
	case "0":
		return false, nil
	case "1":
		return true, nil
	default:
		return false, errActiveFlagInvalid
	}
}
