package skill

import (
	"context"
	"errors"
	"fmt"
	"log/slog"
	"os"
	"path/filepath"
	"strings"
	"sync"
	"time"

	gittransport "github.com/go-git/go-git/v5/plumbing/transport/http"
	"github.com/tidbcloud/AgentTeam/desktop/internal/dispatcher"
	"github.com/tidbcloud/AgentTeam/desktop/internal/profile"
	"golang.org/x/sync/singleflight"
)

// GitRunner abstracts the git operations the skill sync needs. Production
// code uses *profile.DefaultGitRunner (go-git backed); tests inject a fake
// to avoid spawning real git or hitting the network. The consumer owns this
// narrow interface.
type GitRunner interface {
	Clone(ctx context.Context, url, branch, dest string, auth gittransport.AuthMethod) (revision string, err error)
	FetchCheckout(ctx context.Context, repoDir, branch string, auth gittransport.AuthMethod) (revision string, err error)
	RemoteURL(repoDir string) (url string, err error)
}

// ProviderFunc is the lookup signature the sync producer depends on. The
// concrete *Provider satisfies it; tests can substitute a stub. The
// LookupState return lets the producer distinguish an authoritative "agent
// has no skill bindings" (Unbound) from a transient "we could not reach
// the resolver/server" (Unknown) — see LookupState for the full contract.
type ProviderFunc func(ctx context.Context, agentID string) ([]SkillAccess, dispatcher.SkillLookupState, error)

// cacheEntry is the per-scope cache record. The state field distinguishes
// authoritative empty from miss so launch/execution paths can start from cache
// while background refresh owns network sync.
type cacheEntry struct {
	paths   []string
	repoIDs []string
	state   CacheState
}

// Sync is the production skill sync producer. For a given agent it:
//  1. Resolves the agent's accessible skills via the MeshX
//     /api/v1/skills/agents/access:batch endpoint (through Provider).
//  2. Clones (first time) or fetches (subsequent refreshes) each unique
//     AGS repository into ~/.agentteam/managed-skills/<sanitized-repoID>.
//  3. Upserts a ManagedSkillRepositoryRecord capturing repo/name/branch/revision/
//     local path/sync time/skill count/sync state so the runtime materializer
//     picks the clone up at launch and operators can diagnose failures.
//
// Sync honors LookupState:
//   - LookupBound: clone/fetch each repo, cache populated paths.
//   - LookupUnbound: cache an authoritative-empty entry; clear is intentional.
//   - LookupUnknown: do NOT touch the cache; return the previously cached
//     paths (or nil) so transient failures do not wipe a good snapshot.
//
// Sync also dedupes repository work in two layers:
//   - singleflight collapses concurrent clone/fetch calls for the same RepoID;
//   - a short recent-sync window lets sequential agent refreshes share an
//     already-fetched local clone when multiple agents bind the same skill repo.
type Sync struct {
	provider ProviderFunc
	git      GitRunner
	manager  *profile.ManagedSkillRepositoryManager

	cacheMu sync.RWMutex
	cache   map[string]cacheEntry

	// sf dedupes in-flight clone/fetch operations per RepoID so concurrent
	// background or realtime refreshes do not stomp the same on-disk clone.
	sf singleflight.Group

	repoMu          sync.Mutex
	repoLastSynced  map[string]time.Time
	repoReuseWindow time.Duration
}

// NewSync builds a Sync that uses provider to resolve bindings, git to
// clone/fetch, and manager to persist local repository records.
func NewSync(provider ProviderFunc, git GitRunner, manager *profile.ManagedSkillRepositoryManager) *Sync {
	return &Sync{
		provider:        provider,
		git:             git,
		manager:         manager,
		cache:           make(map[string]cacheEntry),
		repoLastSynced:  make(map[string]time.Time),
		repoReuseWindow: repoSyncReuseWindow,
	}
}

// SyncAgent resolves the agent's accessible skills, syncs each unique
// AGS repo locally, and returns the local clone paths for this agent.
//
// Return contract:
//   - Bound: (paths, LookupBound, nil). paths is the list of cloned repo
//     directories; cache is set to CachePopulated.
//   - Unbound: (nil, LookupUnbound, nil). cache is set to
//     CacheAuthoritativeEmpty.
//   - Unknown: (cached-or-nil, LookupUnknown, err-or-nil). cache is left
//     UNCHANGED so callers do not lose a previously good snapshot. The
//     returned paths are whatever the cache currently holds (nil on first
//     call), letting launch/execution paths proceed with the best available
//     cached view while background refresh owns retry.
//
// scopeKey is opaque to Sync but must be stable for the same
// (agent, owning source channel, workspace, serverURL) tuple. The dispatcher
// composes 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.
func (s *Sync) SyncAgent(ctx context.Context, agentID, scopeKey string) ([]string, dispatcher.SkillLookupState, error) {
	agentID = strings.TrimSpace(agentID)
	scopeKey = strings.TrimSpace(scopeKey)
	if agentID == "" {
		// Degenerate input — preserve cache, surface Unknown so callers do not
		// treat this as a binding change.
		cached := s.CachedPaths(scopeKey)
		return cached, LookupUnknown, nil
	}
	if s.provider == nil {
		cached := s.CachedPaths(scopeKey)
		return cached, LookupUnknown, errors.New("skill sync: nil provider")
	}

	access, state, err := s.provider(ctx, agentID)
	if err != nil {
		// Provider returned a wrapped error alongside Unknown. Surface both
		// without touching the cache so callers see the failure but the prior
		// good snapshot (if any) is preserved.
		slog.WarnContext(ctx, "skill sync: lookup failed; preserving cache",
			"agent_id", agentID, "state", skillStateLabel(state), "error", err)
		cached := s.CachedPaths(scopeKey)
		return cached, LookupUnknown, fmt.Errorf("skill sync: lookup: %w", err)
	}

	switch state {
	case LookupBound:
		if len(access) == 0 {
			// Defensive: provider declared Bound but returned no entries. Treat
			// as Unknown so we never cache a phantom "bound but empty" state.
			slog.WarnContext(ctx, "skill sync: provider returned bound with no entries; treating as unknown",
				"agent_id", agentID)
			cached := s.CachedPaths(scopeKey)
			return cached, LookupUnknown, errors.New("skill sync: bound state with empty access")
		}
		paths := s.syncRepositories(ctx, agentID, access)
		s.cacheStore(scopeKey, paths, uniqueRepoIDs(access), CachePopulated)
		return paths, LookupBound, nil

	case LookupUnbound:
		// Authoritative empty: overwrite cache so execution-launch stops
		// surfacing stale paths from a prior binding.
		s.cacheStore(scopeKey, nil, nil, CacheAuthoritativeEmpty)
		return nil, LookupUnbound, nil

	default: // LookupUnknown or any future state
		// Resolver/transient failure: do NOT touch cache. Surface the prior
		// snapshot so a session can still start with the last-known set.
		slog.WarnContext(ctx, "skill sync: lookup unknown; preserving cache",
			"agent_id", agentID)
		cached := s.CachedPaths(scopeKey)
		return cached, LookupUnknown, nil
	}
}

// SyncForAgent is kept as a compatibility wrapper for callers still using
// the previous agent-scoped method name.
func (s *Sync) SyncForAgent(ctx context.Context, agentID, scopeKey string) ([]string, dispatcher.SkillLookupState, error) {
	paths, state, err := s.SyncAgent(ctx, agentID, scopeKey)
	return paths, state, err
}

// syncRepositories clones/fetches each unique AGS repo for the agent and
// returns the local clone paths in dedupe order. Per-repo failures are
// logged, recorded as LastSyncState=Failed on the local record, and skipped
// — a single broken repository does not block the rest. Each repo is
// deduped via singleflight so concurrent SyncAgent calls targeting the
// same RepoID share a single clone/fetch. Sequential refreshes within the
// reuse window read the ready local record instead of fetching again.
func (s *Sync) syncRepositories(ctx context.Context, agentID string, access []SkillAccess) []string {
	seen := make(map[string]bool, len(access))
	paths := make([]string, 0, len(access))
	for _, sk := range access {
		repoID := strings.TrimSpace(sk.RepoID)
		if repoID == "" {
			slog.WarnContext(ctx, "skill sync: skipping skill with empty repo_id",
				"agent_id", agentID, "skill_id", sk.SkillID)
			continue
		}
		if seen[repoID] {
			continue
		}
		seen[repoID] = true

		if path, ok := s.reusableRepositoryPath(sk); ok {
			paths = append(paths, path)
			continue
		}

		// singleflight dedupes concurrent syncs of the same repo across
		// goroutines. The inner closure re-checks the recent-sync window in
		// case another caller finished while this caller was waiting.
		v, err, _ := s.sf.Do(repoKey(repoID), func() (any, error) {
			if path, ok := s.reusableRepositoryPath(sk); ok {
				return path, nil
			}
			path, syncErr := s.syncOneRepository(ctx, sk)
			if syncErr != nil {
				return nil, syncErr
			}
			s.markRepositorySynced(sk)
			return path, nil
		})
		if err != nil {
			slog.WarnContext(ctx, "skill sync: repository sync failed; skipping",
				"agent_id", agentID, "repo_id", repoID, "skill_id", sk.SkillID, "error", err)
			continue
		}
		if path, ok := v.(string); ok && path != "" {
			paths = append(paths, path)
		}
	}
	return paths
}

// repoKey namespaces singleflight keys under "repo:" so future per-scope or
// per-agent flights cannot collide.
func repoKey(repoID string) string {
	return "repo:" + repoID
}

const repoSyncReuseWindow = 10 * time.Minute

func (s *Sync) reusableRepositoryPath(sk SkillAccess) (string, bool) {
	if s == nil || s.manager == nil {
		return "", false
	}
	repoID := strings.TrimSpace(sk.RepoID)
	if repoID == "" {
		return "", false
	}
	reuseWindow := s.repoReuseWindow
	if reuseWindow <= 0 {
		reuseWindow = repoSyncReuseWindow
	}

	s.repoMu.Lock()
	last, ok := s.repoLastSynced[repoKey(repoID)]
	s.repoMu.Unlock()
	if !ok || !last.Add(reuseWindow).After(time.Now().UTC()) {
		return "", false
	}

	rec, ok := s.manager.Get(repoID)
	if !ok || !managedSkillRecordMatches(rec, sk) || !managedSkillRecordReadyOnDisk(rec) {
		return "", false
	}
	return rec.LocalPath, true
}

func (s *Sync) markRepositorySynced(sk SkillAccess) {
	if s == nil {
		return
	}
	repoID := strings.TrimSpace(sk.RepoID)
	if repoID == "" {
		return
	}
	s.repoMu.Lock()
	defer s.repoMu.Unlock()
	if s.repoLastSynced == nil {
		s.repoLastSynced = make(map[string]time.Time)
	}
	s.repoLastSynced[repoKey(repoID)] = time.Now().UTC()
}

func managedSkillRecordMatches(rec profile.ManagedSkillRepositoryRecord, sk SkillAccess) bool {
	return strings.TrimSpace(rec.RepoID) == strings.TrimSpace(sk.RepoID) &&
		strings.TrimSpace(rec.Branch) == strings.TrimSpace(sk.Branch) &&
		strings.TrimSpace(rec.CloneURL) == strings.TrimSpace(sk.CloneURL)
}

func managedSkillRecordReadyOnDisk(rec profile.ManagedSkillRepositoryRecord) bool {
	if strings.TrimSpace(rec.LastSyncState) == profile.SkillSyncStateFailed {
		return false
	}
	localPath := strings.TrimSpace(rec.LocalPath)
	if localPath == "" {
		return false
	}
	info, err := os.Stat(localPath)
	return err == nil && info.IsDir()
}

// CachedPaths returns the most recent successful SyncAgent result
// for scopeKey, or nil when no sync has completed for that scope. The
// returned slice is shared (not copied); callers must treat it as read-only.
// Launch/execution paths read this so they do not trigger a fresh network
// round-trip per attempt — they reuse the latest realtime/background refresh
// result.
//
// Note: a nil return means "no cached snapshot" — it does NOT distinguish
// authoritative empty from miss. Use CacheState when that
// distinction matters.
func (s *Sync) CachedPaths(scopeKey string) []string {
	scopeKey = strings.TrimSpace(scopeKey)
	if scopeKey == "" {
		return nil
	}
	s.cacheMu.RLock()
	defer s.cacheMu.RUnlock()
	entry, ok := s.cache[scopeKey]
	if !ok {
		return nil
	}
	return entry.paths
}

// CachedRepoIDs returns the deduped AGS repository IDs the server most
// recently reported as bound to this scope, or nil when no sync has completed.
// Unlike CachedPaths this reflects the authoritative binding set
// (independent of whether each clone succeeded), so the daemon reconciler can
// safely treat any local managed repo whose RepoID is absent from the union
// across all agents as an orphan to remove. It performs no network work.
func (s *Sync) CachedRepoIDs(scopeKey string) []string {
	scopeKey = strings.TrimSpace(scopeKey)
	if scopeKey == "" {
		return nil
	}
	s.cacheMu.RLock()
	defer s.cacheMu.RUnlock()
	entry, ok := s.cache[scopeKey]
	if !ok {
		return nil
	}
	return entry.repoIDs
}

// CachedPathsForAgent is kept as a compatibility wrapper for callers still
// using the previous agent-scoped method name.
func (s *Sync) CachedPathsForAgent(scopeKey string) []string {
	paths := s.CachedPaths(scopeKey)
	return paths
}

// CachedRepoIDsForAgent is kept as a compatibility wrapper for callers still
// using the previous agent-scoped method name.
func (s *Sync) CachedRepoIDsForAgent(scopeKey string) []string {
	repoIDs := s.CachedRepoIDs(scopeKey)
	return repoIDs
}

// uniqueRepoIDs returns the deduped, trimmed, non-empty RepoIDs from a skill
// access slice. Order follows first occurrence.
func uniqueRepoIDs(access []SkillAccess) []string {
	seen := make(map[string]struct{}, len(access))
	out := make([]string, 0, len(access))
	for _, sk := range access {
		id := strings.TrimSpace(sk.RepoID)
		if id == "" {
			continue
		}
		if _, dup := seen[id]; dup {
			continue
		}
		seen[id] = struct{}{}
		out = append(out, id)
	}
	return out
}

// CacheState reports the cache state for scopeKey. The second return
// is false when no entry exists at all (true miss). Launch/execution callers
// use this to distinguish "no refresh completed yet" from authoritative empty
// or populated cache; they never force SyncAgent on the launch path.
func (s *Sync) CacheState(scopeKey string) (dispatcher.SkillCacheState, bool) {
	scopeKey = strings.TrimSpace(scopeKey)
	if scopeKey == "" {
		return CacheMiss, false
	}
	s.cacheMu.RLock()
	defer s.cacheMu.RUnlock()
	entry, ok := s.cache[scopeKey]
	if !ok {
		return CacheMiss, false
	}
	return entry.state, true
}

// CacheStateForAgent is kept as a compatibility wrapper for callers still
// using the previous agent-scoped method name.
func (s *Sync) CacheStateForAgent(scopeKey string) (dispatcher.SkillCacheState, bool) {
	state, ok := s.CacheState(scopeKey)
	return state, ok
}

// cacheStore writes paths+repoIDs+state under scopeKey, replacing any prior
// entry. A nil paths slice with CacheAuthoritativeEmpty is a valid value — it
// means "this scope currently has no managed skills and that is authoritative".
// repoIDs is the deduped set of AGS repository IDs the server says this scope
// is bound to (regardless of clone success); the daemon reconciler uses it to
// detect local managed repos that no longer belong to any agent.
func (s *Sync) cacheStore(scopeKey string, paths, repoIDs []string, state CacheState) {
	scopeKey = strings.TrimSpace(scopeKey)
	if scopeKey == "" {
		return
	}
	s.cacheMu.Lock()
	defer s.cacheMu.Unlock()
	if s.cache == nil {
		s.cache = make(map[string]cacheEntry)
	}
	s.cache[scopeKey] = cacheEntry{paths: paths, repoIDs: repoIDs, state: state}
}

// syncOneRepository clones or fetches a single AGS repo and upserts the local
// record with LastSyncState metadata. Returns the local clone path on
// success. On failure, the record's LastSyncState is set to Failed with the
// error message so callers can surface diagnostics. Failures normally preserve
// the existing local clone; stale clones whose origin no longer matches the
// server's current repository identity are removed before re-clone because they
// cannot be safely fetched with the current sync lease.
func (s *Sync) syncOneRepository(ctx context.Context, sk SkillAccess) (string, error) {
	if s.git == nil {
		s.markRepositoryFailed(sk, "skill sync: nil git runner")
		return "", errors.New("skill sync: nil git runner")
	}
	if s.manager == nil {
		s.markRepositoryFailed(sk, "skill sync: nil repository manager")
		return "", errors.New("skill sync: nil repository manager")
	}

	repoPath := s.manager.RepositoryPath(sk.RepoID)
	now := time.Now().UTC()

	existing, hasExisting := s.manager.Get(sk.RepoID)
	var revision string
	var err error
	if _, statErr := os.Stat(repoPath); statErr == nil {
		if s.managedRepositoryCloneMatches(ctx, repoPath, sk) {
			revision, err = s.git.FetchCheckout(ctx, repoPath, sk.Branch, sk.Auth)
		} else {
			if removeErr := os.RemoveAll(repoPath); removeErr != nil {
				s.markRepositoryFailed(sk, removeErr.Error())
				return "", fmt.Errorf("replace stale skill repository %s: remove %s: %w", sk.RepoID, repoPath, removeErr)
			}
			revision, err = s.git.Clone(ctx, sk.CloneURL, sk.Branch, repoPath, sk.Auth)
		}
	} else if os.IsNotExist(statErr) {
		// Local clone missing on disk or no record exists yet — clone from the
		// server's current repository identity.
		revision, err = s.git.Clone(ctx, sk.CloneURL, sk.Branch, repoPath, sk.Auth)
	} else {
		s.markRepositoryFailed(sk, statErr.Error())
		return "", fmt.Errorf("inspect skill repository %s: %w", sk.RepoID, statErr)
	}
	if err != nil {
		s.markRepositoryFailed(sk, err.Error())
		return "", fmt.Errorf("sync skill repository %s: %w", sk.RepoID, err)
	}

	skillCount := countSkills(repoPath)
	retryCount := 0
	if hasExisting {
		retryCount = existing.RetryCount
	}
	rec := profile.ManagedSkillRepositoryRecord{
		RepoID:        sk.RepoID,
		Name:          sk.Name,
		CloneURL:      sk.CloneURL,
		Branch:        sk.Branch,
		Revision:      revision,
		LocalPath:     repoPath,
		LastSyncAt:    now,
		SkillCount:    skillCount,
		LastSyncState: profile.SkillSyncStateReady,
		LastAttemptAt: now,
		RetryCount:    retryCount,
	}
	if upsertErr := s.manager.Upsert(rec); upsertErr != nil {
		slog.WarnContext(ctx, "skill sync: failed to upsert ready record",
			"repo_id", sk.RepoID, "error", upsertErr)
	}
	slog.InfoContext(ctx, "skill sync: repository synced",
		"repo_id", sk.RepoID, "skill_id", sk.SkillID, "revision", revision,
		"skill_count", skillCount, "path", repoPath)
	return repoPath, nil
}

func (s *Sync) managedRepositoryCloneMatches(ctx context.Context, repoPath string, sk SkillAccess) bool {
	remoteURL, err := s.git.RemoteURL(repoPath)
	if err != nil {
		slog.InfoContext(ctx, "skill sync: replacing unreadable managed repository clone",
			"repo_id", sk.RepoID, "path", repoPath, "error", err)
		return false
	}
	if strings.TrimSpace(remoteURL) == strings.TrimSpace(sk.CloneURL) {
		return true
	}
	slog.InfoContext(ctx, "skill sync: replacing stale managed repository clone",
		"repo_id", sk.RepoID, "path", repoPath)
	return false
}

// markRepositoryFailed records a Failed LastSyncState on the repo's
// ManagedSkillRepositoryRecord so callers (status API, UI) can surface the
// failure. The caller owns local clone cleanup; this method only records the
// latest diagnostic. Errors here are best-effort: we are already on a failure
// path.
func (s *Sync) markRepositoryFailed(sk SkillAccess, message string) {
	if s.manager == nil {
		return
	}
	existing, hasExisting := s.manager.Get(sk.RepoID)
	now := time.Now().UTC()
	rec := profile.ManagedSkillRepositoryRecord{
		RepoID:        sk.RepoID,
		Name:          sk.Name,
		CloneURL:      sk.CloneURL,
		Branch:        sk.Branch,
		LastSyncState: profile.SkillSyncStateFailed,
		LastError:     truncateErr(message),
		LastAttemptAt: now,
	}
	if hasExisting {
		rec.Revision = existing.Revision
		rec.LocalPath = existing.LocalPath
		rec.LastSyncAt = existing.LastSyncAt
		rec.SkillCount = existing.SkillCount
		rec.RetryCount = existing.RetryCount + 1
	} else {
		rec.RetryCount = 1
	}
	if err := s.manager.Upsert(rec); err != nil {
		slog.Warn("skill sync: failed to upsert failure record",
			"repo_id", sk.RepoID, "error", err)
	}
}

// truncateErr caps stored error messages so a runaway upstream error does
// not bloat the YAML record.
func truncateErr(s string) string {
	const max = 512
	if len(s) <= max {
		return s
	}
	return s[:max-3] + "..."
}

// skillStateLabel returns a lowercase string for log/diagnostic output. It
// mirrors dispatcher.skillStateLabel but is kept local so the sync producer
// can log state values without an extra import dance.
func skillStateLabel(s dispatcher.SkillLookupState) string {
	switch s {
	case dispatcher.SkillLookupBound:
		return "bound"
	case dispatcher.SkillLookupUnbound:
		return "unbound"
	default:
		return "unknown"
	}
}

// countSkills returns the number of recognized SKILL.md entries in a managed
// .sk repo. The server-side scanner supports three layouts and the advisory
// Desktop count must stay aligned with it:
//
//   - SKILL.md at repository root (single-skill layout)
//   - <dir>/SKILL.md (collection layout)
//   - skills/<slug>/SKILL.md (default Create template layout)
//
// Errors are treated as zero because the count is advisory.
func countSkills(dir string) int {
	entries, err := os.ReadDir(dir)
	if err != nil {
		return 0
	}
	count := 0
	if hasSkillFile(filepath.Join(dir, "SKILL.md")) {
		count++
	}
	for _, entry := range entries {
		if !entry.IsDir() {
			continue
		}
		if hasSkillFile(filepath.Join(dir, entry.Name(), "SKILL.md")) {
			count++
		}
	}
	skillsDir := filepath.Join(dir, "skills")
	if skillsEntries, err := os.ReadDir(skillsDir); err == nil {
		for _, entry := range skillsEntries {
			if entry.IsDir() && hasSkillFile(filepath.Join(skillsDir, entry.Name(), "SKILL.md")) {
				count++
			}
		}
	}
	return count
}

func hasSkillFile(path string) bool {
	info, err := os.Lstat(path)
	return err == nil && info.Mode().IsRegular()
}
