package dispatcher

import (
	"strings"
	"testing"

	"github.com/stretchr/testify/require"

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

// P2: the AgentTeamReporterSink emits a TurnStateEvent at the events that can change
// the real turn execution state (session start/stop, delivery pending/acked,
// turn end/idle/start). The state is resolved live from the daemon Process, so
// the assertion uses a fake resolver returning the expected active/queued/idle.
func TestAgentTeamReporterSinkEmitsTurnStateOnRelevantEvents(t *testing.T) {
	cases := []struct {
		name  string
		kind  runtimeevents.Kind
		state string
	}{
		{"turn end empty -> idle", runtimeevents.KindTurnEnded, types.AgentTurnStateIdle},
		{"turn idle -> idle", runtimeevents.KindTurnIdle, types.AgentTurnStateIdle},
		{"turn start -> active", runtimeevents.KindTurnThinking, types.AgentTurnStateActive},
		{"delivery pending -> queued", runtimeevents.KindDeliveryPending, types.AgentTurnStateQueued},
		{"delivery acked -> queued", runtimeevents.KindDeliveryAcked, types.AgentTurnStateQueued},
		{"session stopped -> idle", runtimeevents.KindSessionStopped, types.AgentTurnStateIdle},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			reporter := &reportingStubChannel{id: "agentteam"}
			sink := NewAgentTeamReporterSink(func(serverID string) types.EventReporter {
				require.Equal(t, "agentteam", serverID)
				return reporter
			}, func(_ string, agentID string) TurnStateObservation {
				require.Equal(t, "agt_x", agentID)
				return TurnStateObservation{TurnState: tc.state, SessionID: "sess_1"}
			})
			sink.Handle(runtimeevents.NewEvent(tc.kind, runtimeevents.WithContext(runtimeevents.EventContext{
				ServerID:      "agentteam",
				ServerAgentID: "agt_x",
			})))
			emitted := reporter.eventSnapshot()
			var found *types.TurnStateEvent
			for _, event := range emitted {
				if turn, ok := event.(types.TurnStateEvent); ok {
					found = &turn
					break
				}
			}
			require.NotNil(t, found, "a TurnStateEvent must be emitted for %s", tc.kind)
			require.Equal(t, "agt_x", found.AgentID)
			require.Equal(t, tc.state, found.TurnState)
		})
	}
}

// P2: an event that cannot change turn state must not emit a TurnStateEvent, and
// a nil resolver (old daemon / disabled) never emits.
func TestAgentTeamReporterSinkSkipsIrrelevantAndNilResolver(t *testing.T) {
	reporter := &reportingStubChannel{id: "agentteam"}
	sink := NewAgentTeamReporterSink(func(string) types.EventReporter { return reporter }, func(_ string, _ string) TurnStateObservation {
		return TurnStateObservation{TurnState: types.AgentTurnStateActive, SessionID: "sess_1"}
	})
	sink.Handle(runtimeevents.NewEvent(runtimeevents.KindUsageTokens, runtimeevents.WithContext(runtimeevents.EventContext{
		ServerID: "agentteam", ServerAgentID: "agt_x",
	})))
	for _, event := range reporter.eventSnapshot() {
		_, ok := event.(types.TurnStateEvent)
		require.False(t, ok, "irrelevant events must not emit a TurnStateEvent")
	}

	// A nil resolver never emits.
	nilSink := NewAgentTeamReporterSink(func(string) types.EventReporter { return reporter }, nil)
	nilSink.Handle(runtimeevents.NewEvent(runtimeevents.KindTurnEnded, runtimeevents.WithContext(runtimeevents.EventContext{
		ServerID: "agentteam", ServerAgentID: "agt_x",
	})))
	for _, event := range reporter.eventSnapshot() {
		_, ok := event.(types.TurnStateEvent)
		require.False(t, ok, "a nil resolver must never emit a TurnStateEvent")
	}
}

// TestAgentTeamReporterSinkTerminalClearCarriesExpectedSession proves the terminal
// stopped event resolves its TurnStateObservation exactly once and reuses the
// SAME immutable (state, session) pair as the single dying-generation identity
// for EVERY terminal projection: the idle turn-state, the inactive status, the
// offline activity, and the empty session clear. The frames are emitted in the
// explicit order turn_state -> inactive/offline telemetry -> session clear
// (never SessionEvent-first), and every terminal frame carries the identical
// expected session so a stale (replaced-generation) stop is rejected wholesale.
func TestAgentTeamReporterSinkTerminalClearCarriesExpectedSession(t *testing.T) {
	reporter := &reportingStubChannel{id: "agentteam"}
	sink := NewAgentTeamReporterSink(func(serverID string) types.EventReporter {
		require.Equal(t, "agentteam", serverID)
		return reporter
	}, func(_ string, agentID string) TurnStateObservation {
		require.Equal(t, "agt_x", agentID)
		return TurnStateObservation{TurnState: types.AgentTurnStateIdle, SessionID: "sess_1"}
	})
	sink.Handle(runtimeevents.NewEvent(runtimeevents.KindSessionStopped, runtimeevents.WithContext(runtimeevents.EventContext{
		ServerID: "agentteam", ServerAgentID: "agt_x",
	})))

	emitted := reporter.eventSnapshot()

	// Order: turn_state (idle) -> inactive status -> offline activity -> session clear.
	require.GreaterOrEqual(t, len(emitted), 4, "the terminal stop must emit idle, inactive, offline, and the session clear")
	idx := map[string]int{}
	for i, event := range emitted {
		switch event.(type) {
		case types.TurnStateEvent:
			idx["turn_state"] = i
		case types.StatusEvent:
			idx["status"] = i
		case types.ActivityEvent:
			idx["activity"] = i
		case types.SessionEvent:
			idx["session"] = i
		}
	}
	require.True(t, idx["turn_state"] < idx["status"], "terminal idle must be emitted before the inactive status")
	require.True(t, idx["status"] < idx["activity"], "inactive status must be emitted before the offline activity")
	require.True(t, idx["activity"] < idx["session"], "offline activity must be emitted before the session clear")

	// The terminal idle turn-state carries the dying session.
	var turnState *types.TurnStateEvent
	for _, event := range emitted {
		if ev, ok := event.(types.TurnStateEvent); ok {
			turnState = &ev
			break
		}
	}
	require.NotNil(t, turnState, "the terminal stopped event must publish the terminal idle turn-state")
	require.Equal(t, types.AgentTurnStateIdle, turnState.TurnState)
	require.Equal(t, "sess_1", turnState.SessionID)

	// EVERY terminal lifecycle frame carries the identical expected session.
	var status *types.StatusEvent
	var activity *types.ActivityEvent
	var sessionClear *types.SessionEvent
	for _, event := range emitted {
		switch ev := event.(type) {
		case types.StatusEvent:
			if ev.Status == "inactive" {
				status = &ev
			}
		case types.ActivityEvent:
			if ev.Activity == "offline" {
				activity = &ev
			}
		case types.SessionEvent:
			if strings.TrimSpace(ev.SessionID) == "" {
				sessionClear = &ev
			}
		}
	}
	require.NotNil(t, status, "the terminal stop must emit the inactive status")
	require.Equal(t, "sess_1", status.ExpectedSessionID, "the inactive status must carry the dying generation's session")
	require.NotNil(t, activity, "the terminal stop must emit the offline activity")
	require.Equal(t, "sess_1", activity.ExpectedSessionID, "the offline activity must carry the dying generation's session")
	require.NotNil(t, sessionClear, "the terminal stopped event must emit the session-clearing event")
	require.Equal(t, "sess_1", sessionClear.ExpectedSessionID,
		"the terminal clear must be fenced by the same generation's session identity")
}

// TestAgentTeamReporterSinkLegacyTerminalClearUnfenced proves a terminal stopped event
// whose observation cannot be resolved (unknown/no session) leaves the
// session-clearing event WITHOUT the optional expected-session fence, preserving
// the legacy unconditional clear behavior.
func TestAgentTeamReporterSinkLegacyTerminalClearUnfenced(t *testing.T) {
	reporter := &reportingStubChannel{id: "agentteam"}
	sink := NewAgentTeamReporterSink(func(string) types.EventReporter { return reporter }, func(_ string, _ string) TurnStateObservation {
		return TurnStateObservation{}
	})
	sink.Handle(runtimeevents.NewEvent(runtimeevents.KindSessionStopped, runtimeevents.WithContext(runtimeevents.EventContext{
		ServerID: "agentteam", ServerAgentID: "agt_x",
	})))

	var sessionClear *types.SessionEvent
	for _, event := range reporter.eventSnapshot() {
		if ev, ok := event.(types.SessionEvent); ok && strings.TrimSpace(ev.SessionID) == "" {
			sessionClear = &ev
			break
		}
	}
	require.NotNil(t, sessionClear, "the terminal clear must still be emitted for a legacy/unknown session")
	require.Empty(t, sessionClear.ExpectedSessionID,
		"a legacy clear without a resolvable session must keep the unconditional behavior")
}
