package gitengine

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

type fakeRunner struct {
	outputs []string
	errs    []error
	calls   []fakeCall
}

type fakeCall struct {
	workdir string
	args    []string
}

func (f *fakeRunner) Run(workdir string, args ...string) (string, error) {
	f.calls = append(f.calls, fakeCall{
		workdir: workdir,
		args:    append([]string(nil), args...),
	})
	callIndex := len(f.calls) - 1
	var output string
	if callIndex < len(f.outputs) {
		output = f.outputs[callIndex]
	}
	var err error
	if callIndex < len(f.errs) {
		err = f.errs[callIndex]
	}
	return output, err
}

func TestCLIRunTimesOut(t *testing.T) {
	_, err := (CLI{Timeout: 10 * time.Millisecond}).Run("", "-c", "alias.wait=!sleep 1", "wait")
	if !errors.Is(err, context.DeadlineExceeded) {
		t.Fatalf("expected context deadline exceeded, got %v", err)
	}
}

func TestStatusClean(t *testing.T) {
	runner := &fakeRunner{outputs: []string{"\n"}}
	result, err := Status(runner, "/repo")
	if err != nil {
		t.Fatalf("Status: %v", err)
	}
	if !result.Clean {
		t.Fatal("expected clean status")
	}
	if !reflect.DeepEqual(runner.calls[0].args, []string{"status", "--porcelain"}) {
		t.Fatalf("args: %#v", runner.calls[0].args)
	}
}

func TestDiff(t *testing.T) {
	runner := &fakeRunner{outputs: []string{"diff output"}}
	got, err := Diff(runner, "/repo", DiffOptions{})
	if err != nil {
		t.Fatalf("Diff: %v", err)
	}
	if got != "diff output" {
		t.Fatalf("diff output: got %q", got)
	}
	want := []fakeCall{
		{workdir: "/repo", args: []string{"diff"}},
	}
	if !reflect.DeepEqual(runner.calls, want) {
		t.Fatalf("calls:\n got %#v\nwant %#v", runner.calls, want)
	}
}

func TestDiffStagedPathspecs(t *testing.T) {
	runner := &fakeRunner{}
	_, err := Diff(runner, "/repo", DiffOptions{
		Staged:    true,
		Pathspecs: []string{" README.md ", "", "services/core/main.go"},
	})
	if err != nil {
		t.Fatalf("Diff: %v", err)
	}
	want := []fakeCall{
		{workdir: "/repo", args: []string{"diff", "--cached", "--", "README.md", "services/core/main.go"}},
	}
	if !reflect.DeepEqual(runner.calls, want) {
		t.Fatalf("calls:\n got %#v\nwant %#v", runner.calls, want)
	}
}

func TestDiffRequiresWorkdir(t *testing.T) {
	_, err := Diff(&fakeRunner{}, "", DiffOptions{})
	if !errors.Is(err, ErrInvalidGitInput) {
		t.Fatalf("expected ErrInvalidGitInput, got %v", err)
	}
}

func TestCloneFetchCheckout(t *testing.T) {
	runner := &fakeRunner{}
	if err := Clone(runner, CloneOptions{
		RemoteURL:    "ssh://git.example/nomadcode.git",
		WorktreePath: "/workspace/nomadcode/000",
		Branch:       "develop",
	}); err != nil {
		t.Fatalf("Clone: %v", err)
	}
	if err := Fetch(runner, "/workspace/nomadcode/000", ""); err != nil {
		t.Fatalf("Fetch: %v", err)
	}
	if err := Checkout(runner, "/workspace/nomadcode/000", "feature/a"); err != nil {
		t.Fatalf("Checkout: %v", err)
	}

	want := []fakeCall{
		{args: []string{"clone", "--branch", "develop", "ssh://git.example/nomadcode.git", "/workspace/nomadcode/000"}},
		{workdir: "/workspace/nomadcode/000", args: []string{"fetch", "--prune", "origin"}},
		{workdir: "/workspace/nomadcode/000", args: []string{"checkout", "feature/a"}},
	}
	if !reflect.DeepEqual(runner.calls, want) {
		t.Fatalf("calls:\n got %#v\nwant %#v", runner.calls, want)
	}
}

func TestCommitAndPush(t *testing.T) {
	runner := &fakeRunner{}
	if err := Commit(runner, "/repo", CommitOptions{Message: "sync roadmap", All: true}); err != nil {
		t.Fatalf("Commit: %v", err)
	}
	if err := Push(runner, "/repo", "", "develop"); err != nil {
		t.Fatalf("Push: %v", err)
	}
	want := []fakeCall{
		{workdir: "/repo", args: []string{"add", "-A"}},
		{workdir: "/repo", args: []string{"commit", "-m", "sync roadmap"}},
		{workdir: "/repo", args: []string{"push", "origin", "develop"}},
	}
	if !reflect.DeepEqual(runner.calls, want) {
		t.Fatalf("calls:\n got %#v\nwant %#v", runner.calls, want)
	}
}

func TestHeadRevision(t *testing.T) {
	runner := &fakeRunner{outputs: []string{"abc123\n"}}
	got, err := HeadRevision(runner, "/repo")
	if err != nil {
		t.Fatalf("HeadRevision: %v", err)
	}
	if got != "abc123" {
		t.Fatalf("revision: got %q", got)
	}
}

func TestChangedFiles(t *testing.T) {
	runner := &fakeRunner{outputs: []string{"README.md\nservices/core/main.go\n\n"}}
	files, err := ChangedFiles(runner, "/repo", "abc", "def")
	if err != nil {
		t.Fatalf("ChangedFiles: %v", err)
	}
	want := []string{"README.md", "services/core/main.go"}
	if !reflect.DeepEqual(files, want) {
		t.Fatalf("files: got %#v want %#v", files, want)
	}
}

func TestChangedFilesRequiresRevisions(t *testing.T) {
	_, err := ChangedFiles(&fakeRunner{}, "/repo", "", "def")
	if !errors.Is(err, ErrInvalidGitInput) {
		t.Fatalf("expected ErrInvalidGitInput, got %v", err)
	}
}

func TestChangedFilesWithStatus(t *testing.T) {
	runner := &fakeRunner{outputs: []string{"A\tREADME.md\nM\tservices/core/main.go\nD\told.md\nR100\tfrom.md\tto.md\n"}}
	files, err := ChangedFilesWithStatus(runner, "/repo", "abc", "def")
	if err != nil {
		t.Fatalf("ChangedFilesWithStatus: %v", err)
	}
	want := []ChangedFile{
		{Path: "README.md", ChangeType: "added"},
		{Path: "services/core/main.go", ChangeType: "modified"},
		{Path: "old.md", ChangeType: "deleted"},
		{Path: "to.md", ChangeType: "renamed"},
	}
	if !reflect.DeepEqual(files, want) {
		t.Fatalf("files:\n got %#v\nwant %#v", files, want)
	}
}

func TestChangedFilesWithStatusPreservesWhitespaceInPaths(t *testing.T) {
	runner := &fakeRunner{outputs: []string{
		"A\tdocs/user guide.md\nM\tpath with spaces/file name.go\nR100\told name.md\tnew name.md\n",
	}}
	files, err := ChangedFilesWithStatus(runner, "/repo", "abc", "def")
	if err != nil {
		t.Fatalf("ChangedFilesWithStatus: %v", err)
	}
	want := []ChangedFile{
		{Path: "docs/user guide.md", ChangeType: "added"},
		{Path: "path with spaces/file name.go", ChangeType: "modified"},
		{Path: "new name.md", ChangeType: "renamed"},
	}
	if !reflect.DeepEqual(files, want) {
		t.Fatalf("files:\n got %#v\nwant %#v", files, want)
	}
}

func TestChangedFilesWithStatusLocalCommits(t *testing.T) {
	_, seedPath, initialRev := setupBareRemote(t)

	writeFile(t, filepath.Join(seedPath, "README.md"), "# Seed Repository\n\nupdated\n")
	writeFile(t, filepath.Join(seedPath, "app.txt"), "app\n")
	runGit(t, seedPath, "add", "README.md", "app.txt")
	runGit(t, seedPath, "commit", "-m", "update readme and add app")

	docsDir := filepath.Join(seedPath, "docs")
	if err := os.MkdirAll(docsDir, 0755); err != nil {
		t.Fatalf("MkdirAll %s failed: %v", docsDir, err)
	}
	writeFile(t, filepath.Join(docsDir, "guide.md"), "# Guide\n")
	runGit(t, seedPath, "add", "docs/guide.md")
	runGit(t, seedPath, "commit", "-m", "add guide")
	afterRev := gitOutput(t, seedPath, "rev-parse", "HEAD")

	names, err := ChangedFiles(CLI{}, seedPath, initialRev, afterRev)
	if err != nil {
		t.Fatalf("ChangedFiles: %v", err)
	}
	wantNames := []string{"README.md", "app.txt", "docs/guide.md"}
	if !sameStringSet(names, wantNames) {
		t.Fatalf("changed file names: got %#v want %#v", names, wantNames)
	}

	files, err := ChangedFilesWithStatus(CLI{}, seedPath, initialRev, afterRev)
	if err != nil {
		t.Fatalf("ChangedFilesWithStatus: %v", err)
	}
	wantStatus := map[string]string{
		"README.md":     "modified",
		"app.txt":       "added",
		"docs/guide.md": "added",
	}
	if !sameChangedFileStatus(files, wantStatus) {
		t.Fatalf("changed file status: got %#v want %#v", files, wantStatus)
	}
}

func sameStringSet(got, want []string) bool {
	if len(got) != len(want) {
		return false
	}
	seen := make(map[string]int, len(got))
	for _, item := range got {
		seen[item]++
	}
	for _, item := range want {
		if seen[item] == 0 {
			return false
		}
		seen[item]--
	}
	return true
}

func sameChangedFileStatus(got []ChangedFile, want map[string]string) bool {
	if len(got) != len(want) {
		return false
	}
	for _, file := range got {
		if want[file.Path] != file.ChangeType {
			return false
		}
	}
	return true
}

func runGit(t *testing.T, dir string, args ...string) {
	cmd := exec.Command("git", args...)
	cmd.Dir = dir
	if out, err := cmd.CombinedOutput(); err != nil {
		t.Fatalf("git %s failed in %s: %v\nOutput: %s", strings.Join(args, " "), dir, err, string(out))
	}
}

func gitOutput(t *testing.T, dir string, args ...string) string {
	cmd := exec.Command("git", args...)
	cmd.Dir = dir
	out, err := cmd.CombinedOutput()
	if err != nil {
		t.Fatalf("git %s failed in %s: %v\nOutput: %s", strings.Join(args, " "), dir, err, string(out))
	}
	return strings.TrimSpace(string(out))
}

func writeFile(t *testing.T, path, content string) {
	if err := os.WriteFile(path, []byte(content), 0644); err != nil {
		t.Fatalf("WriteFile %s failed: %v", path, err)
	}
}

func setupBareRemote(t *testing.T) (barePath, seedPath string, initialRev string) {
	tmpDir := t.TempDir()

	// 1. Create bare remote repo
	barePath = filepath.Join(tmpDir, "remote.git")
	if err := os.MkdirAll(barePath, 0755); err != nil {
		t.Fatalf("failed to create bare remote dir: %v", err)
	}
	runGit(t, barePath, "init", "--bare")
	// Make sure default branch is main
	runGit(t, barePath, "symbolic-ref", "HEAD", "refs/heads/main")

	// 2. Create seed repo to initialize the remote
	seedPath = filepath.Join(tmpDir, "seed")
	if err := os.MkdirAll(seedPath, 0755); err != nil {
		t.Fatalf("failed to create seed dir: %v", err)
	}
	runGit(t, seedPath, "init")
	runGit(t, seedPath, "config", "user.name", "Test User")
	runGit(t, seedPath, "config", "user.email", "test@example.com")
	runGit(t, seedPath, "config", "commit.gpgsign", "false")

	// Create first commit
	writeFile(t, filepath.Join(seedPath, "README.md"), "# Seed Repository\n")
	runGit(t, seedPath, "add", "README.md")
	runGit(t, seedPath, "commit", "-m", "initial commit")
	runGit(t, seedPath, "branch", "-M", "main")

	// Push to bare remote
	runGit(t, seedPath, "remote", "add", "origin", barePath)
	runGit(t, seedPath, "push", "-u", "origin", "main")

	initialRev = gitOutput(t, seedPath, "rev-parse", "HEAD")
	return barePath, seedPath, initialRev
}

func TestCloneFetchWithLocalBareRemote(t *testing.T) {
	barePath, seedPath, initialRev := setupBareRemote(t)
	tmpDir := t.TempDir()
	worktreePath := filepath.Join(tmpDir, "worktree")

	// 1. Clone
	cli := CLI{}
	err := Clone(cli, CloneOptions{
		RemoteURL:    barePath,
		WorktreePath: worktreePath,
		Branch:       "main",
	})
	if err != nil {
		t.Fatalf("Clone failed: %v", err)
	}

	// Verify cloned commit
	rev, err := HeadRevision(cli, worktreePath)
	if err != nil {
		t.Fatalf("HeadRevision failed: %v", err)
	}
	if rev != initialRev {
		t.Fatalf("expected cloned rev %q, got %q", initialRev, rev)
	}

	// 2. Make new commit in seed and push to bare remote
	writeFile(t, filepath.Join(seedPath, "file.txt"), "hello")
	runGit(t, seedPath, "add", "file.txt")
	runGit(t, seedPath, "commit", "-m", "second commit")
	runGit(t, seedPath, "push", "origin", "main")
	newRev := gitOutput(t, seedPath, "rev-parse", "HEAD")

	// 3. Fetch in worktree
	err = Fetch(cli, worktreePath, "origin")
	if err != nil {
		t.Fatalf("Fetch failed: %v", err)
	}

	// Verify origin/main has the new commit
	fetchedRev := gitOutput(t, worktreePath, "rev-parse", "origin/main")
	if fetchedRev != newRev {
		t.Fatalf("expected fetched rev %q, got %q", newRev, fetchedRev)
	}
}

func TestStatusDiffWithCleanStagedUnstagedWorkspace(t *testing.T) {
	barePath, _, _ := setupBareRemote(t)
	tmpDir := t.TempDir()
	worktreePath := filepath.Join(tmpDir, "worktree")

	cli := CLI{}
	if err := Clone(cli, CloneOptions{RemoteURL: barePath, WorktreePath: worktreePath, Branch: "main"}); err != nil {
		t.Fatalf("Clone failed: %v", err)
	}

	// 1. Clean workspace
	status, err := Status(cli, worktreePath)
	if err != nil {
		t.Fatalf("Status failed: %v", err)
	}
	if !status.Clean {
		t.Fatalf("expected clean status, got porcelain: %q", status.Output)
	}

	diff, err := Diff(cli, worktreePath, DiffOptions{})
	if err != nil {
		t.Fatalf("Diff failed: %v", err)
	}
	if diff != "" {
		t.Fatalf("expected empty unstaged diff, got: %q", diff)
	}

	// 2. Unstaged changes (modified file)
	writeFile(t, filepath.Join(worktreePath, "README.md"), "# Seed Repository\nmodified content\n")

	status, err = Status(cli, worktreePath)
	if err != nil {
		t.Fatalf("Status failed: %v", err)
	}
	if status.Clean {
		t.Fatal("expected status to be dirty (unstaged changes)")
	}
	if !strings.Contains(status.Output, " README.md") {
		t.Fatalf("expected README.md in status, got: %q", status.Output)
	}

	diff, err = Diff(cli, worktreePath, DiffOptions{})
	if err != nil {
		t.Fatalf("Diff failed: %v", err)
	}
	if !strings.Contains(diff, "+modified content") {
		t.Fatalf("expected diff to show modifications, got: %q", diff)
	}

	// Staged diff should be empty
	stagedDiff, err := Diff(cli, worktreePath, DiffOptions{Staged: true})
	if err != nil {
		t.Fatalf("Diff staged failed: %v", err)
	}
	if stagedDiff != "" {
		t.Fatalf("expected empty staged diff, got: %q", stagedDiff)
	}

	// 3. Staged changes
	runGit(t, worktreePath, "add", "README.md")

	status, err = Status(cli, worktreePath)
	if err != nil {
		t.Fatalf("Status failed: %v", err)
	}
	if status.Clean {
		t.Fatal("expected status to be dirty (staged changes)")
	}

	// Unstaged diff should be empty now
	diff, err = Diff(cli, worktreePath, DiffOptions{})
	if err != nil {
		t.Fatalf("Diff failed: %v", err)
	}
	if diff != "" {
		t.Fatalf("expected empty unstaged diff after staging, got: %q", diff)
	}

	// Staged diff should show modifications
	stagedDiff, err = Diff(cli, worktreePath, DiffOptions{Staged: true})
	if err != nil {
		t.Fatalf("Diff staged failed: %v", err)
	}
	if !strings.Contains(stagedDiff, "+modified content") {
		t.Fatalf("expected staged diff to show modifications, got: %q", stagedDiff)
	}
}

func TestCommitPushToLocalBareRemote(t *testing.T) {
	barePath, _, _ := setupBareRemote(t)
	tmpDir := t.TempDir()
	worktreePath := filepath.Join(tmpDir, "worktree")

	cli := CLI{}
	if err := Clone(cli, CloneOptions{RemoteURL: barePath, WorktreePath: worktreePath, Branch: "main"}); err != nil {
		t.Fatalf("Clone failed: %v", err)
	}

	// Configure local git user for the worktree as well
	runGit(t, worktreePath, "config", "user.name", "Test User")
	runGit(t, worktreePath, "config", "user.email", "test@example.com")
	runGit(t, worktreePath, "config", "commit.gpgsign", "false")

	// Make changes and commit
	writeFile(t, filepath.Join(worktreePath, "newfile.txt"), "hello commit")
	err := Commit(cli, worktreePath, CommitOptions{
		Message: "add newfile",
		All:     true,
	})
	if err != nil {
		t.Fatalf("Commit failed: %v", err)
	}

	localRev, err := HeadRevision(cli, worktreePath)
	if err != nil {
		t.Fatalf("HeadRevision failed: %v", err)
	}

	// Push
	err = Push(cli, worktreePath, "origin", "main")
	if err != nil {
		t.Fatalf("Push failed: %v", err)
	}

	// Verify bare remote branch has updated to local commit revision
	remoteRev := gitOutput(t, barePath, "rev-parse", "main")
	if remoteRev != localRev {
		t.Fatalf("expected remote revision %q, got %q", localRev, remoteRev)
	}
}

func TestCheckoutBranchFromSourceArgs(t *testing.T) {
	runner := &fakeRunner{}

	// 1. Simple checkout
	if err := CheckoutBranch(runner, "/repo", CheckoutOptions{Branch: "main"}); err != nil {
		t.Fatalf("CheckoutBranch failed: %v", err)
	}

	// 2. Checkout with start point
	if err := CheckoutBranch(runner, "/repo", CheckoutOptions{Branch: "feature/a", StartPoint: "main"}); err != nil {
		t.Fatalf("CheckoutBranch failed: %v", err)
	}

	// 3. Checkout reset with start point
	if err := CheckoutBranch(runner, "/repo", CheckoutOptions{Branch: "feature/b", StartPoint: "main", Reset: true}); err != nil {
		t.Fatalf("CheckoutBranch failed: %v", err)
	}

	want := []fakeCall{
		{workdir: "/repo", args: []string{"checkout", "main"}},
		{workdir: "/repo", args: []string{"checkout", "-b", "feature/a", "main"}},
		{workdir: "/repo", args: []string{"checkout", "-B", "feature/b", "main"}},
	}
	if !reflect.DeepEqual(runner.calls, want) {
		t.Fatalf("calls:\n got %#v\nwant %#v", runner.calls, want)
	}
}

func TestCheckoutBranchFromSource(t *testing.T) {
	barePath, _, initialRev := setupBareRemote(t)
	tmpDir := t.TempDir()
	worktreePath := filepath.Join(tmpDir, "worktree")

	cli := CLI{}
	if err := Clone(cli, CloneOptions{RemoteURL: barePath, WorktreePath: worktreePath, Branch: "main"}); err != nil {
		t.Fatalf("Clone failed: %v", err)
	}

	// Configure local user
	runGit(t, worktreePath, "config", "user.name", "Test User")
	runGit(t, worktreePath, "config", "user.email", "test@example.com")
	runGit(t, worktreePath, "config", "commit.gpgsign", "false")

	// Make another commit in main
	writeFile(t, filepath.Join(worktreePath, "main_only.txt"), "main data")
	if err := Commit(cli, worktreePath, CommitOptions{Message: "commit on main", All: true}); err != nil {
		t.Fatalf("Commit failed: %v", err)
	}
	mainRev, err := HeadRevision(cli, worktreePath)
	if err != nil {
		t.Fatalf("HeadRevision failed: %v", err)
	}

	// 1. Checkout new branch from main's initialRev (start point)
	err = CheckoutBranch(cli, worktreePath, CheckoutOptions{
		Branch:     "feature/from-start",
		StartPoint: initialRev,
	})
	if err != nil {
		t.Fatalf("CheckoutBranch from initialRev failed: %v", err)
	}

	// Verify we are on feature/from-start, and revision is initialRev (not mainRev)
	currentRev, err := HeadRevision(cli, worktreePath)
	if err != nil {
		t.Fatalf("HeadRevision failed: %v", err)
	}
	if currentRev != initialRev {
		t.Fatalf("expected HEAD revision to be %q, got %q", initialRev, currentRev)
	}

	// 2. Checkout new branch from main's current mainRev (start point) with Reset: true
	// To test Reset flag (-B), checkout main again
	if err := Checkout(cli, worktreePath, "main"); err != nil {
		t.Fatalf("Checkout main failed: %v", err)
	}

	// Create branch feature/reset with initialRev first
	err = CheckoutBranch(cli, worktreePath, CheckoutOptions{
		Branch:     "feature/reset",
		StartPoint: initialRev,
	})
	if err != nil {
		t.Fatalf("CheckoutBranch feature/reset failed: %v", err)
	}

	// Now reset it to mainRev
	err = CheckoutBranch(cli, worktreePath, CheckoutOptions{
		Branch:     "feature/reset",
		StartPoint: mainRev,
		Reset:      true,
	})
	if err != nil {
		t.Fatalf("CheckoutBranch reset failed: %v", err)
	}

	currentRev, err = HeadRevision(cli, worktreePath)
	if err != nil {
		t.Fatalf("HeadRevision failed: %v", err)
	}
	if currentRev != mainRev {
		t.Fatalf("expected HEAD revision to be reset to %q, got %q", mainRev, currentRev)
	}
}
