package dispatcher

import (
	"context"
	"strings"
	"sync"
	"testing"

	"github.com/stretchr/testify/require"

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

// fakeSkillSyncer is a test double for SkillSyncer. It records every call
// (so tests can assert scope keys and ordering) and returns whatever paths,
// state, or error the test registered. The cache map and state map together
// mirror the production provisioning/skill.Sync semantics: Bound stores Populated,
// Unbound stores AuthoritativeEmpty, Unknown preserves the prior entry.
type fakeSkillSyncer struct {
	mu sync.Mutex

	syncCalls  []fakeSkillSyncCall
	cacheCalls []string

	cache   map[string][]string
	repoIDs map[string][]string
	states  map[string]SkillCacheState
	// nextState overrides the state returned by the next SyncAgent call.
	// Tests set this to drive Bound/Unbound/Unknown paths explicitly. When
	// zero AND nextStateSet is false, Bound is assumed if the registered
	// paths are non-empty, otherwise Unbound.
	nextState SkillLookupState
	// nextStateSet, when true, forces nextState to be used verbatim — even
	// when it is SkillLookupUnknown (the zero value). Without this flag the
	// fake would auto-derive Bound/Unbound from the cache, which makes it
	// impossible to test the Unknown path explicitly.
	nextStateSet bool
}

type fakeSkillSyncCall struct {
	AgentID  string
	ScopeKey string
}

func newFakeSkillSyncer() *fakeSkillSyncer {
	return &fakeSkillSyncer{
		cache:   make(map[string][]string),
		repoIDs: make(map[string][]string),
		states:  make(map[string]SkillCacheState),
	}
}

func (f *fakeSkillSyncer) seed(scopeKey string, paths []string, state SkillCacheState) {
	f.mu.Lock()
	defer f.mu.Unlock()
	f.cache[scopeKey] = paths
	f.states[scopeKey] = state
}

func (f *fakeSkillSyncer) seedRepoIDs(scopeKey string, repoIDs []string) {
	f.mu.Lock()
	defer f.mu.Unlock()
	f.repoIDs[scopeKey] = repoIDs
}

// SyncAgent implements SkillSyncer. The fake honors the same invariant as
// production: Unknown preserves the cache, Bound/Unbound overwrite it.
func (f *fakeSkillSyncer) SyncAgent(_ context.Context, agentID, scopeKey string) ([]string, SkillLookupState, error) {
	f.mu.Lock()
	defer f.mu.Unlock()
	f.syncCalls = append(f.syncCalls, fakeSkillSyncCall{AgentID: agentID, ScopeKey: scopeKey})

	state := f.nextState
	if !f.nextStateSet {
		// Default: derive state from registered paths.
		paths := f.cache[scopeKey]
		if len(paths) > 0 {
			state = SkillLookupBound
		} else {
			state = SkillLookupUnbound
		}
	}

	switch state {
	case SkillLookupBound:
		paths := f.cache[scopeKey]
		f.states[scopeKey] = SkillCachePopulated
		return paths, SkillLookupBound, nil
	case SkillLookupUnbound:
		f.cache[scopeKey] = nil
		f.states[scopeKey] = SkillCacheAuthoritativeEmpty
		return nil, SkillLookupUnbound, nil
	default: // SkillLookupUnknown
		// Preserve whatever was previously cached.
		return f.cache[scopeKey], SkillLookupUnknown, nil
	}
}

func (f *fakeSkillSyncer) CachedPaths(scopeKey string) []string {
	f.mu.Lock()
	defer f.mu.Unlock()
	f.cacheCalls = append(f.cacheCalls, scopeKey)
	return f.cache[scopeKey]
}

func (f *fakeSkillSyncer) CachedRepoIDs(scopeKey string) []string {
	f.mu.Lock()
	defer f.mu.Unlock()
	return f.repoIDs[scopeKey]
}

func (f *fakeSkillSyncer) CacheState(scopeKey string) (SkillCacheState, bool) {
	f.mu.Lock()
	defer f.mu.Unlock()
	state, ok := f.states[scopeKey]
	if !ok {
		return SkillCacheMiss, false
	}
	return state, true
}

// stubScopeChannel is a minimal Channel that also exposes the connection
// scope the skill sync cache keys on. It implements just enough of Channel
// for channel.Manager.Register / Channel(id) to work.
type stubScopeChannel struct {
	id          string
	serverURL   string
	workspaceID string
}

func (c *stubScopeChannel) ID() string                  { return c.id }
func (c *stubScopeChannel) Enabled() bool               { return true }
func (c *stubScopeChannel) Start(context.Context) error { return nil }
func (c *stubScopeChannel) Stop(context.Context) error  { return nil }
func (c *stubScopeChannel) Send(context.Context, types.Metadata, types.OutboundMessage) error {
	return nil
}

// skillConnScopeProvider methods — what computeSkillScopeKey actually reads.
func (c *stubScopeChannel) ServerURL() string   { return c.serverURL }
func (c *stubScopeChannel) WorkspaceID() string { return c.workspaceID }

// registerAgentTeamAgentForTest seeds profile.Default() with an agent bound to a
// AgentTeam source channel and registers the channel with the dispatcher's
// channel manager. Returns the composed scopeKey the test should expect.
func registerAgentTeamAgentForTest(t *testing.T, d *Dispatcher, agentID, channelID, serverURL, workspaceID string) string {
	t.Helper()
	require.NoError(t, profile.Default().Agents().Add(agent.Config{
		ID:      agentID,
		Name:    agentID,
		Runtime: "codex",
		Source: agent.AgentSource{
			Type: "agentteam",
			ID:   channelID,
		},
	}))
	d.serverConnections.Register(&stubScopeChannel{
		id:          channelID,
		serverURL:   serverURL,
		workspaceID: workspaceID,
	})
	return strings.TrimSpace(agentID) + "@" + strings.TrimSpace(channelID) +
		"#" + strings.TrimSpace(serverURL) + ":" + strings.TrimSpace(workspaceID)
}

// TestComputeSkillScopeKey_UsesAgentSourceChannel pins the hard rule that
// the cache key is derived from the agent's owning AgentTeam source channel
// (agent.Source.ID), NOT from any per-message binding channel. Two agents
// routed via the same binding channel but owned by different source
// channels must produce different scope keys.
func TestComputeSkillScopeKey_UsesAgentSourceChannel(t *testing.T) {
	t.Setenv(profile.ConfigDirEnvKey, t.TempDir())
	d := mustNewDispatcher("http://server.test")

	wantA := registerAgentTeamAgentForTest(t, d, "agt_a", "ch_owner_a", "https://s", "ws")
	wantB := registerAgentTeamAgentForTest(t, d, "agt_b", "ch_owner_b", "https://s", "ws")

	gotA, ok := d.computeSkillScopeKey("agt_a")
	require.True(t, ok)
	require.Equal(t, wantA, gotA)

	gotB, ok := d.computeSkillScopeKey("agt_b")
	require.True(t, ok)
	require.Equal(t, wantB, gotB)

	require.NotEqual(t, gotA, gotB, "different owning source channels must produce different scope keys")
}

// TestComputeSkillScopeKey_RejectsNonAgentTeamAgent pins the contract: an agent
// without a AgentTeam source has no skill sync, and computeSkillScopeKey returns
// ok=false so attachSkillSync / cachedSkillPathsForAgent no-op.
func TestComputeSkillScopeKey_RejectsNonAgentTeamAgent(t *testing.T) {
	t.Setenv(profile.ConfigDirEnvKey, t.TempDir())
	d := mustNewDispatcher("http://server.test")
	require.NoError(t, profile.Default().Agents().Add(agent.Config{
		ID:      "agt_local",
		Runtime: "codex",
		Source:  agent.AgentSource{Type: "local"},
	}))

	_, ok := d.computeSkillScopeKey("agt_local")
	require.False(t, ok)

	// Unknown agent also returns ok=false.
	_, ok = d.computeSkillScopeKey("does-not-exist")
	require.False(t, ok)
}

// TestComputeSkillScopeKey_ChannelScopeIsStable pins that two agents bound
// to the SAME owning source channel share the channel portion of the key
// but differ in the agentID prefix — so cross-agent isolation holds even
// when both agents live in the same workspace/server.
func TestComputeSkillScopeKey_ChannelScopeIsStable(t *testing.T) {
	t.Setenv(profile.ConfigDirEnvKey, t.TempDir())
	d := mustNewDispatcher("http://server.test")
	const sharedChannel = "ch_shared"
	d.serverConnections.Register(&stubScopeChannel{
		id:          sharedChannel,
		serverURL:   "https://s",
		workspaceID: "ws",
	})
	require.NoError(t, profile.Default().Agents().Add(agent.Config{
		ID: "agt_a", Runtime: "codex",
		Source: agent.AgentSource{Type: "agentteam", ID: sharedChannel},
	}))
	require.NoError(t, profile.Default().Agents().Add(agent.Config{
		ID: "agt_b", Runtime: "codex",
		Source: agent.AgentSource{Type: "agentteam", ID: sharedChannel},
	}))

	keyA, _ := d.computeSkillScopeKey("agt_a")
	keyB, _ := d.computeSkillScopeKey("agt_b")

	require.Contains(t, keyA, "agt_a@ch_shared#https://s:ws")
	require.Contains(t, keyB, "agt_b@ch_shared#https://s:ws")
	require.NotEqual(t, keyA, keyB, "agentID prefix must isolate cache entries")
}

// TestAttachSkillSync_ReturnsPathsAndCaches verifies the happy path: the
// returned paths flow from the SkillSyncer cache through attachSkillSync to
// the caller, without doing Git/network sync before launch.
func TestAttachSkillSync_ReturnsPathsAndCaches(t *testing.T) {
	t.Setenv(profile.ConfigDirEnvKey, t.TempDir())
	d := mustNewDispatcher("http://server.test")
	scope := registerAgentTeamAgentForTest(t, d, "agt_a", "ch_a", "https://s", "ws")

	syncer := newFakeSkillSyncer()
	syncer.seed(scope, []string{"/cache/sk-a", "/cache/sk-b"}, SkillCachePopulated)
	d.SetSkillSyncer(syncer)

	paths := d.attachSkillSync(context.Background(), "agt_a")
	require.Equal(t, []string{"/cache/sk-a", "/cache/sk-b"}, paths)

	require.Empty(t, syncer.syncCalls, "agent startup must not call SyncAgent")

	require.Equal(t, paths, d.cachedSkillPathsForAgent("agt_a"),
		"execution path must read the same cache entry the leader session populated")
}

// TestAttachSkillSync_EmptyAccessReturnsNilAndOverwritesCache pins the
// revocation cache guarantee: once the syncer has an authoritative empty
// entry, launch paths return nil and never sync on startup.
func TestAttachSkillSync_EmptyAccessReturnsNilAndOverwritesCache(t *testing.T) {
	t.Setenv(profile.ConfigDirEnvKey, t.TempDir())
	d := mustNewDispatcher("http://server.test")
	scope := registerAgentTeamAgentForTest(t, d, "agt_a", "ch_a", "https://s", "ws")

	syncer := newFakeSkillSyncer()
	syncer.seed(scope, nil, SkillCacheAuthoritativeEmpty)
	d.SetSkillSyncer(syncer)

	paths := d.attachSkillSync(context.Background(), "agt_a")
	require.Nil(t, paths)
	require.Nil(t, d.cachedSkillPathsForAgent("agt_a"),
		"Unbound must overwrite cache so stale paths are not exposed")
	require.Empty(t, syncer.syncCalls, "agent startup must not call SyncAgent")
}

// TestAttachSkillSync_UnknownPreservesCache pins the three-state invariant:
// when a background/realtime refresh returns Unknown, the cache MUST be
// preserved so launch keeps using the prior good snapshot.
func TestAttachSkillSync_UnknownPreservesCache(t *testing.T) {
	t.Setenv(profile.ConfigDirEnvKey, t.TempDir())
	d := mustNewDispatcher("http://server.test")
	scope := registerAgentTeamAgentForTest(t, d, "agt_a", "ch_a", "https://s", "ws")

	syncer := newFakeSkillSyncer()
	syncer.seed(scope, []string{"/cache/sk-a"}, SkillCachePopulated)
	d.SetSkillSyncer(syncer)

	require.Equal(t, []string{"/cache/sk-a"}, d.cachedSkillPathsForAgent("agt_a"))

	syncer.nextState = SkillLookupUnknown
	syncer.nextStateSet = true
	d.RefreshSkillAccess(context.Background(), []string{"agt_a"})

	paths := d.attachSkillSync(context.Background(), "agt_a")
	require.Equal(t, []string{"/cache/sk-a"}, paths)
	require.Equal(t, []string{"/cache/sk-a"}, d.cachedSkillPathsForAgent("agt_a"),
		"Unknown MUST preserve cache so transient failures do not wipe good snapshots")
}

// TestAttachSkillSync_AgentIsolation pins the A/B agent guarantee: two
// distinct agents (different agentID + different owning source channel)
// never share a cache entry, so A's synced paths are not materialized for
// B's launch.
func TestAttachSkillSync_AgentIsolation(t *testing.T) {
	t.Setenv(profile.ConfigDirEnvKey, t.TempDir())
	d := mustNewDispatcher("http://server.test")
	scopeA := registerAgentTeamAgentForTest(t, d, "agt_a", "ch_a", "https://s", "ws")
	scopeB := registerAgentTeamAgentForTest(t, d, "agt_b", "ch_b", "https://s", "ws")

	syncer := newFakeSkillSyncer()
	syncer.seed(scopeA, []string{"/cache/sk-a"}, SkillCachePopulated)
	syncer.seed(scopeB, []string{"/cache/sk-b"}, SkillCachePopulated)
	d.SetSkillSyncer(syncer)

	pathsA := d.attachSkillSync(context.Background(), "agt_a")
	pathsB := d.attachSkillSync(context.Background(), "agt_b")

	require.Equal(t, []string{"/cache/sk-a"}, pathsA)
	require.Equal(t, []string{"/cache/sk-b"}, pathsB)

	// cachedSkillPathsForAgent must not cross agents.
	require.Equal(t, []string{"/cache/sk-a"}, d.cachedSkillPathsForAgent("agt_a"))
	require.Equal(t, []string{"/cache/sk-b"}, d.cachedSkillPathsForAgent("agt_b"))
}

// TestAttachSkillSync_WorkspaceScopeIsolation pins the same-agentID,
// different-workspace isolation. In practice this is rare (an agent's
// owning source channel is stable), but if an agent's source channel
// changes between two attachSkillSync calls (e.g. profile reconfig + new
// channel registered under the same agentID), the scope keys must differ
// and the cache entries must stay independent.
func TestAttachSkillSync_WorkspaceScopeIsolation(t *testing.T) {
	t.Setenv(profile.ConfigDirEnvKey, t.TempDir())
	d := mustNewDispatcher("http://server.test")

	const agentID = "agt_x"
	scope1 := registerAgentTeamAgentForTest(t, d, agentID, "ch_1", "https://s1", "ws1")

	syncer := newFakeSkillSyncer()
	syncer.seed(scope1, []string{"/cache/sk-from-ws1"}, SkillCachePopulated)
	d.SetSkillSyncer(syncer)

	paths1 := d.attachSkillSync(context.Background(), agentID)
	require.Equal(t, []string{"/cache/sk-from-ws1"}, paths1)

	// Simulate agent re-binding to a new source channel / workspace.
	require.NoError(t, profile.Default().Agents().Update(agent.Config{
		ID: agentID, Runtime: "codex",
		Source: agent.AgentSource{Type: "agentteam", ID: "ch_2"},
	}))
	d.serverConnections.Register(&stubScopeChannel{
		id: "ch_2", serverURL: "https://s2", workspaceID: "ws2",
	})
	scope2 := agentID + "@ch_2#https://s2:ws2"

	// Pre-seed scope2's cache and confirm scope1 is untouched.
	syncer.seed(scope2, []string{"/cache/sk-from-ws2"}, SkillCachePopulated)
	paths2 := d.attachSkillSync(context.Background(), agentID)
	require.Equal(t, []string{"/cache/sk-from-ws2"}, paths2)

	// The new scope's cache must not return the old scope's paths.
	require.Equal(t, []string{"/cache/sk-from-ws2"}, d.cachedSkillPathsForAgent(agentID))

	// Roll back to scope1 to prove scope1's cache entry survived.
	require.NoError(t, profile.Default().Agents().Update(agent.Config{
		ID: agentID, Runtime: "codex",
		Source: agent.AgentSource{Type: "agentteam", ID: "ch_1"},
	}))
	require.Equal(t, []string{"/cache/sk-from-ws1"}, d.cachedSkillPathsForAgent(agentID),
		"rolling back to the old scope must return that scope's last sync result, not the new scope's")
}

// TestCachedSkillPathsForAgent_NeverSyncedDoesNotSync pins the cache-miss
// behavior: execution-launch reads only the existing cache. It never performs
// Git/network sync itself; background/realtime refresh owns fetching.
func TestCachedSkillPathsForAgent_NeverSyncedDoesNotSync(t *testing.T) {
	t.Setenv(profile.ConfigDirEnvKey, t.TempDir())
	d := mustNewDispatcher("http://server.test")
	registerAgentTeamAgentForTest(t, d, "agt_a", "ch_a", "https://s", "ws")

	syncer := newFakeSkillSyncer()
	d.SetSkillSyncer(syncer)

	require.Nil(t, d.cachedSkillPathsForAgent("agt_a"),
		"cache miss returns nil without putting sync on execution-launch")
	require.Empty(t, syncer.syncCalls, "execution-launch must not call SyncAgent")
}

// TestCachedSkillPathsForAgent_CacheMissForcesSyncAndReturnsPaths confirms
// execution-launch returns an already-populated cache without calling
// SyncAgent.
func TestCachedSkillPathsForAgent_ReturnsPopulatedCache(t *testing.T) {
	t.Setenv(profile.ConfigDirEnvKey, t.TempDir())
	d := mustNewDispatcher("http://server.test")
	scope := registerAgentTeamAgentForTest(t, d, "agt_a", "ch_a", "https://s", "ws")

	syncer := newFakeSkillSyncer()
	syncer.seed(scope, []string{"/cache/sk-a"}, SkillCachePopulated)
	d.SetSkillSyncer(syncer)

	paths := d.cachedSkillPathsForAgent("agt_a")
	require.Equal(t, []string{"/cache/sk-a"}, paths)
	require.Empty(t, syncer.syncCalls, "execution-launch must not call SyncAgent")
}

func TestCachedSkillRepoIDsSnapshotRequiresEveryAgentTeamAgentCache(t *testing.T) {
	t.Setenv(profile.ConfigDirEnvKey, t.TempDir())
	d := mustNewDispatcher("http://server.test")
	scopeA := registerAgentTeamAgentForTest(t, d, "agt_a", "ch_a", "https://s", "ws")
	registerAgentTeamAgentForTest(t, d, "agt_b", "ch_b", "https://s", "ws")

	syncer := newFakeSkillSyncer()
	syncer.seed(scopeA, []string{"/cache/sk-a"}, SkillCachePopulated)
	syncer.seedRepoIDs(scopeA, []string{"sk_a"})
	d.SetSkillSyncer(syncer)

	repoIDs, complete := d.CachedSkillRepoIDsSnapshot([]string{"agt_a", "agt_b"})
	require.False(t, complete)
	require.Equal(t, []string{"sk_a"}, repoIDs)
}

func TestCachedSkillRepoIDsSnapshotTreatsAuthoritativeEmptyAsComplete(t *testing.T) {
	t.Setenv(profile.ConfigDirEnvKey, t.TempDir())
	d := mustNewDispatcher("http://server.test")
	scopeA := registerAgentTeamAgentForTest(t, d, "agt_a", "ch_a", "https://s", "ws")
	scopeB := registerAgentTeamAgentForTest(t, d, "agt_b", "ch_b", "https://s", "ws")

	syncer := newFakeSkillSyncer()
	syncer.seed(scopeA, []string{"/cache/sk-a"}, SkillCachePopulated)
	syncer.seedRepoIDs(scopeA, []string{"sk_a"})
	syncer.seed(scopeB, nil, SkillCacheAuthoritativeEmpty)
	d.SetSkillSyncer(syncer)

	repoIDs, complete := d.CachedSkillRepoIDsSnapshot([]string{"agt_a", "agt_b"})
	require.True(t, complete)
	require.Equal(t, []string{"sk_a"}, repoIDs)
}

// TestCachedSkillPathsForAgent_NeverFallsBackToGlobalRecords is the
// defensive test reviewer1 asked for: even with global managed repository
// records present on disk, cachedSkillPathsForAgent only ever returns
// what RefreshSkillAccess cached. Stale global records are invisible to the
// launch path, even on cache miss.
func TestCachedSkillPathsForAgent_NeverFallsBackToGlobalRecords(t *testing.T) {
	t.Setenv(profile.ConfigDirEnvKey, t.TempDir())
	d := mustNewDispatcher("http://server.test")
	registerAgentTeamAgentForTest(t, d, "agt_a", "ch_a", "https://s", "ws")

	// Seed profile with two pre-existing global records (e.g. from a
	// prior session that has since been revoked). These must NOT show up
	// in cachedSkillPathsForAgent even on cache miss.
	mgr := profile.Default().ManagedSkillRepositories()
	require.NoError(t, mgr.Upsert(profile.ManagedSkillRepositoryRecord{
		RepoID: "sk_stale_1", CloneURL: "https://x/1.sk.git",
		Branch: "main", Revision: "abc", LocalPath: "/cache/sk-stale-1",
	}))
	require.NoError(t, mgr.Upsert(profile.ManagedSkillRepositoryRecord{
		RepoID: "sk_stale_2", CloneURL: "https://x/2.sk.git",
		Branch: "main", Revision: "def", LocalPath: "/cache/sk-stale-2",
	}))

	syncer := newFakeSkillSyncer()
	d.SetSkillSyncer(syncer)

	paths := d.cachedSkillPathsForAgent("agt_a")
	require.Nil(t, paths, "global managed records are not launch authority")
	require.Empty(t, syncer.syncCalls, "execution-launch must not call SyncAgent")
}

// TestAttachSkillSync_NoSkillSyncerReturnsNil pins the pre-MeshX-skill
// behavior: without SetSkillSyncer, attachSkillSync is a no-op returning
// nil, and cachedSkillPathsForAgent also returns nil.
func TestAttachSkillSync_NoSkillSyncerReturnsNil(t *testing.T) {
	t.Setenv(profile.ConfigDirEnvKey, t.TempDir())
	d := mustNewDispatcher("http://server.test")
	registerAgentTeamAgentForTest(t, d, "agt_a", "ch_a", "https://s", "ws")

	require.Nil(t, d.attachSkillSync(context.Background(), "agt_a"))
	require.Nil(t, d.cachedSkillPathsForAgent("agt_a"))
}
