package profile

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

	"github.com/go-git/go-git/v5"
	"github.com/go-git/go-git/v5/config"
	"github.com/go-git/go-git/v5/plumbing"
	gittransport "github.com/go-git/go-git/v5/plumbing/transport/http"
)

// ManagedSkillRepositoryRecord describes one remote skill repository that has
// been cloned into the local managed-skills directory. The MeshX skill backend
// owns the canonical binding metadata; this record is the Desktop-side cache
// used to drive runtime materialization.
//
// IMPORTANT: this record is NOT an authority for which skills an agent can
// access. The MeshX /api/v1/skills/agents/access:batch endpoint is. Callers
// that need current bindings must use the dispatcher skill-sync cache populated
// by realtime/background refresh, never read All()/Get() to derive them.
type ManagedSkillRepositoryRecord struct {
	// RepoID is the MeshX skill repository identifier (e.g. "sk_xxx").
	RepoID string `yaml:"repo_id"`
	// Name is a human-readable display name.
	Name string `yaml:"name,omitempty"`
	// CloneURL is the HTTPS URL used by the go-git backed sync runner.
	// Credential material is supplied out-of-band via go-git Auth; the URL
	// itself must not carry embedded user:password tokens.
	CloneURL string `yaml:"clone_url"`
	// Branch is the single branch this repository is bound to locally.
	Branch string `yaml:"branch"`
	// Revision is the commit SHA currently checked out in LocalPath.
	Revision string `yaml:"revision,omitempty"`
	// LocalPath is the absolute path of the cloned working tree.
	LocalPath string `yaml:"local_path"`
	// LastSyncAt is when the local clone was last synced from the remote.
	LastSyncAt time.Time `yaml:"last_sync_at,omitempty"`
	// SkillCount is the number of SKILL.md entries discovered at last scan.
	SkillCount int `yaml:"skill_count,omitempty"`
	// LastSyncState is the outcome of the most recent sync attempt. Values
	// match the SkillSyncState* constants below. Empty is treated as Ready
	// for records written by older clients.
	LastSyncState string `yaml:"last_sync_state,omitempty"`
	// LastError holds a short diagnostic message when LastSyncState is Failed.
	// It is truncated to keep the YAML record small.
	LastError string `yaml:"last_error,omitempty"`
	// LastAttemptAt is when the sync producer last tried to sync this repo,
	// whether or not it succeeded. Useful for surfacing "stale" repos whose
	// last attempt was long ago.
	LastAttemptAt time.Time `yaml:"last_attempt_at,omitempty"`
	// RetryCount is the number of consecutive failed sync attempts since the
	// last successful sync. Reset to 0 on success.
	RetryCount int `yaml:"retry_count,omitempty"`
}

// SkillSyncState* constants classify the most recent sync attempt for a
// managed skill repository. They surface through the daemon status API so
// operators can diagnose stale or failing repos.
const (
	// SkillSyncStatePending means the record has been created but no clone has
	// completed yet (reserved for future use; current code upserts only after
	// success/failure).
	SkillSyncStatePending = "pending"
	// SkillSyncStateSyncing means a clone/fetch is currently in flight.
	// Reserved for future use; included here so status consumers can handle it
	// without panic on unknown values.
	SkillSyncStateSyncing = "syncing"
	// SkillSyncStateReady means the most recent clone/fetch succeeded.
	SkillSyncStateReady = "ready"
	// SkillSyncStateFailed means the most recent clone/fetch failed; see
	// LastError for details and RetryCount for backoff decisions.
	SkillSyncStateFailed = "failed"
)

type managedSkillRepositoriesFile struct {
	Repositories []ManagedSkillRepositoryRecord `yaml:"repositories"`
}

// ManagedSkillRepositoryManager owns the local cache of remote skill
// repositories. It is deliberately stateless across calls: each method re-reads
// the on-disk YAML
// under the same flock used by other profile sub-managers.
type ManagedSkillRepositoryManager struct {
	mgr *Manager
}

// ManagedSkillRepositories returns the managed skill repository sub-manager.
func (m *Manager) ManagedSkillRepositories() *ManagedSkillRepositoryManager {
	return &ManagedSkillRepositoryManager{mgr: m}
}

// Dir is the root directory holding every cloned managed skill repository. It is
// intentionally separate from the manual skills directory returned by
// SkillManager.Dir(), so manual imports under ~/.agentteam/skills are never
// overwritten by remote syncs.
func (m *ManagedSkillRepositoryManager) Dir() string {
	return filepath.Join(m.mgr.configDir, "managed-skills")
}

// RepositoryPath is the local clone path for a single managed repository.
func (m *ManagedSkillRepositoryManager) RepositoryPath(repoID string) string {
	return filepath.Join(m.Dir(), sanitizeRepoIDForPath(repoID))
}

func (m *ManagedSkillRepositoryManager) filePath() string {
	return m.mgr.filePath("managed_skills.yaml")
}

// All returns every managed skill repository record currently on disk.
func (m *ManagedSkillRepositoryManager) All() []ManagedSkillRepositoryRecord {
	m.mgr.mu.RLock()
	defer m.mgr.mu.RUnlock()
	sf := &managedSkillRepositoriesFile{}
	if err := loadYAMLFile(m.filePath(), sf); err != nil {
		slog.Debug("profile: load managed_skills.yaml", "error", err)
		return nil
	}
	return sf.Repositories
}

// Get returns the record for repoID, or ok=false if absent.
func (m *ManagedSkillRepositoryManager) Get(repoID string) (ManagedSkillRepositoryRecord, bool) {
	for _, v := range m.All() {
		if v.RepoID == repoID {
			return v, true
		}
	}
	return ManagedSkillRepositoryRecord{}, false
}

// Upsert creates or replaces the record keyed by RepoID.
func (m *ManagedSkillRepositoryManager) Upsert(rec ManagedSkillRepositoryRecord) error {
	if err := validateManagedSkillRepositoryRecord(rec); err != nil {
		return err
	}
	m.mgr.mu.Lock()
	defer m.mgr.mu.Unlock()
	sf := &managedSkillRepositoriesFile{}
	if err := loadYAMLFile(m.filePath(), sf); err != nil {
		return fmt.Errorf("load managed_skills.yaml: %w", err)
	}
	for i, v := range sf.Repositories {
		if v.RepoID == rec.RepoID {
			sf.Repositories[i] = rec
			return saveYAMLFileLocked(sf, m.filePath(), m.mgr)
		}
	}
	sf.Repositories = append(sf.Repositories, rec)
	return saveYAMLFileLocked(sf, m.filePath(), m.mgr)
}

// Delete removes the record and best-effort deletes the local clone directory.
// Older records may point at a historical LocalPath; that path is removed too
// only when it is a child of the managed-skills root. This never follows an
// arbitrary record path outside AgentTeam's managed cache. It is idempotent:
// deleting an unknown repoID is a no-op.
func (m *ManagedSkillRepositoryManager) Delete(repoID string) error {
	m.mgr.mu.Lock()
	defer m.mgr.mu.Unlock()
	sf := &managedSkillRepositoriesFile{}
	if err := loadYAMLFile(m.filePath(), sf); err != nil {
		return fmt.Errorf("load managed_skills.yaml: %w", err)
	}
	kept := sf.Repositories[:0]
	removed := false
	removedPaths := make(map[string]struct{})
	for _, v := range sf.Repositories {
		if v.RepoID == repoID {
			removed = true
			if path, ok := m.managedRepositoryPath(v.LocalPath); ok {
				removedPaths[path] = struct{}{}
			}
			continue
		}
		kept = append(kept, v)
	}
	if !removed {
		return nil
	}
	sf.Repositories = kept
	if err := saveYAMLFileLocked(sf, m.filePath(), m.mgr); err != nil {
		return err
	}
	if repoPath, ok := m.managedRepositoryPath(m.RepositoryPath(repoID)); ok {
		removedPaths[repoPath] = struct{}{}
	}
	for repoPath := range removedPaths {
		if err := os.RemoveAll(repoPath); err != nil {
			slog.Warn("managed skill repository: remove local clone", "repo_id", repoID, "path", repoPath, "error", err)
		}
	}
	return nil
}

func (m *ManagedSkillRepositoryManager) managedRepositoryPath(candidate string) (string, bool) {
	candidate = strings.TrimSpace(candidate)
	if candidate == "" {
		return "", false
	}
	root, err := filepath.Abs(m.Dir())
	if err != nil {
		return "", false
	}
	path, err := filepath.Abs(candidate)
	if err != nil {
		return "", false
	}
	relative, err := filepath.Rel(root, path)
	if err != nil || relative == "." || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
		return "", false
	}
	return filepath.Clean(path), true
}

func validateManagedSkillRepositoryRecord(rec ManagedSkillRepositoryRecord) error {
	if strings.TrimSpace(rec.RepoID) == "" {
		return errors.New("managed skill repository: repo_id is required")
	}
	if strings.TrimSpace(rec.Branch) == "" {
		return errors.New("managed skill repository: branch is required")
	}
	if strings.TrimSpace(rec.CloneURL) == "" {
		return errors.New("managed skill repository: clone_url is required")
	}
	return nil
}

// sanitizeRepoIDForPath turns a MeshX repoID into a filesystem-safe path
// component. repoIDs are server-controlled; we never want a malformed value
// (e.g. ".." or absolute path) to escape Dir().
var repoIDPathPattern = regexp.MustCompile(`[^A-Za-z0-9._-]+`)

func sanitizeRepoIDForPath(repoID string) string {
	sanitized := repoIDPathPattern.ReplaceAllString(repoID, "_")
	sanitized = strings.Trim(sanitized, "._-")
	if sanitized == "" || sanitized == "." || sanitized == ".." {
		return "_"
	}
	return sanitized
}

// ---------------------------------------------------------------------------
// go-git runner (production)
// ---------------------------------------------------------------------------

// DefaultGitRunner implements managed repository Git operations via go-git (no shell-out, no
// dependency on a system git binary or credential helper). Auth flows through
// go-git's transport/http layer; the URL itself never carries embedded
// credentials.
type DefaultGitRunner struct {
	timeout time.Duration
}

// NewDefaultGitRunner returns a runner that caps each git invocation at the
// default timeout. Use WithTimeout to override.
func NewDefaultGitRunner() *DefaultGitRunner {
	return &DefaultGitRunner{timeout: 60 * time.Second}
}

// WithTimeout overrides the per-invocation timeout.
func (r *DefaultGitRunner) WithTimeout(d time.Duration) *DefaultGitRunner {
	r.timeout = d
	return r
}

func (r *DefaultGitRunner) Clone(ctx context.Context, url, branch, dest string, auth gittransport.AuthMethod) (string, error) {
	if err := validateCloneArgs(url, branch, dest); err != nil {
		return "", err
	}
	if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
		return "", fmt.Errorf("create parent of %s: %w", dest, err)
	}
	runCtx, cancel := context.WithTimeout(ctx, r.timeout)
	defer cancel()
	repo, err := git.PlainCloneContext(runCtx, dest, false, &git.CloneOptions{
		URL:           url,
		Auth:          auth,
		ReferenceName: plumbing.NewBranchReferenceName(branch),
		SingleBranch:  true,
		Depth:         1,
	})
	if err != nil {
		return "", fmt.Errorf("git clone %s: %w", redactURL(url), err)
	}
	return headRevisionOf(repo)
}

func (r *DefaultGitRunner) FetchCheckout(ctx context.Context, repoDir, branch string, auth gittransport.AuthMethod) (string, error) {
	if err := validateRepoDir(repoDir); err != nil {
		return "", err
	}
	if err := validateGitRef(branch, "fetch branch"); err != nil {
		return "", err
	}
	repo, err := git.PlainOpen(repoDir)
	if err != nil {
		return "", fmt.Errorf("git open %s: %w", repoDir, err)
	}
	runCtx, cancel := context.WithTimeout(ctx, r.timeout)
	defer cancel()
	refSpec := config.RefSpec("+refs/heads/" + branch + ":refs/remotes/origin/" + branch)
	if err := repo.FetchContext(runCtx, &git.FetchOptions{
		RemoteName: "origin",
		RefSpecs:   []config.RefSpec{refSpec},
		Auth:       auth,
		Depth:      1,
	}); err != nil && !errors.Is(err, git.NoErrAlreadyUpToDate) {
		return "", fmt.Errorf("git fetch %s: %w", branch, err)
	}
	wt, err := repo.Worktree()
	if err != nil {
		return "", fmt.Errorf("worktree: %w", err)
	}
	if err := wt.Checkout(&git.CheckoutOptions{
		Branch: plumbing.NewRemoteReferenceName("origin", branch),
	}); err != nil {
		// Fall back to a local branch checkout for clones that lack the
		// remote-tracking ref after a shallow fetch.
		if err := wt.Checkout(&git.CheckoutOptions{
			Branch: plumbing.NewBranchReferenceName(branch),
		}); err != nil {
			return "", fmt.Errorf("git checkout %s: %w", branch, err)
		}
	}
	return headRevisionOf(repo)
}

func (r *DefaultGitRunner) RemoteURL(repoDir string) (string, error) {
	if err := validateRepoDir(repoDir); err != nil {
		return "", err
	}
	repo, err := git.PlainOpen(repoDir)
	if err != nil {
		return "", fmt.Errorf("git open %s: %w", repoDir, err)
	}
	remote, err := repo.Remote("origin")
	if err != nil {
		return "", fmt.Errorf("git remote origin: %w", err)
	}
	for _, url := range remote.Config().URLs {
		if trimmed := strings.TrimSpace(url); trimmed != "" {
			return trimmed, nil
		}
	}
	return "", errors.New("git remote origin: empty url")
}

// headRevisionOf returns the commit SHA currently pointed at by HEAD.
func headRevisionOf(repo *git.Repository) (string, error) {
	head, err := repo.Head()
	if err != nil {
		return "", fmt.Errorf("read HEAD: %w", err)
	}
	return head.Hash().String(), nil
}

func validateCloneArgs(url, branch, dest string) error {
	if strings.TrimSpace(url) == "" {
		return errors.New("git clone: empty url")
	}
	if strings.TrimSpace(dest) == "" {
		return errors.New("git clone: empty destination")
	}
	return validateGitRef(branch, "clone branch")
}

// validateGitRef rejects refs that are empty or contain characters that would
// produce surprising ref names. With go-git there is no shell-escape risk;
// this is a UX guard so callers see a clear error rather than an opaque
// plumbing error from go-git's ref parser.
func validateGitRef(branch, label string) error {
	if strings.TrimSpace(branch) == "" {
		return fmt.Errorf("git: empty %s", label)
	}
	if !safeGitRefPattern.MatchString(branch) {
		return fmt.Errorf("git: invalid %s %q", label, branch)
	}
	return nil
}

func validateRepoDir(repoDir string) error {
	if strings.TrimSpace(repoDir) == "" {
		return errors.New("git: empty repo dir")
	}
	abs, err := filepath.Abs(repoDir)
	if err != nil {
		return fmt.Errorf("resolve repo dir: %w", err)
	}
	if !filepath.IsAbs(abs) {
		return errors.New("git: repo dir must resolve to an absolute path")
	}
	return nil
}

// safeGitRefPattern restricts branch names to a conservative charset. Real
// refs are constrained by git-check-ref-format; this is a defensive
// pre-filter that catches obvious typos and empty input.
var safeGitRefPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9/._-]*$`)

// redactURL strips user info from a URL string so the URL can be safely
// embedded in error messages and logs. Used defensively even though
// production clone URLs never carry embedded credentials.
var urlUserinfoPattern = regexp.MustCompile(`(https?://)[^/@]+@`)

func redactURL(s string) string {
	return urlUserinfoPattern.ReplaceAllString(s, "${1}***@")
}
