package tmux

import (
	"context"
	"fmt"
	"hash/fnv"
	"log/slog"
	"os/exec"
	"path/filepath"
	"strings"
	"sync"
	"time"

	"github.com/chenhg5/cc-connect/core"
)

func init() {
	core.RegisterAgent("tmux", New)
}

// Agent drives a persistent tmux pane as an interactive shell agent.
// User messages are sent as keystrokes; output is captured by polling capture-pane.
type Agent struct {
	sessionName     string
	pane            string
	workDir         string
	autoCreate      bool
	shell           string
	initCmd         string // command to run once after a new session is created (e.g. "claude")
	startupWaitMs   int    // milliseconds to wait after init_command before accepting messages
	promptPat       string
	pollMs          int
	stripInputBlock bool     // strip the ───/❯/─── input area block from output
	stripPatterns   []string // per-line regex patterns to strip from output
	// windowPerSession, when true, gives each cc-connect session its own tmux
	// window (and thus its own init_command/agent instance) instead of sharing
	// the single session:pane target. Required for true per-session isolation
	// (e.g. session_scope = "thread" on the platform).
	windowPerSession bool
	mu               sync.RWMutex
}

func New(opts map[string]any) (core.Agent, error) {
	sessionName, _ := opts["session"].(string)
	if sessionName == "" {
		return nil, fmt.Errorf("tmux: 'session' option is required (name of the tmux session to attach)")
	}

	pane, _ := opts["pane"].(string)
	if pane == "" {
		pane = "0"
	}

	workDir, _ := opts["work_dir"].(string)
	if workDir == "" {
		workDir = "."
	}

	autoCreate := true
	if v, ok := opts["auto_create"].(bool); ok {
		autoCreate = v
	}

	shell, _ := opts["shell"].(string)
	initCmd, _ := opts["init_command"].(string)

	startupWaitMs := 0
	switch v := opts["startup_wait_ms"].(type) {
	case int64:
		startupWaitMs = int(v)
	case int:
		startupWaitMs = v
	case float64:
		startupWaitMs = int(v)
	}
	if startupWaitMs == 0 && initCmd != "" {
		startupWaitMs = 2000 // default: give the init process 2s to start
	}

	promptPat, _ := opts["prompt_pattern"].(string)
	if promptPat == "" {
		// Matches common shell prompts and Claude Code's ❯ prompt.
		promptPat = `[❯\$#>%]\s*$`
	}

	pollMs := 200
	switch v := opts["poll_interval_ms"].(type) {
	case int64:
		if v > 0 {
			pollMs = int(v)
		}
	case int:
		if v > 0 {
			pollMs = v
		}
	case float64:
		if v > 0 {
			pollMs = int(v)
		}
	}

	stripInputBlock := true
	if v, ok := opts["strip_input_block"].(bool); ok {
		stripInputBlock = v
	}

	var stripPatterns []string
	switch v := opts["strip_patterns"].(type) {
	case []string:
		stripPatterns = v
	case []any:
		for _, item := range v {
			if s, ok := item.(string); ok {
				stripPatterns = append(stripPatterns, s)
			}
		}
	case nil:
		// default: strip Claude Code's mode status line
		stripPatterns = []string{`⏵⏵.*\(shift\+tab to cycle\)`}
	}

	windowPerSession, _ := opts["window_per_session"].(bool)

	if _, err := exec.LookPath("tmux"); err != nil {
		return nil, fmt.Errorf("tmux: 'tmux' not found in PATH")
	}

	return &Agent{
		sessionName:      sessionName,
		pane:             pane,
		workDir:          workDir,
		autoCreate:       autoCreate,
		shell:            shell,
		initCmd:          initCmd,
		startupWaitMs:    startupWaitMs,
		promptPat:        promptPat,
		pollMs:           pollMs,
		stripInputBlock:  stripInputBlock,
		stripPatterns:    stripPatterns,
		windowPerSession: windowPerSession,
	}, nil
}

func (a *Agent) Name() string { return "tmux" }

func (a *Agent) StartSession(ctx context.Context, sessionID string) (core.AgentSession, error) {
	a.mu.RLock()
	sessionName := a.sessionName
	pane := a.pane
	workDir := a.workDir
	autoCreate := a.autoCreate
	shell := a.shell
	initCmd := a.initCmd
	startupWaitMs := a.startupWaitMs
	promptPat := a.promptPat
	pollMs := a.pollMs
	stripInputBlock := a.stripInputBlock
	stripPatterns := a.stripPatterns
	a.mu.RUnlock()

	// Decide which tmux window this session drives:
	//   - window_per_session: a dedicated window per cc-connect session (true
	//     isolation; each gets its own init_command/agent instance).
	//   - else, when workDir is a real path: a window named after the directory
	//     so each workspace gets its own instance.
	//   - else: the legacy session:pane target.
	// windowForSession may allocate a fresh window name when sessionID is empty;
	// the returned ID is persisted by the engine so resumes reuse the same window.
	windowName, effectiveSessionID := a.windowForSession(sessionName, pane, workDir, sessionID)
	sessionID = effectiveSessionID
	target := sessionName + ":" + windowName

	sessionExists := tmuxSessionExists(sessionName)
	windowExists := sessionExists && tmuxWindowExists(target)

	if !sessionExists {
		if !autoCreate {
			return nil, fmt.Errorf("tmux: session %q does not exist and auto_create is disabled", sessionName)
		}
		if err := createTmuxSession(sessionName, windowName, workDir, shell); err != nil {
			return nil, fmt.Errorf("tmux: create session %q: %w", sessionName, err)
		}
		slog.Info("tmux: created session", "session", sessionName, "window", windowName, "work_dir", workDir)
	} else if !windowExists && windowName != pane {
		// Session exists but this workspace window does not yet — create it.
		if err := createTmuxWindow(sessionName, windowName, workDir); err != nil {
			return nil, fmt.Errorf("tmux: create window %q in session %q: %w", windowName, sessionName, err)
		}
		slog.Info("tmux: created window", "session", sessionName, "window", windowName, "work_dir", workDir)
	}

	newPane := !sessionExists || (!windowExists && windowName != pane)
	if newPane {
		// Always cd to the workspace directory so the shell (and any init_command)
		// starts in the right place, regardless of tmux's -c flag behaviour.
		if workDir != "" && workDir != "." {
			if err := sendKeys(target, "cd "+shellQuote(workDir)); err != nil {
				slog.Warn("tmux: cd failed", "work_dir", workDir, "err", err)
			}
		}
		if initCmd != "" {
			if err := sendKeys(target, initCmd); err != nil {
				slog.Warn("tmux: init_command failed", "cmd", initCmd, "err", err)
			} else {
				slog.Info("tmux: init_command sent", "cmd", initCmd, "target", target)
			}
			if startupWaitMs > 0 {
				select {
				case <-ctx.Done():
					return nil, ctx.Err()
				case <-time.After(time.Duration(startupWaitMs) * time.Millisecond):
				}
			}
		}
	}

	return newTmuxSession(ctx, target, sessionID, promptPat, time.Duration(pollMs)*time.Millisecond, stripInputBlock, stripPatterns, workDir)
}

// resolveTarget returns the tmux target string and the window name for the given workDir.
// When workDir is a real path the window is named with a hash suffix derived from the
// full path so that two workDirs sharing the same basename (e.g. /a/app and /b/app)
// never collide into the same tmux window.  When workDir is "." or empty the legacy
// session:pane target is returned and windowName == pane.
func (a *Agent) resolveTarget(sessionName, pane, workDir string) (target, windowName string) {
	if workDir != "" && workDir != "." {
		windowName = uniqueWindowName(workDir)
		return sessionName + ":" + windowName, windowName
	}
	return sessionName + ":" + pane, pane
}

// windowForSession returns the tmux window name to use for a StartSession call
// and the effective agent session ID. When window_per_session is enabled, each
// cc-connect session gets its own window: an existing (resumed) sessionID maps
// back to its window, while an empty sessionID allocates a fresh window whose
// name doubles as the agent session ID (persisted by the engine for resumes).
// When disabled, behaviour is unchanged (per-workDir window or legacy pane).
func (a *Agent) windowForSession(sessionName, pane, workDir, sessionID string) (windowName, effectiveID string) {
	if a.windowPerSession {
		if sessionID != "" {
			return sessionID, sessionID
		}
		name := allocateWindowName(sessionName)
		return name, name
	}
	_, windowName = a.resolveTarget(sessionName, pane, workDir)
	return windowName, sessionID
}

// allocateWindowName returns a fresh window name not currently present in the
// tmux session. The name is randomized (see below) so it never repeats across
// process restarts or collides across the per-workspace Agent instances that
// share a single tmux session in multi-workspace mode.
func allocateWindowName(sessionName string) string {
	for {
		// Use a random suffix rather than a counter: counters reset to 0 on
		// process restart and could re-hand-out "cc-1" to a brand-new
		// conversation, which would then collide with an older session whose
		// persisted agent ID is still "cc-1" (mixing two conversations into one
		// window). A random name does not repeat across restarts; the existence
		// check guards against the (astronomically rare) live collision.
		name := "cc-" + core.GenerateToken(4)
		if !tmuxWindowExists(sessionName + ":" + name) {
			return name
		}
	}
}

// uniqueWindowName builds a tmux window name that is unique per workDir path.
// It appends a 4-hex-char FNV hash of the full path so that directories with
// the same basename do not collide (e.g. /repo/a/app → "app-1a2b",
// /repo/b/app → "app-3c4d").
func uniqueWindowName(workDir string) string {
	base := sanitizeWindowName(filepath.Base(workDir))
	h := fnv.New32a()
	_, _ = h.Write([]byte(workDir))
	return fmt.Sprintf("%s-%04x", base, h.Sum32()&0xffff)
}

// sanitizeWindowName makes a string safe to use as a tmux window name.
func sanitizeWindowName(name string) string {
	name = strings.NewReplacer(":", "-", ".", "-", " ", "-").Replace(name)
	if name == "" {
		return "default"
	}
	return name
}

func (a *Agent) ListSessions(_ context.Context) ([]core.AgentSessionInfo, error) {
	return nil, nil
}

func (a *Agent) Stop() error { return nil }

// WorkspaceAgentOptions implements core.WorkspaceAgentOptionSnapshotter so the
// engine can seed per-workspace agent instances with tmux-specific options
// (session name, strip patterns, etc.) in multi-workspace mode.
// work_dir is intentionally omitted; the engine sets it.
func (a *Agent) WorkspaceAgentOptions() map[string]any {
	a.mu.RLock()
	defer a.mu.RUnlock()
	return map[string]any{
		"session":            a.sessionName,
		"pane":               a.pane,
		"auto_create":        a.autoCreate,
		"shell":              a.shell,
		"init_command":       a.initCmd,
		"startup_wait_ms":    a.startupWaitMs,
		"prompt_pattern":     a.promptPat,
		"poll_interval_ms":   a.pollMs,
		"strip_input_block":  a.stripInputBlock,
		"strip_patterns":     a.stripPatterns,
		"window_per_session": a.windowPerSession,
	}
}

func (a *Agent) SetWorkDir(dir string) {
	a.mu.Lock()
	defer a.mu.Unlock()
	a.workDir = dir
	slog.Info("tmux: work_dir changed", "work_dir", dir)
}

func (a *Agent) GetWorkDir() string {
	a.mu.RLock()
	defer a.mu.RUnlock()
	return a.workDir
}
