// Package vault owns the per-agent Memory repository clone and commit
// primitives used by the desktop runtime.
//
// Layout on disk:
//
//	~/.agentteam/agents/<agentID>/memory/    standalone clone on the bound branch
//
// Each agent gets its own independent clone of its bound memory repository and
// branch. The clone is a normal git repository (no linked
// worktree metadata), which keeps the implementation in pure go-git and avoids
// any dependency on a system git binary.
//
// Lifecycle:
//   - EnsureClone: clone-or-open idempotent setup at the agent's memory dir.
//   - CommitPending: stage all changes and commit (or amend) on the bound branch.
//   - Push: push the agent memory branch to origin.
//   - Detach: revoke path (remove .git) without touching local content, used
//     when Memory access is revoked.
package vault

import (
	"context"
	"errors"
	"fmt"
	"log/slog"
	"os"
	"path/filepath"
	"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"
)

// Errors surfaced by the manager. Callers should use errors.Is to branch.
var (
	// ErrPathConflict is returned by EnsureClone when TargetPath exists but is
	// neither empty nor a git repository that can be opened safely.
	ErrPathConflict = errors.New("vault: target path has unexpected content")
	// ErrRepoCorrupt is returned when an existing clone cannot be reopened.
	// Callers should treat this as a signal to back up and re-clone.
	ErrRepoCorrupt = errors.New("vault: existing clone is corrupt")
)

// DefaultFetchTimeout bounds how long a single fetch can run. Memory repos
// are small (markdown), so this is generous but not unbounded.
const DefaultFetchTimeout = 30 * time.Second

// EnsureCloneOpts configures EnsureClone.
type EnsureCloneOpts struct {
	// MemoryID is the Memory identifier this clone backs. Required.
	MemoryID string
	// AgentID is the agent identifier this clone belongs to. Required for
	// logging and clone metadata.
	AgentID string
	// RepoURL is the upstream URL (typically the AgentTeam gateway HTTPS URL
	// for the AGS-backed memory repo). Required.
	RepoURL string
	// TargetPath is the on-disk location of the clone, normally
	// ~/.agentteam/agents/<agentID>/memory. Required; parent dirs are created.
	TargetPath string
	// Branch is the explicit upstream branch for this binding. Required.
	// Callers must not omit it or derive fallback agents/<id> names here.
	Branch string
	// Auth is injected by the caller. Production Memory sync uses a
	// refreshable AGS repository sync lease; tests may leave it nil for
	// file:// URLs.
	Auth gittransport.AuthMethod
	// Logger receives lifecycle events. nil falls back to slog.Default().
	Logger *slog.Logger
}

// Clone represents a per-agent standalone clone of the Memory repo on the
// explicit binding branch. The handle owns no goroutines and is safe to copy.
type Clone struct {
	memoryID string
	agentID  string
	branch   string
	path     string
	repo     *git.Repository
	remote   bool
}

// MemoryID returns the memory identifier this clone backs.
func (c *Clone) MemoryID() string { return c.memoryID }

// AgentID returns the agent identifier this clone belongs to.
func (c *Clone) AgentID() string { return c.agentID }

// Branch returns the explicit branch configured by the memory binding.
func (c *Clone) Branch() string { return c.branch }

// Path returns the on-disk path of the clone.
func (c *Clone) Path() string { return c.path }

// Repository returns the underlying go-git repository handle. Callers must
// not close it; the clone retains ownership. Unlike the prior linked-worktree
// model, this is a normal clone so go-git can read HEAD and the worktree
// without any shell-out.
func (c *Clone) Repository() *git.Repository { return c.repo }

// UsesRemote reports whether this journal is backed by a Git remote. Native
// Memory journals deliberately return false and must never enter clone,
// fetch, pull, or push code paths.
func (c *Clone) UsesRemote() bool { return c != nil && c.remote }

// EnsureClone clones the Memory repo at opts.RepoURL into opts.TargetPath on
// branch opts.Branch when missing, or reopens, fetches, checks out, and pulls
// when it already exists. The call is idempotent.
//
// Branch creation strategy:
//   - Clone defaults to origin's HEAD branch (main for AGS-backed repos).
//   - After a successful fetch, create opts.Branch from origin/<branch>,
//     origin/main, origin/master, or HEAD if missing, then checkout.
//   - On reopen against the same origin, fetch origin, checkout opts.Branch,
//     and pull when origin/<branch> exists.
//
// Path conflict handling: a non-empty directory without a valid .git is
// backed up and replaced by a fresh clone. An existing clone from a different
// origin, or a corrupt clone, is also backed up before re-cloning. A same-origin
// branch switch never deletes local content. A matching local branch remains
// usable when fetch or pull fails, and fresh clones are published only after a
// complete temporary clone is ready.
func EnsureClone(ctx context.Context, opts EnsureCloneOpts) (*Clone, error) {
	if err := validateEnsureCloneOpts(opts); err != nil {
		return nil, err
	}
	unlock, err := lockMemoryPath(ctx, strings.TrimSpace(opts.TargetPath))
	if err != nil {
		return nil, fmt.Errorf("vault: acquire memory path lock: %w", err)
	}
	defer unlock()
	return ensureCloneLocked(ctx, opts)
}

// ensureCloneLocked is the lock-held inner body of EnsureClone. Callers MUST
// hold lockMemoryPath(targetPath) before invoking. Rebind uses it to keep the
// backup + re-clone swap atomic without re-entering the path lock.
func ensureCloneLocked(ctx context.Context, opts EnsureCloneOpts) (*Clone, error) {
	memoryID := strings.TrimSpace(opts.MemoryID)
	agentID := strings.TrimSpace(opts.AgentID)
	repoURL := strings.TrimSpace(opts.RepoURL)
	targetPath := strings.TrimSpace(opts.TargetPath)
	branch := strings.TrimSpace(opts.Branch)

	logger := opts.Logger
	if logger == nil {
		logger = slog.Default()
	}

	if _, ok, err := matchingRetainedOfflineState(targetPath, memoryID, agentID, branch); err != nil {
		return nil, err
	} else if ok {
		reattached, err := reattachRetainedOfflineClone(ctx, opts, logger)
		if err != nil {
			return nil, err
		}
		if err := writeActiveRetentionState(targetPath, memoryID, agentID, branch); err != nil {
			return nil, err
		}
		return reattached, nil
	}

	if existing, err := openExistingClone(targetPath, memoryID, agentID); err == nil {
		sameOrigin, err := originURLMatches(existing.repo, repoURL)
		if err != nil {
			return nil, fmt.Errorf("vault read origin %s: %w", memoryID, err)
		}
		if !sameOrigin {
			if err := backupExistingClone(targetPath); err != nil {
				return nil, fmt.Errorf("vault: backup changed-origin clone: %w", err)
			}
		} else {
			if err := fetch(ctx, existing.repo, opts.Auth); err != nil {
				if ctx.Err() != nil {
					return nil, fmt.Errorf("vault fetch %s: %w", memoryID, err)
				}
				if branchErr := checkoutExistingBoundBranch(existing.repo, branch); branchErr != nil {
					return nil, fmt.Errorf("vault fetch %s: %w (local branch unavailable: %v)", memoryID, err, branchErr)
				}
				existing.branch = branch
				logger.WarnContext(ctx, "vault using local memory clone while remote is unavailable",
					"memory_id", memoryID,
					"agent_id", agentID,
					"branch", branch,
					"path", targetPath,
					"error", err,
				)
				if err := writeActiveRetentionState(targetPath, memoryID, agentID, branch); err != nil {
					return nil, err
				}
				return existing, nil
			}
			if err := ensureBoundBranch(existing.repo, branch); err != nil {
				return nil, fmt.Errorf("vault ensure branch %s: %w", branch, err)
			}
			if err := pullBoundBranch(ctx, existing.repo, branch, opts.Auth); err != nil {
				if ctx.Err() != nil {
					return nil, fmt.Errorf("vault pull branch %s: %w", branch, err)
				}
				logger.WarnContext(ctx, "vault keeping local memory clone after pull failure",
					"memory_id", memoryID,
					"agent_id", agentID,
					"branch", branch,
					"path", targetPath,
					"error", err,
				)
			}
			existing.branch = branch
			logger.InfoContext(ctx, "vault reopened agent memory clone",
				"memory_id", memoryID,
				"agent_id", agentID,
				"branch", branch,
				"path", targetPath,
			)
			if err := writeActiveRetentionState(targetPath, memoryID, agentID, branch); err != nil {
				return nil, err
			}
			return existing, nil
		}
	} else if errors.Is(err, ErrRepoCorrupt) {
		if err := backupExistingClone(targetPath); err != nil {
			return nil, fmt.Errorf("vault: backup corrupt clone: %w", err)
		}
	} else if !errors.Is(err, errPathMissing) {
		return nil, err
	}

	return cloneFreshLocked(ctx, opts, logger)
}

// cloneFreshLocked builds a complete clone beside targetPath and publishes it
// with one rename. The caller holds targetPath's memory path lock.
func cloneFreshLocked(ctx context.Context, opts EnsureCloneOpts, logger *slog.Logger) (*Clone, error) {
	memoryID := strings.TrimSpace(opts.MemoryID)
	agentID := strings.TrimSpace(opts.AgentID)
	repoURL := strings.TrimSpace(opts.RepoURL)
	targetPath := strings.TrimSpace(opts.TargetPath)
	branch := strings.TrimSpace(opts.Branch)

	if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil {
		return nil, fmt.Errorf("vault mkdir parent: %w", err)
	}

	logger.InfoContext(ctx, "vault cloning memory repository",
		"memory_id", memoryID,
		"agent_id", agentID,
		"branch", branch,
		"path", targetPath,
	)
	tmp, err := os.MkdirTemp(filepath.Dir(targetPath), ".memory-clone-*")
	if err != nil {
		return nil, fmt.Errorf("vault create clone temp: %w", err)
	}
	defer os.RemoveAll(tmp)
	repo, err := git.PlainCloneContext(ctx, tmp, false, &git.CloneOptions{
		URL:  repoURL,
		Auth: opts.Auth,
	})
	if err != nil {
		return nil, fmt.Errorf("vault clone %s: %w", memoryID, err)
	}

	if err := ensureBoundBranch(repo, branch); err != nil {
		return nil, fmt.Errorf("vault ensure branch %s: %w", branch, err)
	}
	if info, err := os.Stat(targetPath); err == nil && info.IsDir() && isEmptyDir(targetPath) {
		if err := os.Remove(targetPath); err != nil {
			return nil, fmt.Errorf("vault remove empty clone target: %w", err)
		}
	} else if err == nil {
		return nil, fmt.Errorf("%w: clone target appeared during setup: %s", ErrPathConflict, targetPath)
	} else if !os.IsNotExist(err) {
		return nil, fmt.Errorf("vault stat clone target before publish: %w", err)
	}
	if err := os.Rename(tmp, targetPath); err != nil {
		return nil, fmt.Errorf("vault publish clone: %w", err)
	}
	repo, err = git.PlainOpen(targetPath)
	if err != nil {
		return nil, fmt.Errorf("%w: reopen published clone failed: %v", ErrRepoCorrupt, err)
	}
	if err := writeActiveRetentionState(targetPath, memoryID, agentID, branch); err != nil {
		return nil, err
	}

	return &Clone{memoryID: memoryID, agentID: agentID, branch: branch, path: targetPath, repo: repo, remote: true}, nil
}

func validateEnsureCloneOpts(opts EnsureCloneOpts) error {
	if strings.TrimSpace(opts.MemoryID) == "" {
		return fmt.Errorf("vault: memory id is required")
	}
	if strings.TrimSpace(opts.AgentID) == "" {
		return fmt.Errorf("vault: agent id is required")
	}
	if strings.TrimSpace(opts.RepoURL) == "" {
		return fmt.Errorf("vault: repo url is required")
	}
	if strings.TrimSpace(opts.Branch) == "" {
		return fmt.Errorf("vault: branch is required")
	}
	if strings.TrimSpace(opts.TargetPath) == "" {
		return fmt.Errorf("vault: target path is required")
	}
	return nil
}

var errPathMissing = errors.New("target path missing")

// openExistingClone reopens a prior clone at targetPath. It returns
// errPathMissing when the path does not exist (so the caller can clone fresh),
// ErrPathConflict when the path exists but is not a directory, and any other
// read error wrapped in ErrRepoCorrupt.
func openExistingClone(targetPath, memoryID, agentID string) (*Clone, error) {
	info, err := os.Stat(targetPath)
	if err != nil {
		if os.IsNotExist(err) {
			return nil, errPathMissing
		}
		return nil, fmt.Errorf("vault: stat target: %w", err)
	}
	if !info.IsDir() {
		return nil, fmt.Errorf("%w: %s is not a directory", ErrPathConflict, targetPath)
	}
	// A standalone clone has a `.git` directory at its root. Absence means
	// this is a plain directory (e.g., a detached memory clone after access
	// revocation). Back it up and re-clone fresh.
	gitDir := filepath.Join(targetPath, ".git")
	if _, err := os.Stat(gitDir); err != nil {
		if os.IsNotExist(err) {
			if isEmptyDir(targetPath) {
				return nil, errPathMissing
			}
			// Non-empty directory without .git — back up and treat as missing.
			if err := backupExistingClone(targetPath); err != nil {
				return nil, fmt.Errorf("vault: backup non-git dir: %w", err)
			}
			return nil, errPathMissing
		}
		return nil, fmt.Errorf("vault: stat .git: %w", err)
	}

	repo, err := git.PlainOpen(targetPath)
	if err != nil {
		return nil, fmt.Errorf("%w: reopen failed: %v", ErrRepoCorrupt, err)
	}
	if _, err := repo.Head(); err != nil {
		return nil, fmt.Errorf("%w: read head: %v", ErrRepoCorrupt, err)
	}
	return &Clone{memoryID: memoryID, agentID: agentID, path: targetPath, repo: repo, remote: true}, nil
}

// isEmptyDir reports whether dir contains no entries (ignoring hidden files).
// Used to allow EnsureClone to clone into a freshly-created empty dir.
func isEmptyDir(dir string) bool {
	entries, err := os.ReadDir(dir)
	if err != nil {
		return false
	}
	return len(entries) == 0
}

// backupExistingClone moves an existing clone or non-git directory to a
// timestamped backup sibling so EnsureClone can re-clone fresh into the
// original path when repo identity changes or the clone is corrupt.
func backupExistingClone(targetPath string) error {
	parent := filepath.Dir(targetPath)
	base := filepath.Base(targetPath)
	stamp := time.Now().UTC().Format("20060102T150405Z")
	backupPath := filepath.Join(parent, base+".backup-"+stamp)
	for i := 1; ; i++ {
		if _, err := os.Stat(backupPath); os.IsNotExist(err) {
			break
		}
		backupPath = filepath.Join(parent, fmt.Sprintf("%s.backup-%s-%d", base, stamp, i))
	}
	if err := os.Rename(targetPath, backupPath); err != nil {
		return fmt.Errorf("rename %s to %s: %w", targetPath, backupPath, err)
	}
	slog.Info("vault: backed up memory directory",
		"original", targetPath, "backup", backupPath)
	return nil
}

func reattachRetainedOfflineClone(ctx context.Context, opts EnsureCloneOpts, logger *slog.Logger) (*Clone, error) {
	memoryID := strings.TrimSpace(opts.MemoryID)
	agentID := strings.TrimSpace(opts.AgentID)
	repoURL := strings.TrimSpace(opts.RepoURL)
	targetPath := strings.TrimSpace(opts.TargetPath)
	branch := strings.TrimSpace(opts.Branch)

	parent := filepath.Dir(targetPath)
	if err := os.MkdirAll(parent, 0o755); err != nil {
		return nil, fmt.Errorf("vault: mkdir retained clone parent: %w", err)
	}
	tmp, err := os.MkdirTemp(parent, ".memory-reattach-*")
	if err != nil {
		return nil, fmt.Errorf("vault: create retained clone temp: %w", err)
	}
	defer func() {
		if err := os.RemoveAll(tmp); err != nil {
			slog.Warn("vault: remove retained clone temp failed", "path", tmp, "error", err)
		}
	}()

	logger.InfoContext(ctx, "vault reattaching retained offline memory clone",
		"memory_id", memoryID,
		"agent_id", agentID,
		"branch", branch,
		"path", targetPath,
	)
	repo, err := git.PlainCloneContext(ctx, tmp, false, &git.CloneOptions{
		URL:  repoURL,
		Auth: opts.Auth,
	})
	if err != nil {
		return nil, fmt.Errorf("vault clone retained reattach %s: %w", memoryID, err)
	}
	if err := ensureBoundBranch(repo, branch); err != nil {
		return nil, fmt.Errorf("vault ensure retained branch %s: %w", branch, err)
	}

	gitDir := filepath.Join(targetPath, ".git")
	if _, err := os.Stat(gitDir); err == nil {
		return nil, fmt.Errorf("%w: retained memory path already has .git", ErrPathConflict)
	} else if err != nil && !os.IsNotExist(err) {
		return nil, fmt.Errorf("vault: stat retained .git: %w", err)
	}
	if err := os.Rename(filepath.Join(tmp, ".git"), gitDir); err != nil {
		return nil, fmt.Errorf("vault: attach retained .git: %w", err)
	}

	reattached, err := git.PlainOpen(targetPath)
	if err != nil {
		return nil, fmt.Errorf("%w: reopen retained clone failed: %v", ErrRepoCorrupt, err)
	}
	if _, err := reattached.Head(); err != nil {
		return nil, fmt.Errorf("%w: read retained head: %v", ErrRepoCorrupt, err)
	}
	return &Clone{memoryID: memoryID, agentID: agentID, branch: branch, path: targetPath, repo: reattached, remote: true}, nil
}

// EnsureLocalJournal opens or initializes the canonical local Memory Git
// journal without contacting or configuring any remote. Existing working-set
// files and local commits are preserved; any legacy remote configuration is
// removed before the handle is returned.
func EnsureLocalJournal(ctx context.Context, memoryID, agentID, targetPath string) (*Clone, error) {
	memoryID = strings.TrimSpace(memoryID)
	agentID = strings.TrimSpace(agentID)
	targetPath = strings.TrimSpace(targetPath)
	if memoryID == "" || agentID == "" || targetPath == "" {
		return nil, errors.New("vault: local journal memory, agent, and target are required")
	}
	unlock, err := lockMemoryPath(ctx, targetPath)
	if err != nil {
		return nil, fmt.Errorf("vault: acquire local journal path lock: %w", err)
	}
	defer unlock()
	if state, err := ReadRetentionState(targetPath); err == nil {
		if state.SchemaVersion != retentionStateVersion {
			return nil, fmt.Errorf("vault: unsupported memory retention state version %d", state.SchemaVersion)
		}
		if state.MemoryID != memoryID || state.AgentID != "" && state.AgentID != agentID {
			if _, statErr := os.Stat(targetPath); statErr == nil {
				if err := backupExistingClone(targetPath); err != nil {
					return nil, fmt.Errorf("vault: backup changed local journal binding: %w", err)
				}
			} else if !os.IsNotExist(statErr) {
				return nil, fmt.Errorf("vault: stat changed local journal binding: %w", statErr)
			}
		}
	} else if !os.IsNotExist(err) {
		return nil, err
	}
	if err := os.MkdirAll(targetPath, 0o755); err != nil {
		return nil, fmt.Errorf("vault: mkdir local journal: %w", err)
	}
	var repo *git.Repository
	if _, err := os.Stat(filepath.Join(targetPath, ".git")); err == nil {
		repo, err = git.PlainOpen(targetPath)
		if err != nil {
			return nil, fmt.Errorf("vault: open local journal: %w", err)
		}
	} else if os.IsNotExist(err) {
		repo, err = git.PlainInit(targetPath, false)
		if err != nil {
			return nil, fmt.Errorf("vault: initialize local journal: %w", err)
		}
	} else {
		return nil, fmt.Errorf("vault: stat local journal metadata: %w", err)
	}
	remotes, err := repo.Remotes()
	if err != nil {
		return nil, fmt.Errorf("vault: list local journal remotes: %w", err)
	}
	for _, remote := range remotes {
		if err := repo.DeleteRemote(remote.Config().Name); err != nil {
			return nil, fmt.Errorf("vault: remove local journal remote %q: %w", remote.Config().Name, err)
		}
	}
	branch := "master"
	if head, err := repo.Head(); err == nil && head.Name().IsBranch() {
		branch = head.Name().Short()
	}
	if err := writeActiveRetentionState(targetPath, memoryID, agentID, "native-content-v1"); err != nil {
		return nil, err
	}
	return &Clone{memoryID: memoryID, agentID: agentID, branch: branch, path: targetPath, repo: repo, remote: false}, nil
}

// IsLocalJournal reports whether targetPath is a Git journal with no remotes.
// It performs only local metadata reads and never contacts a transport.
func IsLocalJournal(targetPath string) (bool, error) {
	repo, err := git.PlainOpen(strings.TrimSpace(targetPath))
	if err != nil {
		if errors.Is(err, git.ErrRepositoryNotExists) {
			return false, nil
		}
		return false, err
	}
	remotes, err := repo.Remotes()
	if err != nil {
		return false, err
	}
	return len(remotes) == 0, nil
}

// ensureBoundBranch creates branch from origin/<branch>, origin/main,
// origin/master, or HEAD if missing, then checks it out as the working tree's
// HEAD.
func ensureBoundBranch(repo *git.Repository, branch string) error {
	refName := plumbing.NewBranchReferenceName(branch)
	if _, err := repo.Reference(refName, true); err == nil {
		return checkoutExistingBoundBranch(repo, branch)
	}

	start, err := branchStartRef(repo, branch)
	if err != nil {
		return err
	}
	if err := repo.CreateBranch(&config.Branch{
		Name:   branch,
		Remote: "origin",
		Merge:  refName,
	}); err != nil {
		return fmt.Errorf("create branch ref: %w", err)
	}
	// Point the branch at its upstream commit so the worktree checkout lands
	// on the requested baseline. PlainClone checks out origin/HEAD but leaves
	// other local branch refs unset until we create them.
	localRef := plumbing.NewHashReference(refName, start.Hash())
	if err := repo.Storer.SetReference(localRef); err != nil {
		return fmt.Errorf("set branch ref: %w", err)
	}

	wt, err := repo.Worktree()
	if err != nil {
		return fmt.Errorf("worktree: %w", err)
	}
	if err := wt.Checkout(&git.CheckoutOptions{Branch: refName}); err != nil {
		return fmt.Errorf("checkout %s: %w", branch, err)
	}
	return nil
}

// checkoutExistingBoundBranch checks out branch only when its local ref is
// already cached. It is the safe fallback after fetch fails: creating a branch
// from stale remote-tracking refs or HEAD could fabricate the wrong baseline.
func checkoutExistingBoundBranch(repo *git.Repository, branch string) error {
	refName := plumbing.NewBranchReferenceName(branch)
	if _, err := repo.Reference(refName, true); err != nil {
		return fmt.Errorf("find local branch %s: %w", branch, err)
	}
	wt, err := repo.Worktree()
	if err != nil {
		return fmt.Errorf("worktree: %w", err)
	}
	if err := wt.Checkout(&git.CheckoutOptions{Branch: refName}); err != nil {
		return fmt.Errorf("checkout %s: %w", branch, err)
	}
	return nil
}

// branchStartRef returns the reference that a fresh local branch should be
// based on. We prefer origin/<branch>, then origin/main, origin/master, then
// HEAD.
func branchStartRef(repo *git.Repository, branch string) (*plumbing.Reference, error) {
	for _, candidate := range []string{"origin/" + branch, "origin/main", "origin/master"} {
		if ref, err := repo.Reference(plumbing.ReferenceName("refs/remotes/"+candidate), true); err == nil {
			return ref, nil
		}
	}
	if head, err := repo.Head(); err == nil {
		return head, nil
	}
	return nil, errors.New("no origin branch, origin/main, origin/master, or HEAD reference found to base memory branch on")
}

// fetch updates origin's remote-tracking refs from upstream. Already-up-to-date
// is treated as success.
func fetch(ctx context.Context, repo *git.Repository, auth gittransport.AuthMethod) error {
	fetchCtx, cancel := context.WithTimeout(ctx, DefaultFetchTimeout)
	defer cancel()
	err := repo.FetchContext(fetchCtx, &git.FetchOptions{
		RemoteName: "origin",
		RefSpecs:   []config.RefSpec{"+refs/heads/*:refs/remotes/origin/*"},
		Auth:       auth,
		Prune:      true,
	})
	if err == nil || errors.Is(err, git.NoErrAlreadyUpToDate) {
		return nil
	}
	return err
}

func originURLMatches(repo *git.Repository, repoURL string) (bool, error) {
	remote, err := repo.Remote("origin")
	if err != nil {
		return false, err
	}
	want := normalizeRemoteURL(repoURL)
	for _, candidate := range remote.Config().URLs {
		if normalizeRemoteURL(candidate) == want {
			return true, nil
		}
	}
	return false, nil
}

func normalizeRemoteURL(value string) string {
	return strings.TrimRight(strings.TrimSpace(value), "/")
}

func pullBoundBranch(ctx context.Context, repo *git.Repository, branch string, auth gittransport.AuthMethod) error {
	if _, err := repo.Reference(plumbing.NewRemoteReferenceName("origin", branch), true); err != nil {
		return nil
	}
	wt, err := repo.Worktree()
	if err != nil {
		return fmt.Errorf("worktree: %w", err)
	}
	pullCtx, cancel := context.WithTimeout(ctx, DefaultFetchTimeout)
	defer cancel()
	err = wt.PullContext(pullCtx, &git.PullOptions{
		RemoteName:    "origin",
		ReferenceName: plumbing.NewBranchReferenceName(branch),
		SingleBranch:  true,
		Auth:          auth,
	})
	if err == nil || errors.Is(err, git.NoErrAlreadyUpToDate) {
		return nil
	}
	return err
}

// Detach unlinks this clone from its upstream without deleting local content.
// After Detach, the clone path is a plain directory; the local git history
// remains on disk so the agent can keep reading memory offline, but further
// syncs stop until access is restored and EnsureClone is called again.
//
// Used when an agent loses access to the Memory. Local memory
// content is preserved.
//
// Implementation: remove the `.git` directory. We refuse to operate on paths
// whose `.git` is a file rather than a directory (which would indicate a
// prior linked-worktree pointer from before Phase 2's pivot to standalone
// clones) to avoid silently corrupting unrelated state.
//
// The call is idempotent: re-running on an already-detached directory (no
// .git) is a no-op.
func (c *Clone) Detach(ctx context.Context) error {
	if c == nil {
		return fmt.Errorf("vault: nil clone")
	}
	gitDir := filepath.Join(c.path, ".git")
	info, err := os.Stat(gitDir)
	if err != nil {
		if os.IsNotExist(err) {
			return nil
		}
		return fmt.Errorf("vault: stat clone .git: %w", err)
	}
	if !info.IsDir() {
		return fmt.Errorf("vault: %s/.git is a file (linked worktree?), refusing to detach", c.path)
	}
	if err := os.RemoveAll(gitDir); err != nil {
		return fmt.Errorf("vault: remove clone .git: %w", err)
	}
	return nil
}

// RebindOpts configures Rebind.
type RebindOpts struct {
	// NewMemoryID is the mem_xxx identifier of the new binding. Required.
	NewMemoryID string
	// NewRepoURL is the HTTPS URL of the new Memory repo as exposed by the
	// AgentTeam gateway. Required.
	NewRepoURL string
	// NewBranch is the new binding's branch. Required.
	NewBranch string
	// ReplaceLocal, when true, backs up the existing clone directory and
	// creates a fresh clone from the new binding. The previous local content
	// is preserved on disk under a `.backup-<timestamp>` sibling directory so
	// nothing is lost.
	//
	// When false, the existing clone's `.git` is preserved, its origin
	// remote is repointed to NewRepoURL, the new branch is checked out
	// (creating it from origin if needed), and the local branch is force-
	// pushed to the new origin so the local history lands on the new Memory.
	ReplaceLocal bool
	// Auth forwards the AGS repository sync lease for HTTPS origins. Nil is
	// acceptable for file:// tests.
	Auth gittransport.AuthMethod
	// Logger receives lifecycle events. nil falls back to slog.Default().
	Logger *slog.Logger
}

// RebindResult reports the outcome of a Rebind call. The returned Clone handle
// always points at the same on-disk path as the receiver, but its memoryID /
// branch fields reflect the new binding.
type RebindResult struct {
	Clone *Clone
	// BackupPath is set when ReplaceLocal was true and the prior clone
	// directory was moved out of the way. Empty in the repoint path.
	BackupPath string
	// RepointedRemote is true when the repoint path was taken.
	RepointedRemote bool
}

// Rebind switches an existing clone to a new Memory binding. The caller must
// have already stopped any watcher attached to the clone (Rebind does not
// touch the watcher) and is responsible for re-attaching one to the returned
// Clone handle.
//
// See RebindOpts.ReplaceLocal for the two supported branches.
func (c *Clone) Rebind(ctx context.Context, opts RebindOpts) (RebindResult, error) {
	if c == nil {
		return RebindResult{}, fmt.Errorf("vault: nil clone")
	}
	newMemoryID := strings.TrimSpace(opts.NewMemoryID)
	newRepoURL := strings.TrimSpace(opts.NewRepoURL)
	newBranch := strings.TrimSpace(opts.NewBranch)
	if newMemoryID == "" {
		return RebindResult{}, fmt.Errorf("vault: rebind requires new memory id")
	}
	if newRepoURL == "" {
		return RebindResult{}, fmt.Errorf("vault: rebind requires new repo url")
	}
	if newBranch == "" {
		return RebindResult{}, fmt.Errorf("vault: rebind requires new branch")
	}
	logger := opts.Logger
	if logger == nil {
		logger = slog.Default()
	}
	unlock, err := lockMemoryPath(ctx, c.path)
	if err != nil {
		return RebindResult{}, fmt.Errorf("vault: acquire memory path lock: %w", err)
	}
	defer unlock()

	if opts.ReplaceLocal {
		return c.rebindViaFreshClone(ctx, opts, logger, newMemoryID, newRepoURL, newBranch)
	}
	return c.rebindViaRepoint(ctx, opts, logger, newMemoryID, newRepoURL, newBranch)
}

// rebindViaFreshClone backs up the existing clone and creates a fresh one from
// the new binding. The returned Clone handle reuses the original path but
// wraps a newly-opened repository.
//
// The caller (Rebind) already holds lockMemoryPath(c.path); we must not call
// the public EnsureClone here because it would re-enter the same path lock and
// deadlock. ensureCloneLocked keeps the backup + re-clone swap atomic under the
// single outer lock.
func (c *Clone) rebindViaFreshClone(ctx context.Context, opts RebindOpts, logger *slog.Logger, newMemoryID, newRepoURL, newBranch string) (RebindResult, error) {
	if err := backupExistingClone(c.path); err != nil {
		return RebindResult{}, fmt.Errorf("vault: backup before rebind clone: %w", err)
	}
	logger.InfoContext(ctx, "vault rebind: backed up prior clone, recloning fresh",
		"memory_id_from", c.memoryID,
		"memory_id_to", newMemoryID,
		"path", c.path,
	)
	cloneOpts := EnsureCloneOpts{
		MemoryID:   newMemoryID,
		AgentID:    c.agentID,
		RepoURL:    newRepoURL,
		Branch:     newBranch,
		TargetPath: c.path,
		Auth:       opts.Auth,
		Logger:     logger,
	}
	if err := validateEnsureCloneOpts(cloneOpts); err != nil {
		return RebindResult{}, err
	}
	clone, err := ensureCloneLocked(ctx, cloneOpts)
	if err != nil {
		return RebindResult{}, err
	}
	return RebindResult{Clone: clone}, nil
}

// rebindViaRepoint updates the existing clone's origin remote URL to the new
// binding, ensures the new branch is checked out, force-pushes the local
// branch to the new origin, and updates retention state. The clone's local
// history and working tree are preserved.
func (c *Clone) rebindViaRepoint(ctx context.Context, opts RebindOpts, logger *slog.Logger, newMemoryID, newRepoURL, newBranch string) (RebindResult, error) {
	if c.repo == nil {
		return RebindResult{}, fmt.Errorf("vault: rebind requires an open repository")
	}
	if err := c.repointOriginRemote(newRepoURL); err != nil {
		return RebindResult{}, fmt.Errorf("vault: rebind repoint origin: %w", err)
	}
	if err := fetch(ctx, c.repo, opts.Auth); err != nil {
		return RebindResult{}, fmt.Errorf("vault: rebind fetch: %w", err)
	}
	if err := ensureBoundBranch(c.repo, newBranch); err != nil {
		return RebindResult{}, fmt.Errorf("vault: rebind ensure branch %s: %w", newBranch, err)
	}
	c.memoryID = newMemoryID
	c.branch = newBranch
	if err := writeActiveRetentionState(c.path, newMemoryID, c.agentID, newBranch); err != nil {
		return RebindResult{}, fmt.Errorf("vault: rebind write retention state: %w", err)
	}
	if _, err := c.pushLocked(ctx, PushOpts{Auth: opts.Auth, Force: true}); err != nil && !errors.Is(err, ErrNothingToPush) {
		logger.WarnContext(ctx, "vault rebind: force-push to new origin failed; local history remains in place and will retry on next sync",
			"memory_id_to", newMemoryID,
			"agent_id", c.agentID,
			"error", err)
		return RebindResult{}, fmt.Errorf("vault: rebind push: %w", err)
	}
	logger.InfoContext(ctx, "vault rebind: repointed origin and pushed local history",
		"memory_id_to", newMemoryID,
		"agent_id", c.agentID,
		"path", c.path,
	)
	return RebindResult{Clone: c, RepointedRemote: true}, nil
}

// repointOriginRemote rewrites the clone's origin remote URL to newRepoURL.
// When origin already exists its URL is replaced; when missing it is created.
func (c *Clone) repointOriginRemote(newRepoURL string) error {
	if _, err := c.repo.Remote("origin"); err == nil {
		if err := c.repo.DeleteRemote("origin"); err != nil {
			return fmt.Errorf("delete prior origin: %w", err)
		}
	}
	if _, err := c.repo.CreateRemote(&config.RemoteConfig{
		Name: "origin",
		URLs: []string{newRepoURL},
	}); err != nil {
		return fmt.Errorf("create new origin: %w", err)
	}
	return nil
}
