//go:build e2e

package e2e

import (
	"context"
	"encoding/json"
	"fmt"
	"os"
	"path/filepath"
	"strings"
	"testing"
	"time"

	"github.com/pingcap/failpoint"

	"github.com/tidbcloud/AgentTeam/desktop/internal/agent"
	"github.com/tidbcloud/AgentTeam/desktop/internal/profile"
	"github.com/tidbcloud/AgentTeam/desktop/internal/types"
)

func waitForDispatchedMessages(t *testing.T, disp *stubDispatcher, want int) []*types.Message {
	t.Helper()

	ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
	defer cancel()

	var msgs []*types.Message
	if err := waitFor(ctx, 10*time.Millisecond, func(context.Context) (bool, error) {
		msgs = disp.getDispatchedMsgs()
		return len(msgs) >= want, nil
	}); err != nil {
		t.Fatalf("expected at least %d dispatched messages: %v", want, err)
	}
	return msgs
}

func waitForProtocolPing(t *testing.T, fs *fakeServer) {
	t.Helper()

	fs.Send(map[string]string{"type": "ping"})
	fs.mustReadUntilType("pong", 3*time.Second)
}

func setupAgentTeamAgentProfile(t *testing.T, agentID string) {
	t.Helper()

	cfgDir := t.TempDir()
	if err := failpoint.Enable("agentteam-config-dir", fmt.Sprintf(`return("%s")`, cfgDir)); err != nil {
		t.Fatalf("enable agentteam-config-dir failpoint: %v", err)
	}
	t.Cleanup(func() {
		_ = failpoint.Disable("agentteam-config-dir")
	})

	if err := profile.Default().Agents().Add(agent.Config{
		ID:      agentID,
		Runtime: "opencode",
		Name:    agentID,
	}); err != nil {
		t.Fatalf("add test agent %q: %v", agentID, err)
	}
}

// TestE2E_ReadyMessageOnConnect verifies the channel sends a "ready" message
// with all required fields when it connects to the WebSocket server.
func TestE2E_ReadyMessageOnConnect(t *testing.T) {
	fs := newFakeServer(t)
	startChannel(t, fs)

	readyRaw := fs.mustReadUntilType("ready", 5*time.Second)

	// Verify required fields.
	var ready map[string]json.RawMessage
	if err := json.Unmarshal(readyRaw, &ready); err != nil {
		t.Fatalf("decode ready: %v", err)
	}

	requiredFields := []string{"type", "capabilities", "runtimes", "runningAgents", "hostname", "os", "daemonVersion"}
	for _, field := range requiredFields {
		if _, ok := ready[field]; !ok {
			t.Errorf("ready message missing field: %s", field)
		}
	}

	// Verify all capabilities are present.
	var caps []string
	if err := json.Unmarshal(ready["capabilities"], &caps); err != nil {
		t.Fatalf("decode capabilities: %v", err)
	}
	wantCaps := []string{
		"agent:start", "agent:stop", "agent:interrupt", "agent:deliver",
		"agent:sleep", "agent:reset-workspace", "agent:permission-mode",
		"agent:maas-direct", "agent:runtime-session-authority", "agent:local-session-resume",
		"workspace:files", "workspace:target-path",
		"recovery:restore", "recovery:gc",
		"wake:status",
		"daemon-transport:v1", "daemon-transport:elastic-request-link:v1", "daemon-endpoints:v1",
	}
	if len(caps) != len(wantCaps) {
		t.Fatalf("capabilities count=%d, want %d: %v", len(caps), len(wantCaps), caps)
	}
	capSet := make(map[string]bool, len(caps))
	for _, c := range caps {
		capSet[c] = true
	}
	for _, want := range wantCaps {
		if !capSet[want] {
			t.Errorf("missing capability %q in %v", want, caps)
		}
	}

	// Local test binaries may not carry release version metadata.
	var dv string
	_ = json.Unmarshal(ready["daemonVersion"], &dv)
	if dv != "" && !strings.HasPrefix(dv, "agentteam/") {
		t.Errorf("daemonVersion=%q, want empty or prefix agentteam/", dv)
	}
}

// TestE2E_PingPong verifies that the channel responds to "ping" with "pong".
func TestE2E_PingPong(t *testing.T) {
	fs := newFakeServer(t)
	startChannel(t, fs)
	fs.drainReady()

	fs.Send(map[string]string{"type": "ping"})

	pong := fs.mustReadUntilType("pong", 3*time.Second)
	if decodeType(pong) != "pong" {
		t.Fatalf("expected pong, got %s", decodeType(pong))
	}
}

// TestE2E_DeliverAck verifies that agent:deliver always triggers agent:deliver:ack
// with the correct agentId and seq, even if the agent isn't running.
func TestE2E_DeliverAck(t *testing.T) {
	setupAgentTeamAgentProfile(t, "agent-not-running")
	fs := newFakeServer(t)
	startChannel(t, fs)
	fs.drainReady()

	fs.Send(map[string]any{
		"type":    "agent:deliver",
		"agentId": "agent-not-running",
		"seq":     42,
		"message": map[string]string{
			"content":      "hello world",
			"channel_type": "channel",
			"channel_name": "general",
			"sender_type":  "human",
			"sender_name":  "bob",
		},
	})

	ackRaw := fs.mustReadUntilType("agent:deliver:ack", 3*time.Second)
	if decodeField(ackRaw, "agentId") != "agent-not-running" {
		t.Errorf("ack agentId=%q, want %q", decodeField(ackRaw, "agentId"), "agent-not-running")
	}
	if decodeIntField(ackRaw, "seq") != 42 {
		t.Errorf("ack seq=%d, want 42", decodeIntField(ackRaw, "seq"))
	}
}

// TestE2E_DeliverDispatchesMessage verifies that a valid agent:deliver message
// is dispatched to the dispatcher.
func TestE2E_DeliverDispatchesMessage(t *testing.T) {
	setupAgentTeamAgentProfile(t, "agent-1")
	fs := newFakeServer(t)
	_, disp := startChannel(t, fs)
	fs.drainReady()

	fs.Send(map[string]any{
		"type":    "agent:deliver",
		"agentId": "agent-1",
		"seq":     1,
		"message": map[string]string{
			"content":      "please help me",
			"channel_type": "channel",
			"channel_name": "dev",
			"sender_type":  "human",
			"sender_name":  "alice",
		},
	})

	// Wait for ack and then check dispatcher.
	fs.mustReadUntilType("agent:deliver:ack", 3*time.Second)

	msgs := waitForDispatchedMessages(t, disp, 1)
	if msgs[0].Input.Prompt != "please help me" {
		t.Errorf("dispatched prompt=%q, want %q", msgs[0].Input.Prompt, "please help me")
	}
}

// TestE2E_DuplicateDeliverIsSkipped verifies that a duplicate agent:deliver
// (same agentId + seq) is acked but NOT delivered to the dispatcher twice.
func TestE2E_DuplicateDeliverIsSkipped(t *testing.T) {
	setupAgentTeamAgentProfile(t, "agent-dedup")
	fs := newFakeServer(t)
	_, disp := startChannel(t, fs)
	fs.drainReady()

	msg := map[string]any{
		"type":    "agent:deliver",
		"agentId": "agent-dedup",
		"seq":     99,
		"message": map[string]string{
			"content":      "deduplicate me",
			"channel_type": "channel",
			"channel_name": "test",
			"sender_type":  "human",
			"sender_name":  "eve",
		},
	}

	// Send same message twice.
	fs.Send(msg)
	fs.mustReadUntilType("agent:deliver:ack", 3*time.Second)
	waitForDispatchedMessages(t, disp, 1)

	fs.Send(msg)
	fs.mustReadUntilType("agent:deliver:ack", 3*time.Second)
	waitForProtocolPing(t, fs)

	msgs := disp.getDispatchedMsgs()
	if len(msgs) != 1 {
		t.Fatalf("expected exactly 1 dispatched message (dedup), got %d", len(msgs))
	}
}

// TestE2E_EmptyContentDeliverIsSkipped verifies that a deliver with empty
// content is acked but not dispatched.
func TestE2E_EmptyContentDeliverIsSkipped(t *testing.T) {
	fs := newFakeServer(t)
	_, disp := startChannel(t, fs)
	fs.drainReady()

	fs.Send(map[string]any{
		"type":    "agent:deliver",
		"agentId": "agent-empty",
		"seq":     10,
		"message": map[string]string{
			"content":      "   ",
			"channel_type": "channel",
			"channel_name": "test",
			"sender_type":  "human",
			"sender_name":  "bob",
		},
	})

	fs.mustReadUntilType("agent:deliver:ack", 3*time.Second)
	waitForProtocolPing(t, fs)

	msgs := disp.getDispatchedMsgs()
	if len(msgs) != 0 {
		t.Fatalf("expected no dispatched messages for empty content, got %d", len(msgs))
	}
}

// TestE2E_AttachmentOnlyDeliverDispatchesMessage verifies that attachment-only
// agent:deliver payloads are still forwarded to the dispatcher.
func TestE2E_AttachmentOnlyDeliverDispatchesMessage(t *testing.T) {
	setupAgentTeamAgentProfile(t, "agent-attachment")
	fs := newFakeServer(t)
	_, disp := startChannel(t, fs)
	fs.drainReady()

	fs.Send(map[string]any{
		"type":    "agent:deliver",
		"agentId": "agent-attachment",
		"seq":     11,
		"message": map[string]any{
			"message_id":          "msg-attachment-only",
			"content":             "   ",
			"channel_type":        "thread",
			"channel_name":        "thread-0000104a",
			"parent_channel_type": "channel",
			"parent_channel_name": "agentteam-in-agentteam",
			"sender_type":         "human",
			"sender_name":         "oem979i",
			"attachments": []map[string]string{
				{
					"id":       "att-thread-image",
					"filename": "thread-image.png",
				},
			},
		},
	})

	fs.mustReadUntilType("agent:deliver:ack", 3*time.Second)

	msgs := waitForDispatchedMessages(t, disp, 1)
	if msgs[0].Input.Prompt != "" {
		t.Fatalf("dispatched prompt=%q, want empty string for attachment-only deliver", msgs[0].Input.Prompt)
	}
	meta, ok := msgs[0].Input.Meta.(*types.AgentTeamMeta)
	if !ok {
		t.Fatalf("dispatched meta=%T, want *types.AgentTeamMeta", msgs[0].Input.Meta)
	}
	if meta.AttachmentIDs != "att-thread-image" {
		t.Fatalf("attachment ids=%q, want %q", meta.AttachmentIDs, "att-thread-image")
	}
	if meta.AttachmentNames != "thread-image.png" {
		t.Fatalf("attachment names=%q, want %q", meta.AttachmentNames, "thread-image.png")
	}
	if meta.Target != "#agentteam-in-agentteam:0000104a" {
		t.Fatalf("target=%q, want %q", meta.Target, "#agentteam-in-agentteam:0000104a")
	}
}

// TestE2E_UnknownMessageType verifies that unknown message types are handled
// gracefully without crashing or disconnecting.
func TestE2E_UnknownMessageType(t *testing.T) {
	fs := newFakeServer(t)
	startChannel(t, fs)
	fs.drainReady()

	fs.Send(map[string]string{"type": "totally:unknown:thing"})

	// Verify the channel is still alive by sending ping.
	waitForProtocolPing(t, fs)
}

// TestE2E_MalformedJSON verifies that malformed JSON does not crash the channel.
func TestE2E_MalformedJSON(t *testing.T) {
	fs := newFakeServer(t)
	startChannel(t, fs)
	fs.drainReady()

	fs.SendRaw([]byte(`{this is not valid json`))

	// Verify the channel is still alive by sending ping.
	waitForProtocolPing(t, fs)
}

// TestE2E_MultipleCommandsSequential sends multiple different commands in
// sequence and verifies they all produce correct responses.
func TestE2E_MultipleCommandsSequential(t *testing.T) {
	fs := newFakeServer(t)
	startChannel(t, fs)
	fs.drainReady()

	// 1. Ping → pong.
	fs.Send(map[string]string{"type": "ping"})
	fs.mustReadUntilType("pong", 3*time.Second)

	// 2. Machine scan → scan_result.
	fs.Send(map[string]string{"type": "machine:workspace:scan"})
	scan := fs.mustReadUntilType("machine:workspace:scan_result", 3*time.Second)
	if decodeType(scan) != "machine:workspace:scan_result" {
		t.Errorf("expected scan_result, got %s", decodeType(scan))
	}

	// 3. Workspace list → file_tree.
	fs.Send(map[string]any{
		"type":    "agent:workspace:list",
		"agentId": "multi-test-agent",
		"dirPath": "",
	})
	tree := fs.mustReadUntilType("agent:workspace:file_tree", 3*time.Second)
	if decodeField(tree, "agentId") != "multi-test-agent" {
		t.Errorf("file_tree agentId=%q, want %q", decodeField(tree, "agentId"), "multi-test-agent")
	}

	// 4. Another ping → pong (verify no state corruption).
	fs.Send(map[string]string{"type": "ping"})
	fs.mustReadUntilType("pong", 3*time.Second)
}

// TestE2E_WorkspaceList verifies agent:workspace:list returns agent:workspace:file_tree.
func TestE2E_WorkspaceList(t *testing.T) {
	home := t.TempDir()
	t.Setenv("HOME", home)
	t.Setenv(profile.ConfigDirEnvKey, filepath.Join(home, ".agentteam"))
	fs := newFakeServer(t)
	startChannel(t, fs)
	fs.drainReady()

	agentID := "ws-list-agent"
	agentDir := filepath.Join(os.Getenv("HOME"), ".agentteam", "agents", agentID)
	if err := os.MkdirAll(agentDir, 0755); err != nil {
		t.Fatalf("mkdir: %v", err)
	}
	os.WriteFile(filepath.Join(agentDir, "hello.txt"), []byte("hello"), 0644)
	os.WriteFile(filepath.Join(agentDir, "notes.md"), []byte("# Notes"), 0644)

	fs.Send(map[string]any{
		"type":    "agent:workspace:list",
		"agentId": agentID,
		"dirPath": "",
	})

	tree := fs.mustReadUntilType("agent:workspace:file_tree", 3*time.Second)
	if decodeField(tree, "agentId") != agentID {
		t.Errorf("agentId=%q, want %q", decodeField(tree, "agentId"), agentID)
	}

	// Parse files array.
	var treeMsg struct {
		Files []struct {
			Name string `json:"name"`
		} `json:"files"`
	}
	json.Unmarshal(tree, &treeMsg)
	names := make(map[string]bool)
	for _, f := range treeMsg.Files {
		names[f.Name] = true
	}
	for _, want := range []string{"hello.txt", "notes.md"} {
		if !names[want] {
			t.Errorf("missing file %q in tree: %v", want, names)
		}
	}
}

// TestE2E_WorkspaceRead verifies agent:workspace:read returns file content.
func TestE2E_WorkspaceRead(t *testing.T) {
	home := t.TempDir()
	t.Setenv("HOME", home)
	t.Setenv(profile.ConfigDirEnvKey, filepath.Join(home, ".agentteam"))
	fs := newFakeServer(t)
	startChannel(t, fs)
	fs.drainReady()

	agentID := "ws-read-agent"
	agentDir := filepath.Join(os.Getenv("HOME"), ".agentteam", "agents", agentID)
	os.MkdirAll(agentDir, 0755)
	os.WriteFile(filepath.Join(agentDir, "hello.txt"), []byte("hello world"), 0644)
	os.WriteFile(filepath.Join(agentDir, "image.png"), []byte{0, 1, 2, 3}, 0644)

	// Read text file.
	fs.Send(map[string]any{
		"type":      "agent:workspace:read",
		"agentId":   agentID,
		"path":      "hello.txt",
		"requestId": "req-text",
	})
	textResp := fs.mustReadUntilType("agent:workspace:file_content", 3*time.Second)
	if decodeField(textResp, "requestId") != "req-text" {
		t.Errorf("requestId=%q, want %q", decodeField(textResp, "requestId"), "req-text")
	}
	if decodeBoolField(textResp, "binary") {
		t.Error("expected text file binary=false")
	}

	// Read binary file.
	fs.Send(map[string]any{
		"type":      "agent:workspace:read",
		"agentId":   agentID,
		"path":      "image.png",
		"requestId": "req-bin",
	})
	binResp := fs.mustReadUntilType("agent:workspace:file_content", 3*time.Second)
	if !decodeBoolField(binResp, "binary") {
		t.Error("expected binary file binary=true")
	}
}

// TestE2E_MachineScan verifies machine:workspace:scan returns scan_result with directories.
func TestE2E_MachineScan(t *testing.T) {
	home := t.TempDir()
	t.Setenv("HOME", home)
	t.Setenv(profile.ConfigDirEnvKey, filepath.Join(home, ".agentteam"))
	fs := newFakeServer(t)
	startChannel(t, fs)
	fs.drainReady()

	// Create a workspace directory with files.
	agentDir := filepath.Join(os.Getenv("HOME"), ".agentteam", "agents", "scan-agent")
	os.MkdirAll(agentDir, 0755)
	os.WriteFile(filepath.Join(agentDir, "a.txt"), []byte("1234"), 0644)
	os.WriteFile(filepath.Join(agentDir, "b.txt"), []byte("56789"), 0644)

	fs.Send(map[string]string{"type": "machine:workspace:scan"})
	scan := fs.mustReadUntilType("machine:workspace:scan_result", 3*time.Second)

	var scanResult struct {
		Directories []struct {
			DirectoryName  string `json:"directoryName"`
			TotalSizeBytes int64  `json:"totalSizeBytes"`
			FileCount      int64  `json:"fileCount"`
		} `json:"directories"`
	}
	json.Unmarshal(scan, &scanResult)

	var found bool
	for _, d := range scanResult.Directories {
		if d.DirectoryName == "scan-agent" {
			found = true
			if d.FileCount != 2 {
				t.Errorf("fileCount=%d, want 2", d.FileCount)
			}
			if d.TotalSizeBytes != 9 { // 4 + 5
				t.Errorf("totalSizeBytes=%d, want 9", d.TotalSizeBytes)
			}
		}
	}
	if !found {
		t.Fatalf("scan-agent not found in scan result")
	}
}

// TestE2E_MachineDelete verifies machine:workspace:delete returns delete_result.
func TestE2E_MachineDelete(t *testing.T) {
	home := t.TempDir()
	t.Setenv("HOME", home)
	t.Setenv(profile.ConfigDirEnvKey, filepath.Join(home, ".agentteam"))
	fs := newFakeServer(t)
	startChannel(t, fs)
	fs.drainReady()

	// Create and then delete.
	agentDir := filepath.Join(os.Getenv("HOME"), ".agentteam", "agents", "delete-me")
	os.MkdirAll(agentDir, 0755)
	os.WriteFile(filepath.Join(agentDir, "file.txt"), []byte("data"), 0644)

	fs.Send(map[string]any{
		"type":          "machine:workspace:delete",
		"directoryName": "delete-me",
	})
	del := fs.mustReadUntilType("machine:workspace:delete_result", 3*time.Second)
	if !decodeBoolField(del, "success") {
		t.Error("expected success=true for delete")
	}
	if decodeField(del, "directoryName") != "delete-me" {
		t.Errorf("directoryName=%q, want %q", decodeField(del, "directoryName"), "delete-me")
	}

	// Verify the directory is actually deleted.
	if _, err := os.Stat(agentDir); !os.IsNotExist(err) {
		t.Fatalf("expected agent dir to be deleted, stat err=%v", err)
	}
}
