package dispatcher

import (
	"context"
	"log/slog"
	"strings"

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

// SkillLookupState mirrors the LookupState from the provisioning/skill
// package without forcing the dispatcher to import it. It lets the
// dispatcher act correctly on transient lookup failures (do not change
// state) vs authoritative revocations (clear cache, update running
// sessions).
type SkillLookupState int

const (
	// SkillLookupUnknown means the lookup could not produce an authoritative
	// answer. Callers MUST NOT change cached or session state.
	SkillLookupUnknown SkillLookupState = iota
	// SkillLookupUnbound means the server authoritatively reports the agent
	// has no accessible skills. Safe to clear cache and update sessions.
	SkillLookupUnbound
	// SkillLookupBound means the server returned at least one accessible
	// skill. The returned paths are the authoritative set for the agent.
	SkillLookupBound
)

// SkillCacheState classifies what the in-memory cache currently holds for a
// scope. Launch paths use this to distinguish a real miss (no background sync
// has completed yet) from authoritative empty (server said agent has no
// skills) without putting a network sync on the launch path.
type SkillCacheState int

const (
	// SkillCacheMiss means no sync has completed for this scope.
	SkillCacheMiss SkillCacheState = iota
	// SkillCacheAuthoritativeEmpty means the most recent sync returned
	// SkillLookupUnbound. The cached paths slice is empty AND that emptiness
	// is authoritative.
	SkillCacheAuthoritativeEmpty
	// SkillCachePopulated means the most recent sync returned SkillLookupBound
	// with at least one accessible skill.
	SkillCachePopulated
)

// SkillSyncer resolves an agent's accessible MeshX skills and clones/fetches
// each backing AGS repository into ~/.agentteam/managed-skills/. Launch paths do
// not call SyncAgent; they read the most recent cache populated by
// RefreshSkillAccess, which is driven by realtime access-change events and the
// daemon's low-frequency background refresh.
//
// The global profile.ManagedSkillRepositories().All() records are never used
// as a source of launch paths — they are an audit/sync log, not an authority
// for what bindings an agent currently has.
//
// scopeKey is opaque to the implementor but must be stable for the same
// (agent, owning source channel, workspace, serverURL) tuple. The dispatcher
// computes it from the agent's owning AgentTeam source — not from any per-message
// binding channel — so the cache entry tracks the same scope the underlying
// resolver uses to query bindings.
//
// The contract is read-only with respect to dispatcher state: syncers must
// be safe to call from realtime and background refresh paths while launch
// paths concurrently read the cache.
type SkillSyncer interface {
	// SyncAgent clones (first time) or fetches (subsequent refreshes) each
	// unique AGS repo for the agent, upserts a ManagedSkillRepositoryRecord
	// per repo, and returns the local clone paths in dedupe order along with
	// the authoritative-ness of the result.
	//
	// Return contract:
	//   - Bound:   (paths, SkillLookupBound, nil)
	//   - Unbound: (nil,   SkillLookupUnbound, nil)  — authoritative empty
	//   - Unknown: (cached-or-nil, SkillLookupUnknown, err-or-nil)
	//
	// On Unknown the implementor MUST leave its cache unchanged so callers do
	// not lose a previously good snapshot to a transient resolver failure.
	SyncAgent(ctx context.Context, agentID, scopeKey string) ([]string, SkillLookupState, error)

	// CachedPaths returns the most recent authoritative SyncAgent
	// result for scopeKey, or nil when no sync has completed for that scope.
	CachedPaths(scopeKey string) []string

	// CachedRepoIDs returns the deduped AGS repository IDs the server
	// most recently reported as bound to this scope, or nil when no sync has
	// completed. Used by the daemon reconciler to detect local managed repos
	// that no longer belong to any agent. Performs no network work.
	CachedRepoIDs(scopeKey string) []string

	// CacheState reports the cache state for scopeKey. The second
	// return is false when no entry exists at all (true miss). Launch callers
	// never force sync on miss; they only trust AuthoritativeEmpty or Populated.
	CacheState(scopeKey string) (SkillCacheState, bool)
}

// ManagedSkillLaunchReady reports whether a fresh launch for agentID already
// has an authoritative managed-skill cache answer for its current AgentTeam scope.
//
// True means launch can proceed without waiting for a managed-skill refresh:
// either managed skill sync is disabled for this dispatcher, the agent has no
// AgentTeam-managed scope, or the cache is already populated/authoritatively empty.
// False means the scope is still a true cache miss.
func (d *skillService) ManagedSkillLaunchReady(agentID string) bool {
	agentID = strings.TrimSpace(agentID)
	if agentID == "" {
		return true
	}
	d.skillSyncerMu.RLock()
	syncer := d.skillSyncer
	d.skillSyncerMu.RUnlock()
	if syncer == nil {
		return true
	}
	scopeKey, ok := d.computeSkillScopeKey(agentID)
	if !ok {
		return true
	}
	state, has := syncer.CacheState(scopeKey)
	if !has {
		return false
	}
	return state == SkillCachePopulated || state == SkillCacheAuthoritativeEmpty
}

// SetSkillSyncer wires the skill sync producer. Optional: when nil or never
// called, the dispatcher runs without managed skill sync — equivalent to
// pre-MeshX-skill behavior. Calling this with a nil argument is a no-op.
func (d *skillService) SetSkillSyncer(syncer SkillSyncer) {
	if syncer == nil {
		return
	}
	d.skillSyncerMu.Lock()
	d.skillSyncer = syncer
	d.skillSyncerMu.Unlock()
}

// skillConnScopeProvider is satisfied by AgentTeam channels that can report the
// stable connection scope used to key the per-agent skill sync cache. The
// shape mirrors skill.ConnectionInfoProvider but is kept local so the
// dispatcher does not import skill.
type skillConnScopeProvider interface {
	ServerURL() string
	WorkspaceID() string
}

// computeSkillScopeKey builds the opaque cache key the SkillSyncer uses to
// store and look up the most recent sync result for an agent. The key is
// composed from:
//   - agentID
//   - the agent's owning AgentTeam source channel ID (agent.Source.ID), NOT the
//     per-message binding channel — the same agent routed via a different
//     binding channel still resolves through its owning source channel, so
//     the binding channel must not influence the cache key
//   - the channel's ServerURL + WorkspaceID at key-composition time
//
// Returns ("", false) when the agent has no AgentTeam source or the channel is
// not currently registered. Callers must treat that as "no skill sync" and
// skip both background refresh and cached path lookup.
func (d *skillService) computeSkillScopeKey(agentID string) (string, bool) {
	scopeKey, ok := d.computeAgentTeamAgentScopeKey(agentID)
	return scopeKey, ok
}

func (d *Dispatcher) computeAgentTeamAgentScopeKey(agentID string) (string, bool) {
	agentID = strings.TrimSpace(agentID)
	if agentID == "" {
		return "", false
	}
	ag, ok := profile.Default().Agents().Get(agentID)
	if !ok {
		return "", false
	}
	if strings.TrimSpace(ag.Source.Type) != "agentteam" {
		return "", false
	}
	channelID := strings.TrimSpace(ag.Source.ID)
	if channelID == "" {
		return "", false
	}
	scopeKey := agentID + "@" + channelID
	if d != nil && d.serverConnections != nil {
		if ch, ok := d.serverConnections.ServerConnection(channelID); ok {
			if conn, ok := ch.(skillConnScopeProvider); ok {
				scopeKey += "#" + strings.TrimSpace(conn.ServerURL()) +
					":" + strings.TrimSpace(conn.WorkspaceID())
			}
		}
	}
	return scopeKey, true
}

// attachSkillSync returns the best available cached managed skill paths for
// session launch. It never calls SyncAgent: managed skill fetch is kept off
// the agent startup path and is driven only by realtime access-change events
// plus the daemon background refresh agentteam.
//
// Returns the local clone paths for this agent in this scope (possibly empty
// when the agent has no accessible skills). A cache miss returns nil so the
// session can start immediately; the next refresh will populate the cache.
//
// Runtime materialization is launch-time snapshot semantics: paths returned
// here take effect for THIS session's launch and for subsequent
// execution-launch sub-agents via cachedSkillPathsForAgent. A mid-session
// binding change does NOT retroactively add/remove skills to an already
// running agent process — see RefreshSkillAccess for the lifecycle
// boundaries.
func (d *skillService) attachSkillSync(_ context.Context, agentID string) []string {
	agentID = strings.TrimSpace(agentID)
	if agentID == "" {
		return nil
	}
	d.skillSyncerMu.RLock()
	syncer := d.skillSyncer
	d.skillSyncerMu.RUnlock()
	if syncer == nil {
		return nil
	}
	scopeKey, ok := d.computeSkillScopeKey(agentID)
	if !ok {
		return nil
	}
	if state, has := syncer.CacheState(scopeKey); has {
		switch state {
		case SkillCachePopulated:
			return syncer.CachedPaths(scopeKey)
		case SkillCacheAuthoritativeEmpty:
			return nil
		}
	}
	return nil
}

// RefreshSkillAccess re-evaluates skill bindings for the given agents. Called
// when the server notifies that skill access has changed (bound or revoked)
// via the unified agent:assets:access-changed event while a session is
// already running, or by the daemon's low-frequency background refresh.
//
// For each agent:
//   - Call SyncAgent. The syncer preserves cache on Unknown and
//     overwrites on Bound/Unbound.
//   - If a session exists and the state is authoritative (Bound or Unbound),
//     update sess.managedSkillPaths so the next execution-launch sub-agent
//     picks up the new bindings. The running leader agent process itself is
//     NOT restarted — runtime materialization is launch-time snapshot.
//   - On Unknown, leave sess.managedSkillPaths alone.
//
// Agents without active sessions still refresh the syncer's cache; their next
// session start reads that cache without doing network work.
func (d *skillService) RefreshSkillAccess(ctx context.Context, agentIDs []string) {
	for _, agentID := range agentIDs {
		agentID = strings.TrimSpace(agentID)
		if agentID == "" {
			continue
		}
		d.refreshAgentSkillAccess(ctx, agentID)
	}
}

// CachedSkillRepoIDsSnapshot returns the union of cached AGS repository IDs
// plus a completeness flag. complete=false means at least one AgentTeam-sourced agent has no
// authoritative skill cache entry for its current connection scope, so callers
// must not treat the returned RepoID set as safe for orphan deletion.
func (d *skillService) CachedSkillRepoIDsSnapshot(agentIDs []string) ([]string, bool) {
	d.skillSyncerMu.RLock()
	syncer := d.skillSyncer
	d.skillSyncerMu.RUnlock()
	if syncer == nil {
		return nil, false
	}
	seen := make(map[string]struct{})
	complete := true
	for _, agentID := range agentIDs {
		scopeKey, ok := d.computeSkillScopeKey(agentID)
		if !ok {
			complete = false
			continue
		}
		state, has := syncer.CacheState(scopeKey)
		if !has {
			complete = false
			continue
		}
		if state != SkillCachePopulated {
			continue
		}
		for _, repoID := range syncer.CachedRepoIDs(scopeKey) {
			if id := strings.TrimSpace(repoID); id != "" {
				seen[id] = struct{}{}
			}
		}
	}
	out := make([]string, 0, len(seen))
	for id := range seen {
		out = append(out, id)
	}
	return out, complete
}

func (d *skillService) refreshAgentSkillAccess(ctx context.Context, agentID string) {
	d.skillSyncerMu.RLock()
	syncer := d.skillSyncer
	d.skillSyncerMu.RUnlock()
	if syncer == nil {
		slog.InfoContext(ctx, "skill access refresh: no syncer wired; skipping",
			"agent_id", agentID)
		return
	}
	scopeKey, ok := d.computeSkillScopeKey(agentID)
	if !ok {
		// Agent has no AgentTeam source — refresh is a no-op. This is expected
		// for non-AgentTeam agents or agents whose source channel is gone.
		return
	}

	paths, state, err := syncer.SyncAgent(ctx, agentID, scopeKey)
	if err != nil {
		slog.WarnContext(ctx, "skill access refresh: sync failed",
			"agent_id", agentID, "state", skillStateLabel(state), "error", err)
		return
	}
	if state == SkillLookupUnknown {
		// Transient — syncer preserved cache. Do not touch session state.
		slog.InfoContext(ctx, "skill access refresh: lookup unknown; preserving session skill paths",
			"agent_id", agentID)
		return
	}

	sess, ok := d.sessionManager.FindByLookupID(agentID)
	if !ok || sess == nil {
		slog.InfoContext(ctx, "skill access refresh: no active session; next session start will apply",
			"agent_id", agentID, "state", skillStateLabel(state))
		return
	}
	sess.mu.Lock()
	sess.managedSkillPaths = paths
	sess.mu.Unlock()
	slog.InfoContext(ctx, "skill access refresh: updated running session skill paths",
		"agent_id", agentID, "state", skillStateLabel(state), "repositories", len(paths))
}

// cachedSkillPathsForAgent is the execution-launch path's view of the skill
// sync cache. It rebuilds the same scopeKey attachSkillSync used (deterministic
// from the agent's owning AgentTeam source) and asks the SkillSyncer for the last
// cached result.
//
// Cache-state semantics:
//   - Populated: return cached paths.
//   - AuthoritativeEmpty: return nil — server confirmed no bindings.
//   - Miss: return nil. Fetching is owned by realtime/background refresh, not
//     the execution-launch path.
//
// The global profile.ManagedSkillRepositories().All() records are NEVER
// consulted as a fallback — they are an audit/sync log, not an authority
// for what bindings an agent currently has.
func (d *skillService) cachedSkillPathsForAgent(agentID string) []string {
	agentID = strings.TrimSpace(agentID)
	if agentID == "" {
		return nil
	}
	d.skillSyncerMu.RLock()
	syncer := d.skillSyncer
	d.skillSyncerMu.RUnlock()
	if syncer == nil {
		return nil
	}
	scopeKey, ok := d.computeSkillScopeKey(agentID)
	if !ok {
		return nil
	}

	state, has := syncer.CacheState(scopeKey)
	if has {
		switch state {
		case SkillCachePopulated:
			return syncer.CachedPaths(scopeKey)
		case SkillCacheAuthoritativeEmpty:
			return nil
		}
	}
	return nil
}

// skillStateLabel returns a lowercase string for log/diagnostic output.
func skillStateLabel(s SkillLookupState) string {
	switch s {
	case SkillLookupBound:
		return "bound"
	case SkillLookupUnbound:
		return "unbound"
	default:
		return "unknown"
	}
}
