package vault

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

	"github.com/go-git/go-git/v5"
	"github.com/go-git/go-git/v5/config"
	"github.com/go-git/go-git/v5/plumbing"
	"github.com/stretchr/testify/require"
)

// makeBareOriginRepo seeds a bare git repo at originDir with one initial
// commit on main. Bare origins accept pushes to any ref without the
// "branch is currently checked out" error, so they are necessary for tests
// that verify a round-trip push lands on the remote.
func makeBareOriginRepo(t *testing.T, originDir string) string {
	t.Helper()
	bareRepo, err := git.PlainInit(originDir, true)
	require.NoError(t, err)
	require.NoError(t, bareRepo.Storer.SetReference(plumbing.NewSymbolicReference(plumbing.HEAD, plumbing.NewBranchReferenceName("main"))))

	staging := t.TempDir()
	stagingRepo, err := git.PlainInit(staging, false)
	require.NoError(t, err)
	require.NoError(t, stagingRepo.Storer.SetReference(plumbing.NewSymbolicReference(plumbing.HEAD, plumbing.NewBranchReferenceName("main"))))
	wt, err := stagingRepo.Worktree()
	require.NoError(t, err)
	require.NoError(t, os.WriteFile(filepath.Join(staging, "MEMORY.md"), []byte("# Origin memory\n\nInitial content.\n"), 0o644))
	_, err = wt.Add("MEMORY.md")
	require.NoError(t, err)
	_, err = wt.Commit("initial commit", &git.CommitOptions{})
	require.NoError(t, err)

	_, err = stagingRepo.CreateRemote(&config.RemoteConfig{
		Name: "origin",
		URLs: []string{"file://" + originDir},
	})
	require.NoError(t, err)
	require.NoError(t, stagingRepo.PushContext(context.Background(), &git.PushOptions{
		RemoteName: "origin",
		RefSpecs:   []config.RefSpec{"+refs/heads/main:refs/heads/main"},
	}))
	return "file://" + originDir
}

// remoteOriginURL reads the clone's origin URL so tests can confirm Rebind
// repointed the remote correctly.
func remoteOriginURL(t *testing.T, dir string) string {
	t.Helper()
	repo, err := git.PlainOpen(dir)
	require.NoError(t, err)
	remote, err := repo.Remote("origin")
	require.NoError(t, err)
	urls := remote.Config().URLs
	require.NotEmpty(t, urls)
	return urls[0]
}

// commitFileOnBranch writes a new commit on the current branch with the given
// file content so the test can verify the local working tree survives Rebind.
func commitFileOnBranch(t *testing.T, dir, path, content string) {
	t.Helper()
	repo, err := git.PlainOpen(dir)
	require.NoError(t, err)
	wt, err := repo.Worktree()
	require.NoError(t, err)
	require.NoError(t, os.MkdirAll(filepath.Dir(filepath.Join(dir, path)), 0o755))
	require.NoError(t, os.WriteFile(filepath.Join(dir, path), []byte(content), 0o644))
	_, err = wt.Add(path)
	require.NoError(t, err)
	_, err = wt.Commit("test commit: "+path, &git.CommitOptions{})
	require.NoError(t, err)
}

// readRepoFile reads a file from the repo working tree, failing the test if
// the file is missing.
func readRepoFile(t *testing.T, dir, path string) string {
	t.Helper()
	data, err := os.ReadFile(filepath.Join(dir, path))
	require.NoError(t, err)
	return string(data)
}

// originHeadCommit returns the commit hash currently pointed to by the origin
// repo's main branch. Used to verify a force-push landed on the new origin.
func originHeadCommit(t *testing.T, originDir string) string {
	t.Helper()
	repo, err := git.PlainOpen(originDir)
	require.NoError(t, err)
	ref, err := repo.Reference(plumbing.NewBranchReferenceName("main"), true)
	require.NoError(t, err)
	return ref.Hash().String()
}

func TestRebindViaFreshClone(t *testing.T) {
	ctx := context.Background()
	tmp := t.TempDir()
	originA := makeBareOriginRepo(t, filepath.Join(tmp, "origin-a"))
	originB := makeBareOriginRepo(t, filepath.Join(tmp, "origin-b"))

	clonePath := filepath.Join(tmp, "agents", "agt_alice", "memory")
	original, err := EnsureClone(ctx, EnsureCloneOpts{
		MemoryID:   "mem_alpha",
		AgentID:    "agt_alice",
		Branch:     "main",
		RepoURL:    originA,
		TargetPath: clonePath,
	})
	require.NoError(t, err)
	commitFileOnBranch(t, clonePath, "notes.md", "local alpha note\n")
	require.Equal(t, originA, remoteOriginURL(t, clonePath))

	result, err := original.Rebind(ctx, RebindOpts{
		NewMemoryID:  "mem_bravo",
		NewRepoURL:   originB,
		NewBranch:    "main",
		ReplaceLocal: true,
	})
	require.NoError(t, err)
	require.NotNil(t, result.Clone)
	require.Equal(t, "mem_bravo", result.Clone.MemoryID())
	require.Equal(t, "main", result.Clone.Branch())
	require.Equal(t, clonePath, result.Clone.Path())
	require.Equal(t, originB, remoteOriginURL(t, clonePath))

	// Fresh clone from B should have B's MEMORY.md, not the local alpha note
	// written before Rebind.
	require.FileExists(t, filepath.Join(clonePath, "MEMORY.md"))
	require.Equal(t, "# Origin memory\n\nInitial content.\n", readRepoFile(t, clonePath, "MEMORY.md"))
	_, err = os.Stat(filepath.Join(clonePath, "notes.md"))
	require.Error(t, err)
	require.True(t, os.IsNotExist(err))

	// The prior clone should have been backed up next to the active clone.
	entries, err := os.ReadDir(filepath.Dir(clonePath))
	require.NoError(t, err)
	foundBackup := false
	for _, entry := range entries {
		if strings.HasPrefix(entry.Name(), "memory.backup-") {
			foundBackup = true
			break
		}
	}
	require.True(t, foundBackup, "prior clone should have been backed up")
}

func TestRebindViaRepoint(t *testing.T) {
	ctx := context.Background()
	tmp := t.TempDir()
	originA := makeBareOriginRepo(t, filepath.Join(tmp, "origin-a"))
	originB := makeBareOriginRepo(t, filepath.Join(tmp, "origin-b"))

	clonePath := filepath.Join(tmp, "agents", "agt_bob", "memory")
	original, err := EnsureClone(ctx, EnsureCloneOpts{
		MemoryID:   "mem_alpha",
		AgentID:    "agt_bob",
		Branch:     "main",
		RepoURL:    originA,
		TargetPath: clonePath,
	})
	require.NoError(t, err)
	// Add local-only content that the repoint path must preserve and push to B.
	commitFileOnBranch(t, clonePath, "retained.md", "local bob note\n")
	require.Equal(t, originA, remoteOriginURL(t, clonePath))

	result, err := original.Rebind(ctx, RebindOpts{
		NewMemoryID:  "mem_bravo",
		NewRepoURL:   originB,
		NewBranch:    "main",
		ReplaceLocal: false,
	})
	require.NoError(t, err)
	require.NotNil(t, result.Clone)
	require.True(t, result.RepointedRemote, "expected repoint path to be taken")
	require.Equal(t, "mem_bravo", result.Clone.MemoryID())
	require.Equal(t, "main", result.Clone.Branch())
	require.Equal(t, clonePath, result.Clone.Path())
	require.Equal(t, originB, remoteOriginURL(t, clonePath))

	// Local working tree content must survive.
	require.Equal(t, "local bob note\n", readRepoFile(t, clonePath, "retained.md"))

	// Origin B must now have the local history (force-pushed).
	originBInitial := originHeadCommit(t, filepath.Join(tmp, "origin-b"))
	require.NotEmpty(t, originBInitial)
	bRepo, err := git.PlainOpen(filepath.Join(tmp, "origin-b"))
	require.NoError(t, err)
	bHead, err := bRepo.Head()
	require.NoError(t, err)
	bHeadCommit, err := bRepo.CommitObject(bHead.Hash())
	require.NoError(t, err)
	require.Contains(t, bHeadCommit.Message, "test commit: retained.md")

	// Retention state must reflect the new binding.
	state, err := ReadRetentionState(clonePath)
	require.NoError(t, err)
	require.Equal(t, "mem_bravo", state.MemoryID)
	require.Equal(t, "agt_bob", state.AgentID)
	require.Empty(t, state.RevokedAt)
}

func TestRebindRequiresNewMemoryID(t *testing.T) {
	ctx := context.Background()
	tmp := t.TempDir()
	originA := makeBareOriginRepo(t, filepath.Join(tmp, "origin-a"))
	clonePath := filepath.Join(tmp, "agents", "agt_alice", "memory")
	original, err := EnsureClone(ctx, EnsureCloneOpts{
		MemoryID:   "mem_alpha",
		AgentID:    "agt_alice",
		Branch:     "main",
		RepoURL:    originA,
		TargetPath: clonePath,
	})
	require.NoError(t, err)
	_, err = original.Rebind(ctx, RebindOpts{
		NewMemoryID:  "",
		NewRepoURL:   originA,
		NewBranch:    "main",
		ReplaceLocal: false,
	})
	require.Error(t, err)
}
