package dispatcher

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

	"github.com/stretchr/testify/require"

	"github.com/tidbcloud/AgentTeam/desktop/internal/agent"
	"github.com/tidbcloud/AgentTeam/desktop/internal/agent/runtimeconfig"
	"github.com/tidbcloud/AgentTeam/desktop/internal/consts"
	"github.com/tidbcloud/AgentTeam/desktop/internal/types"
	"github.com/tidbcloud/AgentTeam/desktop/internal/wire/manager"
)

func deferredRawMessage(messageID string, channelName string, authorName string, content string, timestamp string) json.RawMessage {
	return json.RawMessage(`{"message_id":"` + messageID + `","channel_type":"channel","channel_name":"` + channelName + `","author_profile":{"actorType":"human","displayName":"` + authorName + `","targetId":"` + authorName + `"},"content":"` + content + `","timestamp":"` + timestamp + `"}`)
}

func deferredStateEntry(messageID string, seq int, acceptedAt time.Time, content string) deferredDeliveryEntry {
	return deferredDeliveryEntry{
		MessageID:  messageID,
		Seq:        seq,
		AcceptedAt: acceptedAt,
		Message:    deferredRawMessage(messageID, "ops", "alice", content, "2026-04-01T10:00:00Z"),
	}
}

func TestHandleProcessExitDoesNotRemoveReplacementSession(t *testing.T) {
	d := mustNewDispatcher("")
	key := "session-key"
	ch := &recordingChannel{id: "ops"}
	mgr := manager.New(nil, nil)
	mgr.Register(ch)
	d.serverConnections = mgr
	oldProcess := &agent.Process{AgentID: "agent-a"}
	oldSession := &interactiveSession{
		sessionKey:          key,
		agentID:             "agent-a",
		workDir:             t.TempDir(),
		binding:             conversationBinding{channelID: "ops", serverAgentID: "agent-a"},
		process:             oldProcess,
		suppressCheckpoints: true,
	}
	newSession := &interactiveSession{
		sessionKey: key,
		agentID:    "agent-a",
		process:    &agent.Process{AgentID: "agent-a"},
	}
	d.sessionManager.Set(key, oldSession)
	d.sessionManager.Set(key, newSession)

	d.handleProcessExit(oldSession, oldProcess)

	current, ok := d.sessionManager.Get(key)
	require.True(t, ok)
	require.Same(t, newSession, current)
	require.Empty(t, ch.events)
}

func setUnexportedInt(t *testing.T, field reflect.Value, value int64) {
	t.Helper()
	require.True(t, field.IsValid())
	reflect.NewAt(field.Type(), unsafe.Pointer(field.UnsafeAddr())).Elem().SetInt(value)
}

func setUnexportedString(t *testing.T, field reflect.Value, value string) {
	t.Helper()
	require.True(t, field.IsValid())
	reflect.NewAt(field.Type(), unsafe.Pointer(field.UnsafeAddr())).Elem().SetString(value)
}

func setProcessExitSummary(t *testing.T, ap *agent.Process, exitCode int, _ bool) {
	t.Helper()
	value := reflect.ValueOf(ap).Elem()
	setUnexportedInt(t, value.FieldByName("exitCode"), int64(exitCode))
}

func TestEnqueueContextRecoveryContinuationDeduplicatesRetry(t *testing.T) {
	workDir := t.TempDir()
	sess := &interactiveSession{agentID: "agent-recovery", workDir: workDir}
	require.NoError(t, saveSessionResumeState(workDir, sessionResumeState{
		DeferredDeliveries: []deferredDeliveryEntry{
			deferredStateEntry("msg-pending", 1, time.Now().UTC(), "keep this delivery"),
		},
	}))

	d := mustNewDispatcher("http://server.test")
	wakes := []agent.WakeInfo{{
		MessageID:   "msg-recovery-source",
		TargetKey:   "ops",
		TargetSeq:   42,
		Target:      "#ops:deadbeef",
		MessageType: "channel_message",
	}}

	require.NoError(t, d.enqueueContextRecoveryContinuation(sess, wakes))
	first, err := loadSessionResumeState(workDir)
	require.NoError(t, err)
	require.Len(t, first.DeferredDeliveries, 2)

	require.NoError(t, d.enqueueContextRecoveryContinuation(sess, wakes))
	second, err := loadSessionResumeState(workDir)
	require.NoError(t, err)
	require.Len(t, second.DeferredDeliveries, 2, "retry after an unknown result must not enqueue a second recovery")

	var recovery types.InboxMessage
	for _, entry := range second.DeferredDeliveries {
		msg, ok := deferredInboxMessage(entry.Message)
		if ok && isContextRecoveryMessage(msg) {
			recovery = msg
		}
	}
	require.Equal(t, "#ops:deadbeef", recovery.Target)
	require.Equal(t, "ops", recovery.TargetKey)
	require.Equal(t, int64(42), recovery.TargetSeq)
	require.Equal(t, checkpointMsgRecovery, recovery.Content)
}

func TestEnqueueContextRecoveryContinuationAggregatesTargetFrontiers(t *testing.T) {
	workDir := t.TempDir()
	sess := &interactiveSession{agentID: "agent-recovery-aggregate", workDir: workDir}
	d := mustNewDispatcher("http://server.test")
	wakes := []agent.WakeInfo{
		{MessageID: "ops-1", TargetKey: "ops", TargetSeq: 1, Target: "#ops:deadbeef", DeliveryClass: agent.DeliveryClassVisibleReply},
		{MessageID: "ops-3", TargetKey: "ops", TargetSeq: 3, Target: "#ops:deadbeef", DeliveryClass: agent.DeliveryClassVisibleReply},
		{MessageID: "dm-2", TargetKey: "dm", TargetSeq: 2, Target: "dm:@alice", DeliveryClass: agent.DeliveryClassVisibleReply},
		{MessageID: "missing-key", Target: "#ignored", TargetSeq: 99, DeliveryClass: agent.DeliveryClassVisibleReply},
		{MessageID: "signal", TargetKey: "signal", Target: "#signal", TargetSeq: 100, DeliveryClass: agent.DeliveryClassSignalOnly},
	}

	require.NoError(t, d.enqueueContextRecoveryContinuation(sess, wakes))
	state, err := loadSessionResumeState(workDir)
	require.NoError(t, err)

	markers := make(map[string]types.InboxMessage)
	for _, entry := range state.DeferredDeliveries {
		msg, ok := deferredInboxMessage(entry.Message)
		if ok && isContextRecoveryMessage(msg) {
			markers[msg.TargetKey] = msg
		}
	}
	require.Len(t, markers, 2)
	require.Equal(t, int64(3), markers["ops"].TargetSeq)
	require.Equal(t, int64(2), markers["dm"].TargetSeq)
	require.Equal(t, "#ops:deadbeef", markers["ops"].Target)
	require.Equal(t, "dm:@alice", markers["dm"].Target)
	require.NotContains(t, markers["ops"].Content, "#ignored")

	// A retry with the same captured frontier is idempotent for both target
	// markers, while preserving the independent target obligations.
	require.NoError(t, d.enqueueContextRecoveryContinuation(sess, wakes))
	retried, err := loadSessionResumeState(workDir)
	require.NoError(t, err)
	count := 0
	for _, entry := range retried.DeferredDeliveries {
		msg, ok := deferredInboxMessage(entry.Message)
		if ok && isContextRecoveryMessage(msg) {
			count++
		}
	}
	require.Equal(t, 2, count)
}

func contextRecoveryTestEntry(t *testing.T, messageID, targetKey, target string, targetSeq int64, replayPending bool, acceptedAt time.Time) deferredDeliveryEntry {
	t.Helper()
	msg := deferredContinuationMessage{
		MessageID:   messageID,
		TargetKey:   targetKey,
		TargetSeq:   targetSeq,
		ChannelType: "channel",
		ChannelName: strings.TrimPrefix(target, "#"),
		MessageType: "context_recovery",
	}
	raw, err := json.Marshal(msg)
	require.NoError(t, err)
	return deferredDeliveryEntry{
		MessageID:     messageID,
		AcceptedAt:    acceptedAt,
		Message:       raw,
		ReplayPending: replayPending,
	}
}

func TestConsumeContextRecoveryMarkerPreservesOtherTargets(t *testing.T) {
	marker := func(id, targetKey, target string, seq int64) deferredDeliveryEntry {
		raw, err := json.Marshal(deferredContinuationMessage{
			MessageID:   id,
			TargetKey:   targetKey,
			TargetSeq:   seq,
			ChannelType: "channel",
			ChannelName: strings.TrimPrefix(target, "#"),
			MessageType: "context_recovery",
		})
		require.NoError(t, err)
		return deferredDeliveryEntry{MessageID: id, Message: raw, AcceptedAt: time.Now().UTC()}
	}

	entries := []deferredDeliveryEntry{
		marker("recovery-ops", "ops", "#ops", 3),
		marker("recovery-dm", "dm", "#dm", 4),
	}
	require.Equal(t, 0, contextRecoveryReplayIndex(entries))
	remaining := removeDeferredDeliveryByMessageID(entries, "recovery-ops")
	require.Len(t, remaining, 1)
	require.Equal(t, 0, contextRecoveryReplayIndex(remaining), "the next target marker remains consumable")
	msg, ok := deferredInboxMessage(remaining[0].Message)
	require.True(t, ok)
	require.Equal(t, "recovery-dm", msg.MessageID)
	require.Equal(t, "dm", msg.TargetKey)
}

func TestDeferredInboxMessagePreservesReactionMetadata(t *testing.T) {
	msg, ok := deferredInboxMessage(json.RawMessage(`{
		"message_id":"rxdlv_123",
		"channel_type":"channel",
		"channel_name":"ops",
		"channel_display_name":"Operations",
		"author_profile":{"actorType":"human","displayName":"alice","targetId":"alice"},
		"content":"reacted 👍 to your message",
		"timestamp":"2026-04-15T10:00:00Z",
		"message_type":"reaction",
		"metadata":{
			"reaction":{
				"emoji":"👍",
				"targetMessageId":"msg_123",
				"targetMessagePreview":"Looks good to me"
			}
		}
	}`))
	require.True(t, ok)
	require.Equal(t, "Operations", msg.ChannelDisplayName)
	require.Equal(t, "reaction", msg.MessageType)
	require.Equal(t, "msg_123", msg.Metadata["reaction"].(map[string]any)["targetMessageId"])
}

func TestDeferredInboxMessagePreservesNativeServiceEventSource(t *testing.T) {
	msg, ok := deferredInboxMessage(json.RawMessage(`{
		"message_id":"msg_triggered",
		"channel_type":"channel",
		"channel_name":"ops",
		"author_profile":{"actorType":"service","displayName":"gitea","targetId":"gitea"},
		"content":"PR synchronized",
		"timestamp":"2026-05-28T12:00:00Z",
		"source":{
			"kind":"provider_event",
			"refType":"service_event_delivery",
			"refId":"sed_1",
			"runId":"sed_1",
			"eventId":"evt_1",
			"eventType":"gitea.pull_request.synchronized",
			"label":"Gitea PR watch"
		}
	}`))
	require.True(t, ok)
	require.Equal(t, "provider_event", msg.Source.Kind)
	require.Equal(t, "service_event_delivery", msg.Source.RefType)
	require.Equal(t, "sed_1", msg.Source.RunID)
	require.Equal(t, "evt_1", msg.Source.EventID)
}

func TestDeferredInboxMessagePreservesTeamContexts(t *testing.T) {
	msg, ok := deferredInboxMessage(json.RawMessage(`{
		"message_id":"msg_team",
		"channel_type":"channel",
		"channel_name":"ops",
		"author_profile":{"actorType":"human","displayName":"alice","targetId":"alice"},
		"content":"@core-team please coordinate",
		"timestamp":"2026-06-18T11:53:00Z",
		"teamContexts":[{
			"teamId":"team_core",
			"teamHandle":"core-team",
			"teamDisplayName":"Core Team",
			"teamDescription":"Coordinate as Core Team.",
			"teamMemberId":"wtm_agent",
			"roleLabel":"Builder",
			"roleOverlayPrompt":"Focus on delivery.",
			"memberRoster":[{
				"teamMemberId":"wtm_agent",
				"actorKind":"agent",
				"actorId":"mem_agent",
				"displayName":"Core Agent",
				"handle":"core-agent",
				"roleLabel":"Builder",
				"deliveryKind":"wake",
				"self":true
			}]
		}]
	}`))
	require.True(t, ok)
	require.Len(t, msg.TeamContexts, 1)
	require.Equal(t, "team_core", msg.TeamContexts[0].TeamID)
	require.Equal(t, "Coordinate as Core Team.", msg.TeamContexts[0].TeamDescription)
	require.Equal(t, "Focus on delivery.", msg.TeamContexts[0].RoleOverlayPrompt)
	require.Len(t, msg.TeamContexts[0].MemberRoster, 1)
	require.Equal(t, "wtm_agent", msg.TeamContexts[0].MemberRoster[0].TeamMemberID)
	require.True(t, msg.TeamContexts[0].MemberRoster[0].Self)
}

func TestBindingWithDeferredAgentTeamMessageSynthesizesAgentTeamTargetAndMeta(t *testing.T) {
	binding := bindingWithDeferredAgentTeamMessage(conversationBinding{
		channelID:       "chn_agentteam",
		targetScopeType: "channel",
		serverAgentID:   "agt_agentteam",
	}, types.InboxMessage{
		MessageID:                "msg_123",
		Target:                   "#all:deadbeef",
		ChannelDisplayName:       "All Hands",
		ParentChannelDisplayName: "All Hands",
		AuthorProfile:            types.AuthorProfile{ActorType: "human", DisplayName: "owner", TargetID: "owner"},
		Timestamp:                "2026-04-22T09:09:05Z",
		MessageType:              "reaction",
		Source:                   types.ContributionSource{Kind: "schedule_run", RefType: "automation_run", RefID: "arun_1", RunID: "arun_1"},
		Metadata:                 map[string]any{"reaction": map[string]any{"emoji": "👍"}},
		TeamContexts: []types.TeamContext{{
			TeamID:          "team_core",
			TeamHandle:      "core-team",
			TeamDescription: "Coordinate as Core Team.",
			TeamMemberID:    "wtm_agent",
		}},
		AttachmentIDs:   "att_1",
		AttachmentNames: "design.png",
	})

	require.Equal(t, "#all:deadbeef", binding.targetID)

	agentteamMeta, ok := binding.meta.(*types.AgentTeamMeta)
	require.True(t, ok)
	require.Equal(t, "msg_123", agentteamMeta.ID)
	require.Equal(t, "#all:deadbeef", agentteamMeta.Target)
	require.Equal(t, "agt_agentteam", agentteamMeta.AgentID)
	require.Equal(t, "All Hands", agentteamMeta.ChannelDisplayName)
	require.Equal(t, "All Hands", agentteamMeta.ParentChannelDisplayName)
	require.Equal(t, "reaction", agentteamMeta.MessageType)
	require.Equal(t, "schedule_run", agentteamMeta.Source.Kind)
	require.Equal(t, "automation_run", agentteamMeta.Source.RefType)
	require.Equal(t, "arun_1", agentteamMeta.Source.RunID)
	require.Equal(t, "att_1", agentteamMeta.AttachmentIDs)
	require.Equal(t, "design.png", agentteamMeta.AttachmentNames)
	require.Len(t, agentteamMeta.TeamContexts, 1)
	require.Equal(t, "team_core", agentteamMeta.TeamContexts[0].TeamID)
	require.Equal(t, "Coordinate as Core Team.", agentteamMeta.TeamContexts[0].TeamDescription)
	require.Equal(t, "👍", agentteamMeta.Metadata["reaction"].(map[string]any)["emoji"])
}

func TestHandleProcessExitConsumesReplayOnCleanExitWithoutVisibleReply(t *testing.T) {
	workDir := t.TempDir()
	now := time.Now().UTC()
	require.NoError(t, saveSessionResumeState(workDir, sessionResumeState{
		LastStartConfig: &types.AgentStartConfig{
			Name:      "Deferred Agent",
			Runtime:   "opencode",
			ServerURL: "http://server.test",
		},
		DeferredDeliveries: []deferredDeliveryEntry{
			func() deferredDeliveryEntry {
				entry := deferredStateEntry("msg-1", 11, now, "attachment-only reply")
				entry.ReplayPending = true
				return entry
			}(),
		},
	}))

	d := mustNewDispatcher("http://server.test")
	exitingProcess := &agent.Process{AgentID: "agent-clean-exit-no-visible-reply"}
	setProcessExitSummary(t, exitingProcess, 0, false)

	sess := &interactiveSession{
		agentID: "agent-clean-exit-no-visible-reply",
		agent: agent.Config{
			ID:   "agent-clean-exit-no-visible-reply",
			Name: "Deferred Agent",
		},
		runtime: runtimeconfig.Runtime{Type: runtimeconfig.TypeOpenCode},
		binding: conversationBinding{
			channelID:     "ops",
			serverAgentID: "agent-clean-exit-no-visible-reply",
		},
		workDir:       workDir,
		process:       exitingProcess,
		serverAgentID: "agent-clean-exit-no-visible-reply",
	}
	d.sessionManager.Set("agent-clean-exit-no-visible-reply", sess)

	d.handleProcessExit(sess, exitingProcess)

	state, err := loadSessionResumeState(workDir)
	require.NoError(t, err)
	require.Empty(t, state.DeferredDeliveries)

	_, ok := d.Session("agent-clean-exit-no-visible-reply")
	require.False(t, ok)
}

func TestPersistSessionStateAsyncUpdatesLastStartConfigSessionID(t *testing.T) {
	workDir := t.TempDir()
	require.NoError(t, saveSessionResumeState(workDir, sessionResumeState{
		LastStartConfig: &types.AgentStartConfig{
			Name:      "Deferred Agent",
			Runtime:   "codex",
			ServerURL: "http://server.test",
		},
	}))

	persistSessionStateAsync(workDir, "sess-persisted")

	state, err := loadSessionResumeState(workDir)
	require.NoError(t, err)
	require.NotNil(t, state.LastStartConfig)
	require.Equal(t, "sess-persisted", state.LastSessionID)
	require.Equal(t, "sess-persisted", state.LastStartConfig.SessionID)
}

type recordingChannel struct {
	id       string
	events   []types.AgentEvent
	outbound []types.OutboundMessage
}

func (c *recordingChannel) ID() string                  { return c.id }
func (c *recordingChannel) Enabled() bool               { return true }
func (c *recordingChannel) Start(context.Context) error { return nil }
func (c *recordingChannel) Stop(context.Context) error  { return nil }
func (c *recordingChannel) Send(_ context.Context, _ types.Metadata, msg types.OutboundMessage) error {
	c.outbound = append(c.outbound, msg)
	return nil
}
func (c *recordingChannel) Emit(event types.AgentEvent) { c.events = append(c.events, event) }

func TestHandleProcessExitDropsDeferredReplayAfterMaxAttempts(t *testing.T) {
	workDir := t.TempDir()
	now := time.Now().UTC()
	require.NoError(t, saveSessionResumeState(workDir, sessionResumeState{
		LastStartConfig: &types.AgentStartConfig{
			Name:      "Deferred Agent",
			Runtime:   "codex",
			ServerURL: "http://server.test",
		},
		DeferredDeliveries: []deferredDeliveryEntry{
			func() deferredDeliveryEntry {
				entry := deferredStateEntry("msg-1", 11, now, "please retry")
				entry.ReplayPending = true
				entry.ReplayAttempts = maxDeferredReplayAttempts - 1
				return entry
			}(),
		},
	}))

	d := mustNewDispatcher("http://server.test")
	ch := &recordingChannel{id: "ops"}
	mgr := manager.New(nil, nil)
	mgr.Register(ch)
	d.serverConnections = mgr

	exitingProcess := &agent.Process{AgentID: "agent-replay-fail"}
	setProcessExitSummary(t, exitingProcess, 1, false)

	sess := &interactiveSession{
		agentID: "agent-replay-fail",
		agent: agent.Config{
			ID:   "agent-replay-fail",
			Name: "Deferred Agent",
		},
		runtime: runtimeconfig.Runtime{Type: runtimeconfig.TypeCodex},
		binding: conversationBinding{
			channelID:     "ops",
			serverAgentID: "agent-replay-fail",
		},
		workDir:       workDir,
		process:       exitingProcess,
		serverAgentID: "agent-replay-fail",
	}
	d.sessionManager.Set("agent-replay-fail", sess)

	d.handleProcessExit(sess, exitingProcess)

	state, err := loadSessionResumeState(workDir)
	require.NoError(t, err)
	require.Empty(t, state.DeferredDeliveries)

	found := false
	for _, event := range ch.events {
		runtimeEvent, ok := event.(types.RuntimeEvent)
		if !ok {
			continue
		}
		if runtimeEvent.EventClass == consts.RuntimeEventClassWakeFailed {
			found = true
			require.Equal(t, "agent-replay-fail", runtimeEvent.AgentID)
			require.Equal(t, "msg-1", runtimeEvent.MessageID)
			require.Equal(t, "#ops", runtimeEvent.Target)
			require.Contains(t, runtimeEvent.Detail, "deferred replay aborted after")
		}
	}
	require.True(t, found)
}

func TestEmitRuntimeStartFailed(t *testing.T) {
	d := mustNewDispatcher("http://server.test")
	ch := &recordingChannel{id: "ops"}
	mgr := manager.New(nil, nil)
	mgr.Register(ch)
	d.serverConnections = mgr

	d.emitRuntimeStartFailed("ops", "agent-start-fail", types.InboxMessage{
		MessageID: "msg-start",
		Target:    "#ops",
	}, os.ErrNotExist)

	require.Len(t, ch.events, 1)
	runtimeEvent, ok := ch.events[0].(types.RuntimeEvent)
	require.True(t, ok)
	require.Equal(t, "agent-start-fail", runtimeEvent.AgentID)
	require.Equal(t, "msg-start", runtimeEvent.MessageID)
	require.Equal(t, "#ops", runtimeEvent.Target)
	require.Equal(t, consts.RuntimeEventClassRuntimeStartFailed, runtimeEvent.EventClass)
	require.Contains(t, runtimeEvent.Detail, "file does not exist")
}

func TestResetByAgentRemovesWorkspaceRootDirectory(t *testing.T) {
	workDir := filepath.Join(t.TempDir(), "agent-workdir")
	require.NoError(t, os.MkdirAll(filepath.Join(workDir, "notes"), 0o755))
	require.NoError(t, os.WriteFile(filepath.Join(workDir, "notes", "last-run.txt"), []byte("hello\n"), 0o644))

	d := mustNewDispatcher("http://server.test")
	d.sessionManager.Set("agent-reset", &interactiveSession{
		agentID: "agent-reset",
		workDir: workDir,
	})

	reset, err := d.ResetByAgent("agent-reset")
	require.NoError(t, err)
	require.True(t, reset)

	_, statErr := os.Stat(workDir)
	require.ErrorIs(t, statErr, os.ErrNotExist)
}

func TestHandleProcessExitWhileResettingDoesNotRecreateWorkspace(t *testing.T) {
	workDir := filepath.Join(t.TempDir(), "agent-workdir")
	require.NoError(t, os.MkdirAll(workDir, 0o755))
	require.NoError(t, os.RemoveAll(workDir))

	d := mustNewDispatcher("http://server.test")
	exitingProcess := &agent.Process{
		AgentID:   "agent-reset",
		SessionID: "sess-reset",
	}
	setProcessExitSummary(t, exitingProcess, -1, false)
	setUnexportedString(t, reflect.ValueOf(exitingProcess).Elem().FieldByName("workDir"), workDir)

	sess := &interactiveSession{
		agentID: "agent-reset",
		agent: agent.Config{
			ID:   "agent-reset",
			Name: "Reset Agent",
		},
		runtime: runtimeconfig.Runtime{Type: runtimeconfig.TypeCodex},
		binding: conversationBinding{
			channelID:     "ops",
			serverAgentID: "agent-reset",
		},
		workDir:       workDir,
		process:       exitingProcess,
		serverAgentID: "agent-reset",
		resetting:     true,
	}
	d.sessionManager.Set("agent-reset", sess)

	d.handleProcessExit(sess, exitingProcess)

	_, statErr := os.Stat(workDir)
	require.ErrorIs(t, statErr, os.ErrNotExist)

	_, ok := d.Session("agent-reset")
	require.False(t, ok)
}
