package profile

import (
	"context"
	"errors"
	"os"
	"path/filepath"
	"strings"
	"testing"
	"time"

	"github.com/go-git/go-git/v5"
	"github.com/go-git/go-git/v5/plumbing"
	gittransport "github.com/go-git/go-git/v5/plumbing/transport/http"
	"github.com/stretchr/testify/require"
)

func TestManagedSkillRepositories_DirSeparateFromManualSkillDir(t *testing.T) {
	m := New(t.TempDir())

	manualDir := m.Skills().Dir()
	managedDir := m.ManagedSkillRepositories().Dir()

	require.NotEqual(t, manualDir, managedDir,
		"managed repository dir must not collide with the manual skill dir")
	require.Equal(t, filepath.Join(m.ConfigDir(), "skills"), manualDir)
	require.Equal(t, filepath.Join(m.ConfigDir(), "managed-skills"), managedDir)
}

func TestManagedSkillRepositories_RepositoryPathSanitizesRepoID(t *testing.T) {
	m := New(t.TempDir())
	mgr := m.ManagedSkillRepositories()

	require.Equal(t, filepath.Join(mgr.Dir(), "sk_abc"), mgr.RepositoryPath("sk_abc"))
	// Dangerous IDs must not escape Dir().
	require.Equal(t, filepath.Join(mgr.Dir(), "_"), mgr.RepositoryPath(".."))
	require.Equal(t, filepath.Join(mgr.Dir(), "_"), mgr.RepositoryPath(""))
	require.True(t, strings.HasPrefix(mgr.RepositoryPath("sk/a:b"), mgr.Dir()),
		"sanitized path must stay inside managed dir")
}

func TestManagedSkillRepositories_UpsertGetAll(t *testing.T) {
	m := New(t.TempDir())
	mgr := m.ManagedSkillRepositories()

	now := time.Now().UTC().Truncate(time.Second)
	rec := ManagedSkillRepositoryRecord{
		RepoID:     "sk_abc",
		Name:       "Backend Skills",
		CloneURL:   "https://git.example.com/ws-1.sk",
		Branch:     "main",
		Revision:   "abc123",
		LocalPath:  mgr.RepositoryPath("sk_abc"),
		LastSyncAt: now,
		SkillCount: 3,
	}
	require.NoError(t, mgr.Upsert(rec))

	got, ok := mgr.Get("sk_abc")
	require.True(t, ok)
	require.Equal(t, rec, got)

	all := mgr.All()
	require.Len(t, all, 1)

	// Upsert again updates in place.
	rec.Revision = "def456"
	rec.SkillCount = 4
	require.NoError(t, mgr.Upsert(rec))
	got, ok = mgr.Get("sk_abc")
	require.True(t, ok)
	require.Equal(t, "def456", got.Revision)
	require.Equal(t, 4, got.SkillCount)
	require.Len(t, mgr.All(), 1)
}

func TestManagedSkillRepositories_UpsertRejectsInvalidRecords(t *testing.T) {
	m := New(t.TempDir())
	mgr := m.ManagedSkillRepositories()

	require.Error(t, mgr.Upsert(ManagedSkillRepositoryRecord{Branch: "main", CloneURL: "u"}))
	require.Error(t, mgr.Upsert(ManagedSkillRepositoryRecord{RepoID: "sk_1"}))
	require.Error(t, mgr.Upsert(ManagedSkillRepositoryRecord{RepoID: "sk_1", Branch: "main"}))
}

func TestManagedSkillRepositories_DeleteRemovesRecordAndLocalClone(t *testing.T) {
	m := New(t.TempDir())
	mgr := m.ManagedSkillRepositories()

	rec := ManagedSkillRepositoryRecord{
		RepoID:   "sk_xyz",
		CloneURL: "https://git.example.com/ws.sk",
		Branch:   "main",
		Revision: "rev1",
	}
	require.NoError(t, mgr.Upsert(rec))

	repoPath := mgr.RepositoryPath("sk_xyz")
	require.NoError(t, os.MkdirAll(filepath.Join(repoPath, "some-skill"), 0o755))
	require.NoError(t, os.WriteFile(filepath.Join(repoPath, "some-skill", "SKILL.md"),
		[]byte("# Some\n"), 0o644))

	require.NoError(t, mgr.Delete("sk_xyz"))

	_, ok := mgr.Get("sk_xyz")
	require.False(t, ok)
	_, err := os.Stat(repoPath)
	require.True(t, os.IsNotExist(err))
}

func TestManagedSkillRepositories_DeleteRemovesHistoricalLocalPathInsideManagedRoot(t *testing.T) {
	m := New(t.TempDir())
	mgr := m.ManagedSkillRepositories()
	historicalPath := filepath.Join(mgr.Dir(), "legacy-layout", "sk_xyz")
	require.NoError(t, os.MkdirAll(historicalPath, 0o755))
	require.NoError(t, mgr.Upsert(ManagedSkillRepositoryRecord{
		RepoID: "sk_xyz", CloneURL: "https://git.example.com/ws.sk", Branch: "main", LocalPath: historicalPath,
	}))

	require.NoError(t, mgr.Delete("sk_xyz"))

	require.NoDirExists(t, historicalPath)
}

func TestManagedSkillRepositories_DeleteNeverRemovesLocalPathOutsideManagedRoot(t *testing.T) {
	m := New(t.TempDir())
	mgr := m.ManagedSkillRepositories()
	outsidePath := t.TempDir()
	require.NoError(t, os.WriteFile(filepath.Join(outsidePath, "keep"), []byte("keep"), 0o644))
	require.NoError(t, mgr.Upsert(ManagedSkillRepositoryRecord{
		RepoID: "sk_xyz", CloneURL: "https://git.example.com/ws.sk", Branch: "main", LocalPath: outsidePath,
	}))

	require.NoError(t, mgr.Delete("sk_xyz"))

	require.FileExists(t, filepath.Join(outsidePath, "keep"))
}

func TestManagedSkillRepositories_DeleteIdempotentWhenAbsent(t *testing.T) {
	m := New(t.TempDir())
	require.NoError(t, m.ManagedSkillRepositories().Delete("nonexistent"))
}

// ---------------------------------------------------------------------------
// go-git runner
// ---------------------------------------------------------------------------

func TestValidateCloneArgs_RejectsInvalidInput(t *testing.T) {
	require.Error(t, validateCloneArgs("", "main", "/tmp/dest"))
	require.Error(t, validateCloneArgs("https://x/y", "main", ""))
	require.Error(t, validateCloneArgs("https://x/y", "", "/tmp/dest"))
	require.NoError(t, validateCloneArgs("https://x/y", "main", "/tmp/dest"))
	require.NoError(t, validateCloneArgs("https://x/y", "feature/foo-bar_1.0", "/tmp/dest"))
}

// TestValidateGitRef pins the ref validator. With go-git there is no shell to
// escape, so this is a UX guard that catches typos and obviously-malformed
// refs before they reach go-git's plumbing.
func TestValidateGitRef(t *testing.T) {
	require.Error(t, validateGitRef("", "fetch branch"))
	require.Error(t, validateGitRef("   ", "fetch branch"))
	require.Error(t, validateGitRef("main; rm -rf /", "fetch branch"))
	require.Error(t, validateGitRef("--upload-pack=evil", "fetch branch"))
	require.Error(t, validateGitRef("-b", "fetch branch"))
	require.Error(t, validateGitRef("../escape", "fetch branch"))
	require.NoError(t, validateGitRef("main", "fetch branch"))
	require.NoError(t, validateGitRef("feature/foo-bar_1.0", "fetch branch"))
	require.NoError(t, validateGitRef("release/2.0", "fetch branch"))
}

// TestRedactURL_StripsUserinfo verifies the URL redaction used to keep
// credential-bearing URLs out of error logs. Production clone URLs never
// carry embedded credentials, but we redact defensively before wrapping an
// error.
func TestRedactURL_StripsUserinfo(t *testing.T) {
	out := redactURL("https://token@git.example.com/ws.sk/")
	require.NotContains(t, out, "token@")
	require.Contains(t, out, "***@")
	require.Contains(t, out, "git.example.com")
}

// makeFileOriginRepo seeds a normal (non-bare) git repository at originDir
// with one commit on the requested branch, then returns its file:// URL.
// Mirrors the helper used by the vault package's go-git tests.
func makeFileOriginRepo(t *testing.T, originDir, branch string) string {
	t.Helper()
	repo, err := git.PlainInit(originDir, false)
	require.NoError(t, err)
	require.NoError(t, repo.Storer.SetReference(
		plumbing.NewSymbolicReference(plumbing.HEAD, plumbing.NewBranchReferenceName(branch))))
	wt, err := repo.Worktree()
	require.NoError(t, err)
	require.NoError(t, os.WriteFile(filepath.Join(originDir, "SKILL.md"), []byte("# origin skill\n"), 0o644))
	_, err = wt.Add("SKILL.md")
	require.NoError(t, err)
	_, err = wt.Commit("initial commit", &git.CommitOptions{})
	require.NoError(t, err)
	return "file://" + originDir
}

// appendOriginCommit adds a commit modifying SKILL.md on the current branch
// of the repo at originDir. Used to advance the origin between Clone and
// FetchCheckout in TestDefaultGitRunner_FetchCheckoutAdvancesHEAD.
func appendOriginCommit(t *testing.T, originDir, content string) {
	t.Helper()
	repo, err := git.PlainOpen(originDir)
	require.NoError(t, err)
	wt, err := repo.Worktree()
	require.NoError(t, err)
	require.NoError(t, os.WriteFile(filepath.Join(originDir, "SKILL.md"), []byte(content), 0o644))
	_, err = wt.Add("SKILL.md")
	require.NoError(t, err)
	_, err = wt.Commit("update", &git.CommitOptions{})
	require.NoError(t, err)
}

// TestDefaultGitRunner_CloneClonesRemoteBranchAndReturnsHead proves the
// production runner uses go-git to clone the requested branch and returns
// HEAD. Runs entirely against a file:// origin so no network or system git
// binary is required.
func TestDefaultGitRunner_CloneClonesRemoteBranchAndReturnsHead(t *testing.T) {
	originURL := makeFileOriginRepo(t, t.TempDir(), "main")

	r := NewDefaultGitRunner().WithTimeout(10 * time.Second)
	dest := filepath.Join(t.TempDir(), "clone")
	rev, err := r.Clone(context.Background(), originURL, "main", dest, nil)
	require.NoError(t, err)
	require.NotEmpty(t, rev, "HEAD revision must be reported")

	// Sanity: dest exists and the SKILL.md is materialized.
	data, err := os.ReadFile(filepath.Join(dest, "SKILL.md"))
	require.NoError(t, err)
	require.Contains(t, string(data), "origin skill")

	// HEAD reported by the runner matches HEAD reported by re-opening the clone.
	repo, err := git.PlainOpen(dest)
	require.NoError(t, err)
	head, err := repo.Head()
	require.NoError(t, err)
	require.Equal(t, rev, head.Hash().String())

	remoteURL, err := r.RemoteURL(dest)
	require.NoError(t, err)
	require.Equal(t, originURL, remoteURL)
}

// TestDefaultGitRunner_FetchCheckoutAdvancesHEAD seeds a clone at an older
// revision, advances the origin with a second commit, then proves
// FetchCheckout picks up the new HEAD.
func TestDefaultGitRunner_FetchCheckoutAdvancesHEAD(t *testing.T) {
	originDir := t.TempDir()
	originURL := makeFileOriginRepo(t, originDir, "main")

	r := NewDefaultGitRunner().WithTimeout(10 * time.Second)

	// First clone to get a stable baseline.
	dest := filepath.Join(t.TempDir(), "clone")
	baselineRev, err := r.Clone(context.Background(), originURL, "main", dest, nil)
	require.NoError(t, err)

	// Advance origin with a second commit.
	appendOriginCommit(t, originDir, "# v2\n")

	newRev, err := r.FetchCheckout(context.Background(), dest, "main", nil)
	require.NoError(t, err)
	require.NotEqual(t, baselineRev, newRev, "FetchCheckout must move HEAD to the new tip")
	require.NotEmpty(t, newRev)
}

// TestDefaultGitRunner_FetchCheckoutRejectsInvalidRef proves validateGitRef
// is consulted before any go-git call, so a malformed branch returns a clear
// error rather than a plumbing failure.
func TestDefaultGitRunner_FetchCheckoutRejectsInvalidRef(t *testing.T) {
	r := NewDefaultGitRunner()
	for _, branch := range []string{"", "main; rm -rf /", "--upload-pack=evil", "../escape"} {
		_, err := r.FetchCheckout(context.Background(), t.TempDir(), branch, nil)
		require.Error(t, err, "branch %q must be rejected", branch)
	}
}

// TestDefaultGitRunner_CloneRejectsInvalidArgs covers the same UX-level
// validation on the Clone path.
func TestDefaultGitRunner_CloneRejectsInvalidArgs(t *testing.T) {
	r := NewDefaultGitRunner()
	_, err := r.Clone(context.Background(), "", "main", t.TempDir(), nil)
	require.Error(t, err)
	_, err = r.Clone(context.Background(), "file://x", "main", "", nil)
	require.Error(t, err)
	_, err = r.Clone(context.Background(), "file://x", "", t.TempDir(), nil)
	require.Error(t, err)
}

// TestDefaultGitRunner_AcceptsAuthNilForFileURLs is a smoke test that the
// auth parameter is optional for file:// origins (mirrors the vault package's
// approach). The TokenAuth path against a real HTTPS server is exercised
// end-to-end in integration tests; here we just confirm nil does not panic.
func TestDefaultGitRunner_AcceptsAuthNilForFileURLs(t *testing.T) {
	originURL := makeFileOriginRepo(t, t.TempDir(), "main")
	r := NewDefaultGitRunner().WithTimeout(10 * time.Second)
	dest := filepath.Join(t.TempDir(), "clone")
	var auth gittransport.AuthMethod = nil
	_, err := r.Clone(context.Background(), originURL, "main", dest, auth)
	require.NoError(t, err)
}

// stub error used to ensure errors.Is chains compile cleanly
var _ = errors.Is
