package memorywatcher_test

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

	"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"

	"github.com/tidbcloud/AgentTeam/desktop/internal/memorywatcher"
	"github.com/tidbcloud/AgentTeam/desktop/internal/vault"
)

// makeOriginRepo seeds a normal source repo with one commit on main and
// returns its file:// URL. Used to back the clone-per-agent flow under test.
func makeOriginRepo(t *testing.T, originDir 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("main"))))
	wt, err := repo.Worktree()
	require.NoError(t, err)
	require.NoError(t, os.WriteFile(filepath.Join(originDir, "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)
	return "file://" + originDir
}

// makeBareOriginRepo seeds a bare repository with one commit on main and
// returns its file:: URL. Bare origins accept pushes to any ref without the
// "branch is currently checked out" error that non-bare repos return, so push
// tests must use bare origins.
//
// Approach: build the empty bare origin, then PlainInit a separate non-bare
// staging repo, write+commit there, attach origin as a remote, and push main
// into the bare. (PlainClone of an empty bare repo fails with "remote
// repository is empty", so we cannot clone-then-commit.)
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)
	err = stagingRepo.PushContext(context.Background(), &git.PushOptions{
		RemoteName: "origin",
		RefSpecs:   []config.RefSpec{"+refs/heads/main:refs/heads/main"},
	})
	require.NoError(t, err)
	return "file://" + originDir
}

// newCloneForAgent constructs a real per-agent vault.Clone for the test.
func newCloneForAgent(t *testing.T, origin, agentID string) *vault.Clone {
	t.Helper()
	tmp := t.TempDir()
	c, err := vault.EnsureClone(context.Background(), vault.EnsureCloneOpts{
		MemoryID:   "mem_test",
		AgentID:    agentID,
		Branch:     "alice-memory",
		RepoURL:    origin,
		TargetPath: filepath.Join(tmp, "agents", agentID, "memory"),
	})
	require.NoError(t, err)
	return c
}

// go-git rewrites the memory clone's bound-branch ref in place: SetReference
// truncates the loose ref file (refs/heads/<branch>) and writes the new hash
// while the watcher's commit holds it. A reader that opens the ref file inside
// that window observes an empty file; go-git then falls back to packed-refs and,
// because the branch lives only as a loose ref, surfaces the miss as
// plumbing.ErrReferenceNotFound ("reference not found").
//
// The readHead* helpers below return ok=false on any such transient miss. The
// polling loops in require.Eventually treat a miss as "not yet changed" and
// retry, instead of failing the test via require.NoError the way a one-shot
// post-condition read would.

// readHeadMessage reads the HEAD commit message at clonePath, returning
// (message, true) on success. ok is false when HEAD cannot be read right now.
func readHeadMessage(clonePath string) (string, bool) {
	repo, err := git.PlainOpen(clonePath)
	if err != nil {
		return "", false
	}
	head, err := repo.Head()
	if err != nil {
		return "", false
	}
	commit, err := repo.CommitObject(head.Hash())
	if err != nil {
		return "", false
	}
	return commit.Message, true
}

// headMessage reads the HEAD commit message at clonePath. Used to verify the
// watcher's commits actually landed. Used for one-shot post-condition reads
// after the watcher has settled (no concurrent ref writer), so a read failure
// here is a real error — polling loops use readHeadMessage instead.
func headMessage(t *testing.T, clonePath string) string {
	t.Helper()
	msg, ok := readHeadMessage(clonePath)
	require.True(t, ok, "HEAD commit message must be readable at %s", clonePath)
	return msg
}

// headAuthorName reads the HEAD commit author name at clonePath.
func headAuthorName(t *testing.T, clonePath string) string {
	t.Helper()
	repo, err := git.PlainOpen(clonePath)
	require.NoError(t, err)
	head, err := repo.Head()
	require.NoError(t, err)
	commit, err := repo.CommitObject(head.Hash())
	require.NoError(t, err)
	return commit.Author.Name
}

func TestE2EWatcherCommitsRealClone(t *testing.T) {
	origin := makeOriginRepo(t, filepath.Join(t.TempDir(), "origin"))
	clone := newCloneForAgent(t, origin, "agt_alice")

	watcher, err := memorywatcher.New(memorywatcher.Options{
		MemoryDir: clone.Path(),
		Committer: memorywatcher.NewCloneCommitter(clone),
		Resolve: func(_ context.Context) memorywatcher.CommitContext {
			return memorywatcher.CommitContext{
				AgentID:    "agt_alice",
				ActorName:  "alice",
				ChannelID:  "#agentteam",
				TaskScope:  "#tsk_tk8msx2pmcjpw9ujlqn0",
				TaskNumber: 9,
				TaskTitle:  "Fix reply routing",
			}
		},
		Debounce: 30 * time.Millisecond,
	})
	require.NoError(t, err)
	require.NoError(t, watcher.Start(context.Background()))
	t.Cleanup(func() { _ = watcher.Close() })

	// Overwrite an existing root-level file. The clone's root is added to
	// the watcher on Start, so this event is guaranteed to arrive — unlike
	// a newly-created subdir where the watcher may race the mkdir.
	require.NoError(t, os.WriteFile(
		filepath.Join(clone.Path(), "MEMORY.md"),
		[]byte("# alice's memory\n\nUpdated by E2E test.\n"),
		0o644,
	))

	waitForHeadChange(t, clone.Path(), "initial commit", 3*time.Second)

	msg := headMessage(t, clone.Path())
	require.NotEqual(t, "initial commit", strings.TrimSpace(msg),
		"watcher should have committed over the seed commit")
	require.Contains(t, msg, "Update memory from task #t9 in #agentteam")
	require.Contains(t, msg, "Task: #t9 (#tsk_tk8msx2pmcjpw9ujlqn0) in #agentteam — Fix reply routing")
	require.Contains(t, msg, "AgentTeam-Channel: #agentteam")
	require.Contains(t, msg, "AgentTeam-Task: #t9")
	require.Contains(t, msg, "AgentTeam-Task-Scope: #tsk_tk8msx2pmcjpw9ujlqn0")
	require.NotContains(t, msg, "AgentTeam-Agent:")
	require.Equal(t, "alice", headAuthorName(t, clone.Path()))
}

func TestE2EWatcherAmendsSameScopeWithinDebounceWindow(t *testing.T) {
	origin := makeOriginRepo(t, filepath.Join(t.TempDir(), "origin"))
	clone := newCloneForAgent(t, origin, "agt_alice")

	watcher, err := memorywatcher.New(memorywatcher.Options{
		MemoryDir: clone.Path(),
		Committer: memorywatcher.NewCloneCommitter(clone),
		Resolve: func(_ context.Context) memorywatcher.CommitContext {
			return memorywatcher.CommitContext{
				AgentID:   "agt_alice",
				ChannelID: "#agentteam",
			}
		},
		Debounce: 30 * time.Millisecond,
	})
	require.NoError(t, err)
	require.NoError(t, watcher.Start(context.Background()))
	t.Cleanup(func() { _ = watcher.Close() })

	// First write — fresh commit.
	require.NoError(t, os.WriteFile(filepath.Join(clone.Path(), "MEMORY.md"), []byte("# alice's memory\n"), 0o644))
	waitForHeadChange(t, clone.Path(), "initial commit", 3*time.Second)
	firstMsg := headMessage(t, clone.Path())
	require.Contains(t, firstMsg, "Update memory from channel #agentteam")
	firstHash := headHash(t, clone.Path())

	// Second write same scope — must amend. HEAD hash should change (amend
	// rewrites the commit) but commit count must stay at 2 (seed + amended).
	require.NoError(t, os.WriteFile(filepath.Join(clone.Path(), "second.md"), []byte("more\n"), 0o644))
	require.Eventually(t, func() bool {
		// Non-fatal reader: the amend rewrites the bound-branch ref in place,
		// so a poll landing inside that window must read as "unchanged" and
		// keep polling rather than fail the test.
		h, ok := readHeadHash(clone.Path())
		return ok && h != firstHash
	}, 3*time.Second, 15*time.Millisecond)

	finalHash := headHash(t, clone.Path())
	require.NotEqual(t, firstHash, finalHash, "amend must rewrite HEAD")
	finalMsg := headMessage(t, clone.Path())
	require.Contains(t, finalMsg, "Update memory from channel #agentteam")
	require.Equal(t, commitCount(t, clone.Path()), 2, "amend must not stack a new commit on top")
}

// waitForHeadChange polls until the HEAD message differs from `from` or the
// timeout elapses. Both sides are trimmed for the comparison so callers can
// pass either form.
func waitForHeadChange(t *testing.T, clonePath, from string, timeout time.Duration) {
	t.Helper()
	from = strings.TrimSpace(from)
	require.Eventually(t, func() bool {
		// Use the non-fatal reader: a transient miss while the watcher rewrites
		// the bound-branch ref mid-commit must read as "unchanged" and keep
		// polling, not fail the test.
		msg, ok := readHeadMessage(clonePath)
		return ok && strings.TrimSpace(msg) != from
	}, timeout, 10*time.Millisecond)
}

// readHeadHash returns the HEAD commit hash as a hex string, or ok=false when
// HEAD cannot be read right now (see the note above readHeadMessage).
func readHeadHash(clonePath string) (string, bool) {
	repo, err := git.PlainOpen(clonePath)
	if err != nil {
		return "", false
	}
	head, err := repo.Head()
	if err != nil {
		return "", false
	}
	return head.Hash().String(), true
}

// headHash returns the HEAD commit hash as a hex string. Used for one-shot
// post-condition reads; polling loops use readHeadHash.
func headHash(t *testing.T, clonePath string) string {
	t.Helper()
	h, ok := readHeadHash(clonePath)
	require.True(t, ok, "HEAD must be readable at %s", clonePath)
	return h
}

// commitCount returns the number of commits reachable from HEAD. amend must
// not change this; a stacked commit would add one.
func commitCount(t *testing.T, clonePath string) int {
	t.Helper()
	repo, err := git.PlainOpen(clonePath)
	require.NoError(t, err)
	head, err := repo.Head()
	require.NoError(t, err)
	iter, err := repo.Log(&git.LogOptions{From: head.Hash()})
	require.NoError(t, err)
	count := 0
	for {
		_, err := iter.Next()
		if err != nil {
			break
		}
		count++
	}
	return count
}

// refHeadHash returns the hex hash of a named reference (e.g. "alice-memory")
// in the repository at repoPath. Used to inspect upstream bare repos after a
// push to verify the watcher-driven commits actually landed on origin.
func refHeadHash(t *testing.T, repoPath, refName string) string {
	t.Helper()
	repo, err := git.PlainOpen(repoPath)
	require.NoError(t, err)
	ref, err := repo.Reference(plumbing.NewBranchReferenceName(refName), true)
	require.NoError(t, err)
	return ref.Hash().String()
}

// TestE2EWatcherPushesToUpstreamAfterCommit drives the full clone → watcher
// commit → async push → upstream-verify roundtrip against a file:// bare
// origin. Validates that the per-commit push hook actually fires and that the
// pushed commits carry the watcher's commit message + AgentTeam-* trailers.
func TestE2EWatcherPushesToUpstreamAfterCommit(t *testing.T) {
	originDir := filepath.Join(t.TempDir(), "origin")
	origin := makeBareOriginRepo(t, originDir)
	clone := newCloneForAgent(t, origin, "agt_alice")
	pusher := memorywatcher.NewClonePusher(clone, nil) // file:// → nil auth

	watcher, err := memorywatcher.New(memorywatcher.Options{
		MemoryDir: clone.Path(),
		Committer: memorywatcher.NewCloneCommitter(clone),
		Push:      pusher,
		Resolve: func(_ context.Context) memorywatcher.CommitContext {
			return memorywatcher.CommitContext{
				AgentID:   "agt_alice",
				ActorName: "alice",
				ChannelID: "#agentteam",
			}
		},
		Debounce: 30 * time.Millisecond,
	})
	require.NoError(t, err)
	require.NoError(t, watcher.Start(context.Background()))
	t.Cleanup(func() { _ = watcher.Close() })

	require.NoError(t, os.WriteFile(
		filepath.Join(clone.Path(), "MEMORY.md"),
		[]byte("# alice's memory\n\npush-driven commit.\n"),
		0o644,
	))

	// First wait for the local commit, then wait for the async push to land
	// on origin's bound branch ref. Both happen within the debounce window
	// plus the push goroutine's latency.
	waitForHeadChange(t, clone.Path(), "initial commit", 3*time.Second)
	require.NotEqual(t, "initial commit", strings.TrimSpace(headMessage(t, clone.Path())),
		"watcher should have committed locally before push")

	var upstreamHash string
	require.Eventually(t, func() bool {
		if h, err := refHashQuiet(originDir, "alice-memory"); err == nil && h != "" {
			upstreamHash = h
			return true
		}
		return false
	}, 5*time.Second, 25*time.Millisecond)
	require.NotEmpty(t, upstreamHash, "push should have created alice-memory on origin")

	// Verify upstream's alice-memory carries the watcher commit.
	upstreamRepo, err := git.PlainOpen(originDir)
	require.NoError(t, err)
	upstreamCommit, err := upstreamRepo.CommitObject(plumbing.NewHash(upstreamHash))
	require.NoError(t, err)
	require.Contains(t, upstreamCommit.Message, "Update memory from channel #agentteam")
	require.Contains(t, upstreamCommit.Message, "AgentTeam-Channel: #agentteam")
}

// TestE2EFinalPushFlushesPendingCommit verifies that the watcher's Close +
// subsequent final push (as orchestrated by stopMemoryWatcher) catches a
// commit that was still in-flight when teardown began. This is the
// regression-guard for "agent made a memory write just before session end;
// does it reach upstream?"
func TestE2EFinalPushFlushesPendingCommit(t *testing.T) {
	originDir := filepath.Join(t.TempDir(), "origin")
	origin := makeBareOriginRepo(t, originDir)
	clone := newCloneForAgent(t, origin, "agt_alice")

	// No Push hook on the watcher — we want to exercise the explicit final
	// push path, not the per-commit async push.
	watcher, err := memorywatcher.New(memorywatcher.Options{
		MemoryDir: clone.Path(),
		Committer: memorywatcher.NewCloneCommitter(clone),
		Resolve: func(_ context.Context) memorywatcher.CommitContext {
			return memorywatcher.CommitContext{AgentID: "agt_alice", ChannelID: "#agentteam"}
		},
		Debounce: 30 * time.Millisecond,
	})
	require.NoError(t, err)
	require.NoError(t, watcher.Start(context.Background()))

	require.NoError(t, os.WriteFile(
		filepath.Join(clone.Path(), "MEMORY.md"),
		[]byte("# alice's memory\n\nfinal push test.\n"),
		0o644,
	))

	// Close the watcher immediately — this cancels the debounce timer. The
	// commit never happens via the watcher because we close before debounce
	// fires. Then we explicitly commit + push via vault to simulate the
	// "stopMemoryWatcher final push" semantics.
	require.NoError(t, watcher.Close())

	_, err = clone.CommitPending(context.Background(), "manual commit before final push", vault.CommitPendingOpts{})
	require.NoError(t, err)
	_, err = clone.Push(context.Background(), vault.PushOpts{})
	require.NoError(t, err)

	upstreamHash, err := refHashQuiet(originDir, "alice-memory")
	require.NoError(t, err)
	require.NotEmpty(t, upstreamHash, "final push must land on origin")

	upstreamRepo, err := git.PlainOpen(originDir)
	require.NoError(t, err)
	upstreamCommit, err := upstreamRepo.CommitObject(plumbing.NewHash(upstreamHash))
	require.NoError(t, err)
	require.Contains(t, upstreamCommit.Message, "manual commit before final push")
}

// refHashQuiet returns the hex hash of refs/heads/<branch> at repoPath, or
// an error when the ref does not exist yet (e.g., origin has not received
// the push). Used by polling loops in push tests.
func refHashQuiet(repoPath, branch string) (string, error) {
	repo, err := git.PlainOpen(repoPath)
	if err != nil {
		return "", err
	}
	ref, err := repo.Reference(plumbing.NewBranchReferenceName(branch), true)
	if err != nil {
		return "", err
	}
	return ref.Hash().String(), nil
}
