package webterm

import (
	"bufio"
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"net"
	"net/http"
	"net/http/httptest"
	"os"
	"os/exec"
	"path/filepath"
	"reflect"
	"strings"
	"sync"
	"sync/atomic"
	"testing"
	"time"

	"github.com/rcarmo/webterm/internal/terminalstate"
)

func newUnixHTTPTestServer(t *testing.T, handler http.Handler) (string, func()) {
	t.Helper()
	socket := filepath.Join(t.TempDir(), "docker.sock")
	ln, err := net.Listen("unix", socket)
	if err != nil {
		t.Fatalf("listen unix socket: %v", err)
	}
	srv := &http.Server{Handler: handler}
	go func() { _ = srv.Serve(ln) }()
	cleanup := func() {
		ctx, cancel := context.WithTimeout(context.Background(), time.Second)
		defer cancel()
		_ = srv.Shutdown(ctx)
		_ = ln.Close()
		_ = os.Remove(socket)
	}
	return socket, cleanup
}

func TestDockerSocketPathVariants(t *testing.T) {
	t.Setenv(DockerHostEnv, "")
	if got := DockerSocketPath(); got != defaultDockerSocket {
		t.Fatalf("empty %s: got %q", DockerHostEnv, got)
	}

	t.Setenv(DockerHostEnv, "unix:///tmp/docker.sock")
	if got := DockerSocketPath(); got != "/tmp/docker.sock" {
		t.Fatalf("unix:// host: got %q", got)
	}

	t.Setenv(DockerHostEnv, "/tmp/direct.sock")
	if got := DockerSocketPath(); got != "/tmp/direct.sock" {
		t.Fatalf("absolute host: got %q", got)
	}

	t.Setenv(DockerHostEnv, "tcp://127.0.0.1:2375")
	if got := DockerSocketPath(); got != defaultDockerSocket {
		t.Fatalf("unsupported host should fallback: got %q", got)
	}
}

func TestUnixJSONRequestAndSharedClient(t *testing.T) {
	sharedClientsMu.Lock()
	sharedClients = map[string]*http.Client{}
	sharedClientsMu.Unlock()

	handler := http.NewServeMux()
	handler.HandleFunc("/ping", func(w http.ResponseWriter, r *http.Request) {
		_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "method": r.Method})
	})
	handler.HandleFunc("/echo", func(w http.ResponseWriter, r *http.Request) {
		var payload map[string]any
		_ = json.NewDecoder(r.Body).Decode(&payload)
		_ = json.NewEncoder(w).Encode(payload)
	})
	socket, cleanup := newUnixHTTPTestServer(t, handler)
	defer cleanup()

	status, body, err := unixJSONRequest(socket, http.MethodGet, "/ping", nil)
	if err != nil || status != http.StatusOK {
		t.Fatalf("unixJSONRequest GET: status=%d err=%v", status, err)
	}
	if !strings.Contains(string(body), `"ok":true`) {
		t.Fatalf("unexpected GET body: %s", string(body))
	}

	status, body, err = unixJSONRequest(socket, http.MethodPost, "/echo", map[string]any{"x": 1})
	if err != nil || status != http.StatusOK {
		t.Fatalf("unixJSONRequest POST: status=%d err=%v", status, err)
	}
	if !strings.Contains(string(body), `"x":1`) {
		t.Fatalf("unexpected POST body: %s", string(body))
	}

	c1 := sharedUnixClient(socket)
	c2 := sharedUnixClient(socket)
	if c1 != c2 {
		t.Fatalf("sharedUnixClient should cache by socket path")
	}
}

func TestNoopConnectorMethods(t *testing.T) {
	var c noopConnector
	c.OnData([]byte("x"))
	c.OnBinary([]byte("y"))
	c.OnMeta(map[string]any{"k": "v"})
	c.OnClose()
}

func TestSessionManagerAPIsAndClosePaths(t *testing.T) {
	manager := NewSessionManager([]App{{Name: "Shell", Slug: "shell", Command: "/bin/sh", Terminal: true}})
	manager.SetSessionFactory(func(app App, sessionID string) Session { return newFakeSession() })

	apps := manager.Apps()
	if len(apps) != 1 || apps[0].Slug != "shell" {
		t.Fatalf("unexpected apps: %+v", apps)
	}
	if app, ok := manager.GetDefaultApp(); !ok || app.Slug != "shell" {
		t.Fatalf("GetDefaultApp failed: app=%+v ok=%v", app, ok)
	}

	session, err := manager.NewSession("shell", "sid-1", "route-1", 80, 24)
	if err != nil || session == nil {
		t.Fatalf("NewSession failed: %v", err)
	}
	route, running, ok := manager.GetFirstRunningSession()
	if !ok || route != "route-1" || running == nil {
		t.Fatalf("GetFirstRunningSession failed: route=%q ok=%v", route, ok)
	}

	manager.CloseSession("sid-1")
	if manager.GetSessionByRouteKey("route-1") != nil {
		t.Fatalf("CloseSession should remove route mapping")
	}

	_, _ = manager.NewSession("shell", "sid-2", "route-2", 80, 24)
	_, _ = manager.NewSession("shell", "sid-3", "route-3", 80, 24)
	manager.CloseAll()
	if s := manager.GetSession("sid-2"); s != nil {
		t.Fatalf("CloseAll should remove session sid-2")
	}
	if s := manager.GetSession("sid-3"); s != nil {
		t.Fatalf("CloseAll should remove session sid-3")
	}
	if manager.GetSessionByRouteKey("route-2") != nil || manager.GetSessionByRouteKey("route-3") != nil {
		t.Fatalf("CloseAll should remove route mappings")
	}
}

func TestLocalClientConnectorAndHelpers(t *testing.T) {
	server := NewLocalServer(Config{}, ServerOptions{})
	connector := &localClientConnector{server: server, sessionID: "sid", routeKey: "rk"}
	connector.OnData([]byte("abc"))
	connector.OnBinary([]byte("def"))
	connector.OnMeta(map[string]any{"x": 1})
	connector.OnClose()

	if got := toIntFromQuery("42", 7); got != 42 {
		t.Fatalf("toIntFromQuery valid: got %d", got)
	}
	if got := toIntFromQuery("not-a-number", 7); got != 7 {
		t.Fatalf("toIntFromQuery fallback: got %d", got)
	}
}

func TestHandleEventsDisconnect(t *testing.T) {
	server := NewLocalServer(Config{}, ServerOptions{})
	ctx, cancel := context.WithCancel(context.Background())
	req := httptest.NewRequest(http.MethodGet, "/events", nil).WithContext(ctx)
	w := httptest.NewRecorder()

	done := make(chan struct{})
	go func() {
		server.handleEvents(w, req)
		close(done)
	}()
	time.Sleep(20 * time.Millisecond)
	cancel()
	select {
	case <-done:
	case <-time.After(2 * time.Second):
		t.Fatalf("handleEvents did not exit on context cancellation")
	}
}

func TestRunWithCanceledContext(t *testing.T) {
	server := NewLocalServer(Config{}, ServerOptions{Host: "127.0.0.1", Port: 0})
	ctx, cancel := context.WithCancel(context.Background())
	cancel()
	if err := server.Run(ctx); err != nil {
		t.Fatalf("Run with canceled context failed: %v", err)
	}
}

func TestDockerWatcherHelpersAndContainerLifecycle(t *testing.T) {
	manager := NewSessionManager(nil)
	manager.SetSessionFactory(func(app App, sessionID string) Session { return newFakeSession() })

	var addedCount atomic.Int32
	var removedCount atomic.Int32
	watcher := NewDockerWatcher(
		manager,
		"/tmp/does-not-exist.sock",
		func(slug, name, command string) { addedCount.Add(1) },
		func(slug string) { removedCount.Add(1) },
	)

	if !hasWebtermLabel(map[string]string{WebtermLabelName: "auto"}) {
		t.Fatalf("expected command label to match")
	}
	if !hasWebtermLabel(map[string]string{WebtermThemeLabel: "nord"}) {
		t.Fatalf("expected theme label to match")
	}
	if hasWebtermLabel(map[string]string{"other": "x"}) {
		t.Fatalf("unexpected label match")
	}
	if !isAutoLabel("") || !isAutoLabel("auto") || !isAutoLabel(" AUTO ") {
		t.Fatalf("expected auto labels")
	}
	if isAutoLabel("bash") {
		t.Fatalf("non-auto label incorrectly matched")
	}

	container := map[string]any{
		"Id":    "123456789012",
		"Names": []any{"/my_container.1"},
		"Labels": map[string]any{
			WebtermLabelName:  "auto",
			WebtermThemeLabel: "nord",
		},
	}
	watcher.addContainer(container)
	watcher.addContainer(container) // duplicate should be ignored

	slug := watcher.containerToSlug(container)
	if app, ok := manager.AppBySlug(slug); !ok || app.Theme != "nord" || app.Command != AutoCommandSentinel {
		t.Fatalf("added app mismatch: app=%+v ok=%v", app, ok)
	}
	if addedCount.Load() != 1 {
		t.Fatalf("expected exactly one add callback, got %d", addedCount.Load())
	}

	manager.sessions["sid"] = newFakeSession()
	_ = manager.routes.Set(slug, "sid")
	watcher.removeContainer("1234567")
	if _, ok := manager.AppBySlug(slug); ok {
		t.Fatalf("removeContainer should remove app %q", slug)
	}
	if removedCount.Load() != 1 {
		t.Fatalf("expected remove callback")
	}
}

func TestDockerWatcherListAndHandleEvent(t *testing.T) {
	manager := NewSessionManager(nil)
	watcher := NewDockerWatcher(manager, "", nil, nil)

	handler := http.NewServeMux()
	handler.HandleFunc("/containers/json", func(w http.ResponseWriter, r *http.Request) {
		containers := []map[string]any{
			{
				"Id":    "abc123",
				"Names": []any{"/svc1"},
				"Labels": map[string]any{
					WebtermLabelName: "auto",
				},
			},
		}
		_ = json.NewEncoder(w).Encode(containers)
	})
	handler.HandleFunc("/containers/evt123/json", func(w http.ResponseWriter, r *http.Request) {
		_ = json.NewEncoder(w).Encode(map[string]any{
			"Name": "/evt_container",
			"Config": map[string]any{
				"Labels": map[string]any{
					WebtermLabelName: "auto",
				},
			},
		})
	})
	socket, cleanup := newUnixHTTPTestServer(t, handler)
	defer cleanup()
	watcher.socketPath = socket

	containers, err := watcher.listLabeledContainers()
	if err != nil {
		t.Fatalf("listLabeledContainers error: %v", err)
	}
	if len(containers) != 1 || asString(containers[0]["Id"]) != "abc123" {
		t.Fatalf("unexpected containers payload: %+v", containers)
	}

	watcher.handleEvent(map[string]any{
		"Action": "start",
		"Actor":  map[string]any{"ID": "evt123"},
	})
	if _, ok := manager.AppBySlug("evt-container"); !ok {
		t.Fatalf("start event should add app")
	}
	watcher.handleEvent(map[string]any{
		"Action": "die",
		"Actor":  map[string]any{"ID": "evt123"},
	})
	if _, ok := manager.AppBySlug("evt-container"); ok {
		t.Fatalf("die event should remove app")
	}
}

func TestDockerStatsCollectorLifecycleAndPolling(t *testing.T) {
	handler := http.NewServeMux()
	handler.HandleFunc("/_ping", func(w http.ResponseWriter, r *http.Request) {
		_, _ = w.Write([]byte("OK"))
	})
	handler.HandleFunc("/containers/json", func(w http.ResponseWriter, r *http.Request) {
		_ = json.NewEncoder(w).Encode([]map[string]any{
			{
				"Id":    "deadbeefcafebabe",
				"Names": []any{"/svc"},
				"Labels": map[string]any{
					"com.docker.compose.project": "proj",
					"com.docker.compose.service": "svc",
				},
			},
		})
	})
	handler.HandleFunc("/containers/deadbeefcafe/stats", func(w http.ResponseWriter, r *http.Request) {
		_ = json.NewEncoder(w).Encode(map[string]any{
			"cpu_stats": map[string]any{
				"cpu_usage":        map[string]any{"total_usage": 200.0, "percpu_usage": []any{1.0, 2.0}},
				"system_cpu_usage": 400.0,
				"online_cpus":      2.0,
			},
			"precpu_stats": map[string]any{
				"cpu_usage":        map[string]any{"total_usage": 100.0},
				"system_cpu_usage": 200.0,
			},
		})
	})
	socket, cleanup := newUnixHTTPTestServer(t, handler)
	defer cleanup()

	collector := NewDockerStatsCollector(socket, "proj")
	if !collector.Available() {
		t.Fatalf("collector should be available against test unix socket")
	}

	collector.AddService("svc")
	collector.AddService("svc") // no duplicate
	collector.AddService("other")
	collector.RemoveService("other")
	if got := collector.serviceList; !reflect.DeepEqual(got, []string{"svc"}) {
		t.Fatalf("unexpected service list: %+v", got)
	}

	mapping := collector.discoverContainers([]string{"svc"})
	if mapping["svc"] != "deadbeefcafe" {
		t.Fatalf("unexpected mapping: %+v", mapping)
	}

	collector.pollContainer("svc", "deadbeefcafe")
	history := collector.GetCPUHistory("svc")
	if len(history) != 1 || history[0] <= 0 {
		t.Fatalf("expected one positive CPU sample, got %+v", history)
	}

	collector.Start([]string{"svc"})
	time.Sleep(20 * time.Millisecond)
	collector.Stop()
}

type recorderConnector struct {
	mu     sync.Mutex
	data   [][]byte
	closed bool
}

func (r *recorderConnector) OnData(data []byte) {
	r.mu.Lock()
	defer r.mu.Unlock()
	r.data = append(r.data, append([]byte{}, data...))
}

func (r *recorderConnector) OnBinary(payload []byte)    { r.OnData(payload) }
func (r *recorderConnector) OnMeta(meta map[string]any) {}
func (r *recorderConnector) OnClose() {
	r.mu.Lock()
	defer r.mu.Unlock()
	r.closed = true
}

func TestTerminalSessionMethodsAndReadLoop(t *testing.T) {
	s := NewTerminalSession("sid", "unterminated '")
	if err := s.Open(80, 24); err == nil {
		t.Fatalf("expected shlex parse error")
	}
	if err := s.Start(&recorderConnector{}); err == nil {
		t.Fatalf("expected Start error when session is not open")
	}

	conn := &recorderConnector{}
	s.UpdateConnector(conn)
	s.tracker = terminalstate.NewTracker(80, 24)
	s.handleOutput([]byte("abc"))
	if got := string(s.GetReplayBuffer()); got != "abc" {
		t.Fatalf("unexpected replay buffer: %q", got)
	}

	pipeR, pipeW, err := os.Pipe()
	if err != nil {
		t.Fatalf("pipe: %v", err)
	}
	defer pipeR.Close()
	defer pipeW.Close()
	s.ptyFile = pipeW
	if ok := s.SendBytes([]byte("x")); !ok {
		t.Fatalf("SendBytes should succeed with writable ptyFile")
	}
	buf := make([]byte, 1)
	if _, err := pipeR.Read(buf); err != nil || string(buf) != "x" {
		t.Fatalf("pipe read failed: %v %q", err, string(buf))
	}

	s.ptyFile = nil
	if err := s.SetTerminalSize(80, 24); err == nil {
		t.Fatalf("expected SetTerminalSize error when closed")
	}
	if err := s.ForceRedraw(); err == nil {
		t.Fatalf("expected ForceRedraw error when closed")
	}
	if !s.SendMeta(map[string]any{"k": "v"}) {
		t.Fatalf("SendMeta should return true")
	}
	if s.IsRunning() {
		t.Fatalf("new session should not be running")
	}

	s.width, s.height = 10, 2
	s.tracker = nil
	snapshot := s.GetScreenSnapshot()
	if snapshot.Width != 10 || snapshot.Height != 2 {
		t.Fatalf("unexpected snapshot dimensions: %dx%d", snapshot.Width, snapshot.Height)
	}

	reader, writer, err := os.Pipe()
	if err != nil {
		t.Fatalf("pipe: %v", err)
	}
	defer reader.Close()
	s2 := NewTerminalSession("sid2", "/bin/sh")
	rc := &recorderConnector{}
	s2.connector = rc
	s2.tracker = terminalstate.NewTracker(80, 24)
	s2.running = true
	go s2.readLoop(reader)
	_, _ = writer.Write([]byte("hello"))
	_ = writer.Close()
	if err := s2.Wait(); err != nil {
		t.Fatalf("unexpected wait error: %v", err)
	}
	rc.mu.Lock()
	defer rc.mu.Unlock()
	if !rc.closed || len(rc.data) == 0 {
		t.Fatalf("expected readLoop to forward data and close connector")
	}

	cmd := exec.Command("true")
	s3 := NewTerminalSession("sid3", "/bin/sh")
	s3.cmd = cmd
	_ = s3.Close()
}

func TestDockerExecSessionMethodsAndAPI(t *testing.T) {
	spec := DockerExecSpec{Container: "my/container", Command: []string{"sh", "-lc", "echo hi"}, User: "root"}
	s := NewDockerExecSession("sid", spec, "/tmp/none.sock")

	if err := s.Start(&recorderConnector{}); err == nil {
		t.Fatalf("expected Start error when not open")
	}
	if s.SendBytes([]byte("x")) {
		t.Fatalf("SendBytes should fail when conn is nil")
	}
	if !s.SendMeta(map[string]any{"k": "v"}) {
		t.Fatalf("SendMeta should return true")
	}
	if s.IsRunning() {
		t.Fatalf("new DockerExecSession should not be running")
	}

	s.tracker = terminalstate.NewTracker(80, 24)
	conn := &recorderConnector{}
	s.UpdateConnector(conn)
	s.handleOutput([]byte("abc"))
	if got := string(s.GetReplayBuffer()); got != "abc" {
		t.Fatalf("unexpected replay: %q", got)
	}

	c1, c2 := net.Pipe()
	defer c2.Close()
	s.conn = c1
	readCh := make(chan []byte, 1)
	go func() {
		buf := make([]byte, 1)
		if _, err := io.ReadFull(c2, buf); err != nil {
			readCh <- nil
			return
		}
		readCh <- buf
	}()
	if !s.SendBytes([]byte("z")) {
		t.Fatalf("SendBytes should succeed with active conn")
	}
	select {
	case read := <-readCh:
		if string(read) != "z" {
			t.Fatalf("pipe read mismatch: %q", string(read))
		}
	case <-time.After(2 * time.Second):
		t.Fatalf("timed out waiting for net.Pipe read")
	}

	s.execID = ""
	if err := s.SetTerminalSize(100, 40); err != nil {
		t.Fatalf("SetTerminalSize with empty execID should not error: %v", err)
	}
	if err := s.ForceRedraw(); err != nil {
		t.Fatalf("ForceRedraw failed: %v", err)
	}

	s.width, s.height = 10, 2
	s.tracker = nil
	snap := s.GetScreenSnapshot()
	if snap.Width != 10 || snap.Height != 2 {
		t.Fatalf("unexpected snapshot dimensions: %dx%d", snap.Width, snap.Height)
	}

	r1, r2 := net.Pipe()
	defer r2.Close()
	s2 := NewDockerExecSession("sid2", spec, "/tmp/none.sock")
	rc := &recorderConnector{}
	s2.connector = rc
	s2.tracker = terminalstate.NewTracker(80, 24)
	s2.running = true
	go s2.readLoop(r1)
	_, _ = r2.Write([]byte("hello"))
	_ = r2.Close()
	if err := s2.Wait(); err != nil {
		t.Fatalf("unexpected wait error: %v", err)
	}
	rc.mu.Lock()
	defer rc.mu.Unlock()
	if !rc.closed || len(rc.data) == 0 {
		t.Fatalf("expected readLoop to forward data and close connector")
	}

	// API-level tests for createExec/startExecSocket/resizeExec
	mux := http.NewServeMux()
	mux.HandleFunc("/containers/", func(w http.ResponseWriter, r *http.Request) {
		if !strings.HasSuffix(r.URL.Path, "/exec") {
			http.NotFound(w, r)
			return
		}
		_ = json.NewEncoder(w).Encode(map[string]any{"Id": "exec123"})
	})
	mux.HandleFunc("/exec/exec123/resize", func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(http.StatusOK)
		_, _ = io.WriteString(w, "ok")
	})
	mux.HandleFunc("/exec/exec123/start", func(w http.ResponseWriter, r *http.Request) {
		hj, ok := w.(http.Hijacker)
		if !ok {
			t.Fatalf("response writer is not a hijacker")
		}
		conn, rw, err := hj.Hijack()
		if err != nil {
			t.Fatalf("hijack failed: %v", err)
		}
		_, _ = fmt.Fprintf(conn, "HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: tcp\r\n\r\n")
		_, _ = rw.WriteString("ok")
		_ = rw.Flush()
		_ = conn.Close()
	})
	socket, cleanup := newUnixHTTPTestServer(t, mux)
	defer cleanup()

	s3 := NewDockerExecSession("sid3", spec, socket)
	execID, err := s3.createExec()
	if err != nil || execID != "exec123" {
		t.Fatalf("createExec failed: id=%q err=%v", execID, err)
	}
	c, err := s3.startExecSocket(execID)
	if err != nil {
		t.Fatalf("startExecSocket failed: %v", err)
	}
	defer c.Close()
	reader := bufio.NewReader(c)
	reply, _ := reader.ReadString('k')
	if reply == "" {
		t.Fatalf("expected upgraded stream payload")
	}
	s3.execID = execID
	if err := s3.resizeExec(80, 24); err != nil {
		t.Fatalf("resizeExec failed: %v", err)
	}
	_ = s3.Close()
}

func TestDockerExecSessionOpenAndStart(t *testing.T) {
	spec := DockerExecSpec{Container: "c1", Command: []string{"sh"}, User: ""}

	mux := http.NewServeMux()
	mux.HandleFunc("/containers/c1/exec", func(w http.ResponseWriter, r *http.Request) {
		_ = json.NewEncoder(w).Encode(map[string]any{"Id": "exec-open"})
	})
	mux.HandleFunc("/exec/exec-open/resize", func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(http.StatusOK)
	})
	mux.HandleFunc("/exec/exec-open/start", func(w http.ResponseWriter, r *http.Request) {
		hj, _ := w.(http.Hijacker)
		conn, rw, _ := hj.Hijack()
		_, _ = fmt.Fprintf(conn, "HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: tcp\r\n\r\n")
		_, _ = rw.WriteString("hello")
		_ = rw.Flush()
		_ = conn.Close()
	})
	socket, cleanup := newUnixHTTPTestServer(t, mux)
	defer cleanup()

	s := NewDockerExecSession("sid-open", spec, socket)
	if err := s.Open(0, 0); err != nil {
		t.Fatalf("Open failed: %v", err)
	}
	rc := &recorderConnector{}
	if err := s.Start(rc); err != nil {
		t.Fatalf("Start failed: %v", err)
	}
	if err := s.Wait(); err != nil && !errors.Is(err, io.EOF) {
		t.Fatalf("Wait failed: %v", err)
	}
}

func TestTerminalSessionOpenStartAndResize(t *testing.T) {
	s := NewTerminalSession("sid-term", "/bin/sh -lc 'printf ok'")
	if err := s.Open(0, 0); err != nil {
		t.Fatalf("Open failed: %v", err)
	}
	rc := &recorderConnector{}
	if err := s.Start(rc); err != nil {
		t.Fatalf("Start failed: %v", err)
	}
	if err := s.SetTerminalSize(100, 30); err != nil {
		t.Fatalf("SetTerminalSize failed: %v", err)
	}
	if err := s.Wait(); err != nil {
		t.Fatalf("Wait failed: %v", err)
	}
	_ = s.Close()
}

func TestTerminalSessionOpenWhenRunningNoop(t *testing.T) {
	s := NewTerminalSession("sid-running", "/bin/true")
	s.running = true
	if err := s.Open(80, 24); err != nil {
		t.Fatalf("Open should be a no-op when already running: %v", err)
	}
}

func TestSessionManagerDefaultSessionFactory(t *testing.T) {
	manager := NewSessionManager(nil)
	t.Setenv(DockerAutoCommandEnv, "tmux new-session -ADs {container}")
	t.Setenv(DockerUsernameEnv, "alice")

	auto := manager.defaultSessionFactory(App{Name: "svc1", Command: AutoCommandSentinel}, "sid")
	execSession, ok := auto.(*DockerExecSession)
	if !ok {
		t.Fatalf("expected DockerExecSession, got %T", auto)
	}
	if len(execSession.spec.Command) == 0 || execSession.spec.Command[len(execSession.spec.Command)-1] != "svc1" {
		t.Fatalf("expected container placeholder expansion, got %+v", execSession.spec.Command)
	}
	if execSession.spec.User != "alice" {
		t.Fatalf("expected docker user from env, got %q", execSession.spec.User)
	}

	plain := manager.defaultSessionFactory(App{Name: "term", Command: "/bin/sh"}, "sid2")
	if _, ok := plain.(*TerminalSession); !ok {
		t.Fatalf("expected TerminalSession, got %T", plain)
	}
}

func TestDefaultConfigAndCPUSparklineEndpoint(t *testing.T) {
	if cfg := DefaultConfig(); len(cfg.Apps) != 0 {
		t.Fatalf("DefaultConfig expected empty apps")
	}

	mux := http.NewServeMux()
	mux.HandleFunc("/_ping", func(w http.ResponseWriter, r *http.Request) {
		_, _ = w.Write([]byte("OK"))
	})
	mux.HandleFunc("/containers/json", func(w http.ResponseWriter, r *http.Request) {
		_ = json.NewEncoder(w).Encode([]map[string]any{
			{
				"Id":    "cafebabedeadbeef",
				"Names": []any{"/svc"},
				"Labels": map[string]any{
					"com.docker.compose.project": "proj",
					"com.docker.compose.service": "svc",
				},
			},
		})
	})
	mux.HandleFunc("/containers/cafebabedead/stats", func(w http.ResponseWriter, r *http.Request) {
		_ = json.NewEncoder(w).Encode(map[string]any{
			"cpu_stats": map[string]any{
				"cpu_usage":        map[string]any{"total_usage": 300.0, "percpu_usage": []any{1.0, 2.0}},
				"system_cpu_usage": 600.0,
				"online_cpus":      2.0,
			},
			"precpu_stats": map[string]any{
				"cpu_usage":        map[string]any{"total_usage": 150.0},
				"system_cpu_usage": 300.0,
			},
		})
	})
	socket, cleanup := newUnixHTTPTestServer(t, mux)
	defer cleanup()
	t.Setenv(DockerHostEnv, "unix://"+socket)

	server := NewLocalServer(
		Config{},
		ServerOptions{
			ComposeMode:    true,
			ComposeProject: "proj",
			LandingApps:    []App{{Name: "svc", Slug: "svc", Command: "/bin/sh", Terminal: true}},
		},
	)
	server.setupDockerFeatures()
	defer server.shutdown()

	req := httptest.NewRequest(http.MethodGet, "/cpu-sparkline.svg?container=svc&width=x&height=y", nil)
	rr := httptest.NewRecorder()
	server.handleCPUSparkline(rr, req)
	if rr.Code != http.StatusOK {
		t.Fatalf("expected 200, got %d", rr.Code)
	}
	if !strings.Contains(rr.Body.String(), "<svg") {
		t.Fatalf("expected SVG response")
	}
}

func TestDockerWatcherStartStop(t *testing.T) {
	manager := NewSessionManager(nil)

	mux := http.NewServeMux()
	mux.HandleFunc("/containers/json", func(w http.ResponseWriter, r *http.Request) {
		_ = json.NewEncoder(w).Encode([]map[string]any{})
	})
	mux.HandleFunc("/events", func(w http.ResponseWriter, r *http.Request) {
		_, _ = io.WriteString(w, `{"Action":"die","Actor":{"ID":"none"}}`+"\n")
	})
	socket, cleanup := newUnixHTTPTestServer(t, mux)
	defer cleanup()

	watcher := NewDockerWatcher(manager, socket, nil, nil)
	watcher.Start()
	time.Sleep(20 * time.Millisecond)
	watcher.Stop()
}

func TestIdleTrackerPauseAndRebuild(t *testing.T) {
s := NewTerminalSession("idle-test", "echo hello")
s.tracker = terminalstate.NewTracker(80, 24)
conn := &recorderConnector{}
s.connector = conn

// Feed some output while active — tracker and replay both update
s.handleOutput([]byte("hello"))
snap1 := s.GetScreenSnapshot()
if !snap1.HasChanges {
t.Fatal("expected HasChanges after active feed")
}
if got := string(s.GetReplayBuffer()); got != "hello" {
t.Fatalf("replay mismatch: %q", got)
}

// Mark idle and advance past threshold
s.MarkIdle()
s.idleSince.Store(time.Now().Add(-idleTrackerThreshold - time.Second).UnixNano())

	// Feed more output while idle — replay and connector still receive data,
	// but the VT parser (tracker) is skipped.
	s.handleOutput([]byte(" world"))
	if got := string(s.GetReplayBuffer()); got != "hello world" {
		t.Fatalf("replay should accumulate while idle: %q", got)
	}
	conn.mu.Lock()
	idleData := len(conn.data)
	conn.mu.Unlock()
	if idleData != 2 {
		t.Fatalf("connector should still receive data while idle, got %d calls", idleData)
	}

// GetScreenSnapshot should rebuild tracker on-demand
snap2 := s.GetScreenSnapshot()
if !snap2.HasChanges {
t.Fatal("snapshot after idle rebuild should have changes")
}

// UpdateConnector should clear idle and rebuild
s.MarkIdle()
s.idleSince.Store(time.Now().Add(-idleTrackerThreshold - time.Second).UnixNano())
s.handleOutput([]byte("!"))
conn2 := &recorderConnector{}
s.UpdateConnector(conn2)
if s.idleSince.Load() != 0 {
t.Fatal("idleSince should be 0 after UpdateConnector")
}

// Feed after reconnect should go through full pipeline again
s.handleOutput([]byte("x"))
conn2.mu.Lock()
got := len(conn2.data)
conn2.mu.Unlock()
if got != 1 {
t.Fatalf("expected 1 data call after reconnect, got %d", got)
}
}
