package session

import (
	"fmt"
	"net"
	"os"
	"os/exec"
	"path/filepath"
	"strings"
	"testing"
	"time"

	"tobi/internal/protocol"
	"tobi/internal/termcolor"
)

// t.TempDir() blows past the ~104 byte cap on a unix socket path on macos
func shortTempDir(t *testing.T) string {
	dir, err := os.MkdirTemp("", "tb")
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { _ = os.RemoveAll(dir) })
	return dir
}

func startOwner(t *testing.T, cmd ...string) string {
	id, sp := GenerateID(), ""
	sp = SocketPath(id)
	go func() { _ = RunOwner(id, cmd) }()
	for i := 0; i < 50; i++ {
		if c, err := net.Dial("unix", sp); err == nil {
			_ = c.Close()
			return sp
		}
		time.Sleep(20 * time.Millisecond)
	}
	t.Fatalf("owner did not start: %s", sp)
	return ""
}

func attach(t *testing.T, sp string) net.Conn {
	c, err := net.Dial("unix", sp)
	if err != nil {
		t.Fatal(err)
	}
	_ = protocol.WriteFrame(c, protocol.TypeAttach, protocol.EncodeSize(80, 24))
	if ft, _, err := protocol.ReadFrame(c); err != nil || ft != protocol.TypeSnapshot {
		t.Fatalf("snapshot: %v", err)
	}
	return c
}

func expectFrame(t *testing.T, c net.Conn, want protocol.FrameType, contains string) {
	_ = c.SetReadDeadline(time.Now().Add(time.Second))
	for {
		ft, p, err := protocol.ReadFrame(c)
		if err != nil {
			t.Fatalf("frame %v: %v", want, err)
		}
		if ft == want && (contains == "" || strings.Contains(string(p), contains)) {
			return
		}
	}
}

func TestKill(t *testing.T) {
	t.Setenv("XDG_RUNTIME_DIR", shortTempDir(t))
	sp := startOwner(t, "sleep", "60")
	id := strings.TrimSuffix(filepath.Base(sp), ".sock")
	if err := Kill(id); err != nil {
		t.Fatal(err)
	}
	if list := ListLocal(); len(list) != 0 {
		t.Fatalf("session survived: %v", list)
	}
	if err := Kill(id); err == nil {
		t.Fatal("killing a dead session succeeded")
	}
}

func TestSnapshotKeepsMouseEncoding(t *testing.T) {
	t.Setenv("XDG_RUNTIME_DIR", shortTempDir(t))
	sp := startOwner(t, "sh")
	v := attach(t, sp)
	defer v.Close()
	// the tty echoes the typed line, so the marker is split by quotes to
	// match only once the command has run
	_ = protocol.WriteFrame(v, protocol.TypeInput, []byte("printf '\\033[?1002h\\033[?1006h'; echo do''ne\n"))
	expectFrame(t, v, protocol.TypeOutput, "done")
	c, err := net.Dial("unix", sp)
	if err != nil {
		t.Fatal(err)
	}
	defer c.Close()
	_ = protocol.WriteFrame(c, protocol.TypeAttach, protocol.EncodeSize(80, 24))
	if _, snap, err := protocol.ReadFrame(c); err != nil || !strings.Contains(string(snap), "\x1b[?1006h") {
		t.Fatalf("snapshot lost sgr mouse: %v %q", err, snap)
	}
}

func TestKillSignalsAnOwnerThatPredatesIt(t *testing.T) {
	if sock := os.Getenv("TOBI_TEST_DEAF_OWNER"); sock != "" {
		l, err := net.Listen("unix", sock)
		if err != nil {
			os.Exit(1)
		}
		for {
			c, err := l.Accept()
			if err != nil {
				os.Exit(1)
			}
			_, _, _ = protocol.ReadFrame(c)
			_ = c.Close()
		}
	}
	t.Setenv("XDG_RUNTIME_DIR", shortTempDir(t))
	id := GenerateID()
	owner := exec.Command(os.Args[0], "-test.run=^TestKillSignalsAnOwnerThatPredatesIt$")
	owner.Env = append(os.Environ(), "TOBI_TEST_DEAF_OWNER="+SocketPath(id))
	if err := owner.Start(); err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { _ = owner.Process.Kill() })
	for i := 0; i < 100 && !answers(SocketPath(id), 250*time.Millisecond); i++ {
		time.Sleep(20 * time.Millisecond)
	}
	if err := Kill(id); err != nil {
		t.Fatal(err)
	}
	if err := owner.Wait(); err == nil || !strings.Contains(err.Error(), "terminated") {
		t.Fatalf("owner exit: %v", err)
	}
}

func TestQueryFilter(t *testing.T) {
	var f queryFilter
	for _, tc := range []struct{ in, want string }{
		{"\x1b]10", ""}, {";?\x1b\\", ""},
		{"\x1b]4;1;?;2", ""}, {";?\a", ""},
		{"hi\x1b[?6nthere\x1b[2", "hithere"}, {"Jok", "\x1b[2Jok"},
		{"ok\x1b", "ok"}, {"[31mred", "\x1b[31mred"},
	} {
		if got := f.Feed([]byte(tc.in)).shared; string(got) != tc.want {
			t.Fatalf("Feed(%q) = %q, want %q", tc.in, got, tc.want)
		}
	}
}

func TestQueryFilterRoutesClipboardTraffic(t *testing.T) {
	var f queryFilter
	req := "\x1b]5522;type=read:id=a;aW1hZ2UvcG5n\x1b\\"
	for _, tc := range []struct{ in, emulator, shared, routed string }{
		{"hi\x1b]5522;type=re", "hi", "hi", ""},
		{"ad:id=a;aW1hZ2UvcG5n\x1b\\there", "there", "there", req},
		{"\x1b]52;c;?\a", "\x1b]52;c;?\a", "\x1b]52;c;?\a", ""},
		{"\x1b[?5522$p", "", "", "\x1b[?5522$p"},
		{"\x1b[?2026$p", "\x1b[?2026$p", "", ""},
	} {
		got := f.Feed([]byte(tc.in))
		if string(got.emulator) != tc.emulator || string(got.shared) != tc.shared || string(got.routed) != tc.routed {
			t.Fatalf("Feed(%q) = %q; want emulator %q, shared %q, routed %q", tc.in, got, tc.emulator, tc.shared, tc.routed)
		}
	}
}

func TestStaleSocketCleanup(t *testing.T) {
	t.Setenv("XDG_RUNTIME_DIR", shortTempDir(t))
	sp := filepath.Join(RuntimeDir(), "stale123456.sock")
	_ = os.WriteFile(sp, []byte("stale"), 0600)
	_ = ListLocal()
	if _, err := os.Stat(sp); !os.IsNotExist(err) {
		t.Fatal("stale socket not removed")
	}
}

func TestOwnerMultiViewerHopAndOSC(t *testing.T) {
	t.Setenv("XDG_RUNTIME_DIR", shortTempDir(t))
	sp := startOwner(t, "sh")
	v1, v2 := attach(t, sp), attach(t, sp)
	defer v1.Close()
	defer v2.Close()

	cmd := "printf '\\033]2;hx main.go\\a\\033]7;file://localhost/custom/dir\\a'; echo sha''red\n" // quoted apart from the tty's echo
	_ = protocol.WriteFrame(v1, protocol.TypeInput, []byte(cmd))
	expectFrame(t, v1, protocol.TypeOutput, "shared")
	expectFrame(t, v2, protocol.TypeOutput, "shared")
	if err := SendHop(sp, "remote.123456"); err != nil {
		t.Fatal(err)
	}
	expectFrame(t, v1, protocol.TypeHop, "remote.123456")
	expectFrame(t, v2, protocol.TypeHop, "remote.123456")
	if list := ListLocal(); len(list) == 0 || list[0].Command != "hx main.go" || list[0].Cwd != "/custom/dir" {
		t.Fatalf("metadata mismatch: %v", list)
	}
}

func TestHopRetiresOwnerOnceViewersLeave(t *testing.T) {
	t.Setenv("XDG_RUNTIME_DIR", shortTempDir(t))
	sp := startOwner(t, "sh")
	v := attach(t, sp)
	if err := SendHop(sp, "remote.123456"); err != nil {
		t.Fatal(err)
	}
	expectFrame(t, v, protocol.TypeHop, "remote.123456")
	if !answers(sp, 100*time.Millisecond) {
		t.Fatal("owner went away while a viewer was still attached")
	}
	_ = v.Close()
	waitGone(t, sp)
}

func TestHopToSelfKeepsOwner(t *testing.T) {
	t.Setenv("XDG_RUNTIME_DIR", shortTempDir(t))
	sp := startOwner(t, "sh")
	self := ShortHostname() + "." + strings.TrimSuffix(filepath.Base(sp), ".sock")
	v := attach(t, sp)
	if err := SendHop(sp, self); err != nil {
		t.Fatal(err)
	}
	expectFrame(t, v, protocol.TypeHop, self)
	_ = v.Close()
	time.Sleep(200 * time.Millisecond)
	if !answers(sp, 100*time.Millisecond) {
		t.Fatal("hop to the same session retired it")
	}
}

func TestHopWithoutViewersKeepsOwner(t *testing.T) {
	t.Setenv("XDG_RUNTIME_DIR", shortTempDir(t))
	sp := startOwner(t, "sh")
	if err := SendHop(sp, "remote.123456"); err != nil {
		t.Fatal(err)
	}
	v := attach(t, sp)
	_ = v.Close()
	time.Sleep(200 * time.Millisecond)
	if !answers(sp, 100*time.Millisecond) {
		t.Fatal("a hop nobody saw retired the owner")
	}
}

func waitGone(t *testing.T, sp string) {
	t.Helper()
	for deadline := time.Now().Add(2 * time.Second); time.Now().Before(deadline); time.Sleep(20 * time.Millisecond) {
		if !answers(sp, 50*time.Millisecond) {
			return
		}
	}
	t.Fatalf("owner still answering: %s", sp)
}

func TestSlowViewerDoesNotBlockOwner(t *testing.T) {
	t.Setenv("XDG_RUNTIME_DIR", shortTempDir(t))
	sp := startOwner(t, "sh", "-c", "dd if=/dev/zero bs=1024 count=2000; sleep 10")
	slow, _ := net.Dial("unix", sp)
	defer slow.Close()
	_ = protocol.WriteFrame(slow, protocol.TypeAttach, protocol.EncodeSize(80, 24))
	time.Sleep(300 * time.Millisecond)

	conn, err := net.DialTimeout("unix", sp, 500*time.Millisecond)
	if err != nil {
		t.Fatal(err)
	}
	defer conn.Close()
	go func() { _ = SendHop(sp, "remote.blocked") }()
	_ = protocol.WriteFrame(conn, protocol.TypeInfo, nil)
	if ft, _, err := protocol.ReadFrame(conn); err != nil || ft != protocol.TypeInfoResp {
		t.Fatalf("owner blocked: %v", err)
	}
}

func TestDetachedIdleTTL(t *testing.T) {
	t.Setenv("XDG_RUNTIME_DIR", shortTempDir(t))
	defer detachedTTL.Store(detachedTTL.Swap(int64(80 * time.Millisecond))) // the swap runs now, the restore on return
	sp := startOwner(t, "sh", "-c", "sleep 10")
	v := attach(t, sp)
	_ = v.Close()
	time.Sleep(150 * time.Millisecond)
	if _, err := os.Stat(sp); !os.IsNotExist(err) {
		t.Fatalf("socket not reaped: %v", err)
	}
}

func TestEnsureOwnerNamesAMissingCommand(t *testing.T) {
	t.Setenv("XDG_RUNTIME_DIR", shortTempDir(t))
	err := EnsureOwner(GenerateID(), []string{"tobi-no-such-command-xyz"})
	if err == nil || !strings.Contains(err.Error(), "tobi-no-such-command-xyz") {
		t.Fatalf("EnsureOwner = %v, want the missing command named", err)
	}
	if strings.Contains(err.Error(), "did not start") {
		t.Fatalf("EnsureOwner = %v, want the real reason rather than a timeout", err)
	}
}
func attachColors(t *testing.T, sp string, colors map[int]termcolor.Color) net.Conn {
	c, err := net.Dial("unix", sp)
	if err != nil {
		t.Fatal(err)
	}
	_ = protocol.WriteFrame(c, protocol.TypeAttach, append(protocol.EncodeSize(80, 24), termcolor.EncodeColors(colors)...))
	if ft, _, err := protocol.ReadFrame(c); err != nil || ft != protocol.TypeSnapshot {
		t.Fatalf("snapshot: %v", err)
	}
	return c
}

func expectNoOutput(t *testing.T, c net.Conn, contains string) {
	t.Helper()
	_ = c.SetReadDeadline(time.Now().Add(300 * time.Millisecond))
	defer c.SetReadDeadline(time.Time{})
	for {
		ft, p, err := protocol.ReadFrame(c)
		if err != nil {
			if ne, ok := err.(net.Error); ok && ne.Timeout() {
				return
			}
			t.Fatalf("frame %q: %v", contains, err)
		}
		if ft == protocol.TypeOutput && strings.Contains(string(p), contains) {
			t.Fatalf("unexpected %q", contains)
		}
	}
}

func TestOwnerAnswersAgreedColorsOnly(t *testing.T) {
	t.Setenv("XDG_RUNTIME_DIR", shortTempDir(t))
	sp := startOwner(t, "sh", "-c", "stty raw -echo; cat")
	ask := func(v net.Conn, i int) {
		_ = protocol.WriteFrame(v, protocol.TypeInput, []byte(fmt.Sprintf("\x1b]%d;?\x1b\\", i)))
	}

	fg := termcolor.Color{R: 0xffff, G: 0xffff, B: 0xffff}
	v1 := attachColors(t, sp, map[int]termcolor.Color{termcolor.Foreground: fg, termcolor.Background: {R: 0, G: 0, B: 0}})
	defer v1.Close()
	v2 := attachColors(t, sp, map[int]termcolor.Color{termcolor.Foreground: fg, termcolor.Background: {R: 0, G: 0, B: 0xffff}})
	defer v2.Close()

	ask(v1, 10)
	expectFrame(t, v1, protocol.TypeOutput, "\x1b]10;rgb:ffff/ffff/ffff")
	ask(v1, 11)
	expectNoOutput(t, v1, "\x1b]11;rgb:")

	// a viewer without colors silences even agreed colors
	v3 := attach(t, sp)
	defer v3.Close()
	ask(v1, 10)
	expectNoOutput(t, v1, "\x1b]10;rgb:")

	// once the disagreeing and unknown viewers detach the answer appears
	v2.Close()
	v3.Close()
	time.Sleep(100 * time.Millisecond)
	ask(v1, 11)
	expectFrame(t, v1, protocol.TypeOutput, "\x1b]11;rgb:0000/0000/0000")
}

// every terminal that sees a clipboard read request prompts and answers it into
// the same pty, so only the viewer that last typed (the one who pressed paste)
// may see it
func TestClipboardRequestReachesOnlyTheTypingViewer(t *testing.T) {
	t.Setenv("XDG_RUNTIME_DIR", shortTempDir(t))
	sp := startOwner(t, "sh", "-c", "stty raw -echo; cat")
	time.Sleep(200 * time.Millisecond) // until the tty stops echoing, which would show v2 the request
	v1, v2 := attach(t, sp), attach(t, sp)
	defer v1.Close()
	defer v2.Close()

	_ = protocol.WriteFrame(v1, protocol.TypeInput, []byte("\x1b]5522;type=read:id=x;aW1hZ2UvcG5n\x1b\\"))
	expectFrame(t, v1, protocol.TypeOutput, "\x1b]5522;type=read:id=x;")
	expectNoOutput(t, v2, "5522")

	// the reply goes back through the same viewer and reaches the app, which cat echoes
	_ = protocol.WriteFrame(v1, protocol.TypeInput, []byte("replydone\n"))
	expectFrame(t, v1, protocol.TypeOutput, "replydone")
	expectFrame(t, v2, protocol.TypeOutput, "replydone")

	_ = protocol.WriteFrame(v2, protocol.TypeInput, []byte("\x1b]5522;type=read:id=y;aW1hZ2UvcG5n\x1b\\"))
	expectFrame(t, v2, protocol.TypeOutput, "\x1b]5522;type=read:id=y;")
	expectNoOutput(t, v1, "id=y")
}

// the owner's emulator would answer the capability probe with "mode unknown"
// before the real terminal could, so the probe must reach only that terminal
func TestClipboardProbeIsLeftToTheTypingViewersTerminal(t *testing.T) {
	t.Setenv("XDG_RUNTIME_DIR", shortTempDir(t))
	sp := startOwner(t, "sh", "-c", "stty raw -echo; cat")
	time.Sleep(200 * time.Millisecond)
	v1, v2 := attach(t, sp), attach(t, sp)
	defer v1.Close()
	defer v2.Close()

	_ = protocol.WriteFrame(v1, protocol.TypeInput, []byte("\x1b[?5522$p"))
	expectFrame(t, v1, protocol.TypeOutput, "\x1b[?5522$p")
	expectNoOutput(t, v1, "5522;")
	expectNoOutput(t, v2, "5522")
}

func TestAttachPayloadKeepsLegacySize(t *testing.T) {
	colors := map[int]termcolor.Color{termcolor.Foreground: {R: 1, G: 2, B: 3}}
	payload := append(protocol.EncodeSize(120, 40), termcolor.EncodeColors(colors)...)
	c, r, err := protocol.DecodeSize(payload)
	if err != nil || c != 120 || r != 40 {
		t.Fatalf("DecodeSize on a longer payload = %d %d %v, want the size bytes only", c, r, err)
	}
	if colors2, ok := termcolor.DecodeColors(payload[4:]); !ok || colors2[termcolor.Foreground] != (termcolor.Color{R: 1, G: 2, B: 3}) {
		t.Fatalf("DecodeColors on the tail = %v %v", colors2, ok)
	}
}
