package gitx

import (
	"context"
	"errors"
	"strings"
	"testing"

	"sourcecraft.dev/bigbes/sr-ht-compare/core"
)

func ctx() context.Context { return context.Background() }

func TestOpen(t *testing.T) {
	reposRoot := newFixtureRepo(t)

	t.Run("valid", func(t *testing.T) {
		repo, err := Open(reposRoot, fxOwner, fxName)
		if err != nil {
			t.Fatalf("Open: %v", err)
		}
		if !strings.HasSuffix(repo.Dir(), "~alice/demo") {
			t.Errorf("dir = %q", repo.Dir())
		}
	})

	// Every hostile input must fail with ErrNotFound and never escape reposRoot.
	traversal := []struct {
		name        string
		owner, repo string
	}{
		{"dotdot owner", "..", "demo"},
		{"tilde owner", "~evil", "demo"},
		{"slash in repo", "alice", "../../etc"},
		{"dotdot repo", "alice", ".."},
		{"absolute-ish repo", "alice", "/etc/passwd"},
		{"leading dash owner", "-x", "demo"},
		{"missing repo", "alice", "nonexistent"},
		{"missing owner", "bob", "demo"},
	}
	for _, tt := range traversal {
		t.Run(tt.name, func(t *testing.T) {
			_, err := Open(reposRoot, tt.owner, tt.repo)
			if err == nil {
				t.Fatalf("Open(%q,%q) succeeded, want error", tt.owner, tt.repo)
			}
			if !errors.Is(err, core.ErrNotFound) {
				t.Errorf("err = %v, want ErrNotFound", err)
			}
		})
	}
}

func TestRefs(t *testing.T) {
	reposRoot := newFixtureRepo(t)
	repo := openFixture(t, reposRoot)

	branches, tags, err := repo.Refs(ctx())
	if err != nil {
		t.Fatalf("Refs: %v", err)
	}

	if len(branches) != 2 {
		t.Fatalf("branches = %v, want 2", branches)
	}
	if branches[0].Name != "main" {
		t.Errorf("branches[0] = %q, want HEAD branch main first", branches[0].Name)
	}
	if branches[1].Name != "feature/with-slash" {
		t.Errorf("branches[1] = %q, want feature/with-slash", branches[1].Name)
	}
	if branches[0].SHA != fxRev(t, reposRoot, "main") {
		t.Errorf("main SHA = %q, want %q", branches[0].SHA, fxRev(t, reposRoot, "main"))
	}

	if len(tags) != 2 {
		t.Fatalf("tags = %v, want 2", tags)
	}
	// Newest creator-date first: v1.0.0 was tagged after v0.9.0.
	if tags[0].Name != "v1.0.0" || tags[1].Name != "v0.9.0" {
		t.Errorf("tags order = [%q %q], want [v1.0.0 v0.9.0]", tags[0].Name, tags[1].Name)
	}

	def, err := repo.DefaultBranch(ctx())
	if err != nil {
		t.Fatalf("DefaultBranch: %v", err)
	}
	if def != "main" {
		t.Errorf("DefaultBranch = %q, want main", def)
	}
}

func TestResolveCommit(t *testing.T) {
	reposRoot := newFixtureRepo(t)
	repo := openFixture(t, reposRoot)

	mainSHA := fxRev(t, reposRoot, "main")
	rootSHA := fxRev(t, reposRoot, "main^1~3")

	t.Run("branch", func(t *testing.T) {
		ci, err := repo.ResolveCommit(ctx(), "main")
		if err != nil {
			t.Fatalf("ResolveCommit(main): %v", err)
		}
		if ci.SHA != mainSHA {
			t.Errorf("SHA = %q, want %q", ci.SHA, mainSHA)
		}
		if len(ci.ParentSHAs) != 2 {
			t.Errorf("main is a merge, parents = %v", ci.ParentSHAs)
		}
		if ci.AuthorName != "Alice Example" {
			t.Errorf("author = %q", ci.AuthorName)
		}
	})

	t.Run("annotated tag peels to commit", func(t *testing.T) {
		ci, err := repo.ResolveCommit(ctx(), "v1.0.0")
		if err != nil {
			t.Fatalf("ResolveCommit(v1.0.0): %v", err)
		}
		if ci.SHA != mainSHA {
			t.Errorf("SHA = %q, want %q", ci.SHA, mainSHA)
		}
	})

	t.Run("full SHA", func(t *testing.T) {
		ci, err := repo.ResolveCommit(ctx(), rootSHA)
		if err != nil {
			t.Fatalf("ResolveCommit(root SHA): %v", err)
		}
		if ci.SHA != rootSHA {
			t.Errorf("SHA = %q, want %q", ci.SHA, rootSHA)
		}
		if len(ci.ParentSHAs) != 0 {
			t.Errorf("root parents = %v, want none", ci.ParentSHAs)
		}
	})

	t.Run("branch with slash", func(t *testing.T) {
		ci, err := repo.ResolveCommit(ctx(), "feature/with-slash")
		if err != nil {
			t.Fatalf("ResolveCommit(feature/with-slash): %v", err)
		}
		if ci.SHA != fxRev(t, reposRoot, "feature/with-slash") {
			t.Errorf("SHA = %q", ci.SHA)
		}
	})

	t.Run("nonexistent ref is ErrBadRef", func(t *testing.T) {
		_, err := repo.ResolveCommit(ctx(), "does-not-exist")
		if !errors.Is(err, core.ErrBadRef) {
			t.Errorf("err = %v, want ErrBadRef", err)
		}
	})

	t.Run("malformed ref is ErrBadRef", func(t *testing.T) {
		_, err := repo.ResolveCommit(ctx(), "@")
		if !errors.Is(err, core.ErrBadRef) {
			t.Errorf("err = %v, want ErrBadRef", err)
		}
	})
}

func TestLog(t *testing.T) {
	reposRoot := newFixtureRepo(t)
	repo := openFixture(t, reposRoot)

	base := fxRev(t, reposRoot, "main^1~2") // c2
	head := fxRev(t, reposRoot, "main^1")   // binary commit

	commits, err := repo.Log(ctx(), base, head, 10)
	if err != nil {
		t.Fatalf("Log: %v", err)
	}
	if len(commits) != 2 {
		t.Fatalf("commits = %d, want 2 (rename, binary)", len(commits))
	}
	// Newest first.
	if !strings.HasPrefix(commits[0].Subject, "binary") {
		t.Errorf("commits[0] = %q, want binary", commits[0].Subject)
	}
	if !strings.HasPrefix(commits[1].Subject, "rename") {
		t.Errorf("commits[1] = %q, want rename", commits[1].Subject)
	}

	t.Run("limit", func(t *testing.T) {
		commits, err := repo.Log(ctx(), base, head, 1)
		if err != nil {
			t.Fatalf("Log: %v", err)
		}
		if len(commits) != 1 {
			t.Errorf("commits = %d, want 1", len(commits))
		}
	})
}

func TestDiffThreeDotVsTwoDot(t *testing.T) {
	reposRoot := newFixtureRepo(t)
	repo := openFixture(t, reposRoot)

	// base = binary commit (has rename + binary on its side), head = feature.
	// Their merge base is c2. Three-dot must show only head-side changes
	// (feature.txt); two-dot compares the endpoints directly and surfaces the
	// base-only files.
	base := fxRev(t, reposRoot, "main^1")
	head := "feature/with-slash" // slash branch name passes through verbatim

	three, err := repo.Diff(ctx(), core.CompareSpec{Base: base, Head: head, ThreeDot: true})
	if err != nil {
		t.Fatalf("Diff three-dot: %v", err)
	}
	if !strings.Contains(three.Text, "feature.txt") {
		t.Errorf("three-dot diff missing feature.txt")
	}
	if strings.Contains(three.Text, "bin.dat") {
		t.Errorf("three-dot diff should NOT contain base-only bin.dat")
	}

	two, err := repo.Diff(ctx(), core.CompareSpec{Base: base, Head: head, ThreeDot: false})
	if err != nil {
		t.Fatalf("Diff two-dot: %v", err)
	}
	if !strings.Contains(two.Text, "bin.dat") {
		t.Errorf("two-dot diff should surface base-side bin.dat")
	}
}

func TestDiffStat(t *testing.T) {
	reposRoot := newFixtureRepo(t)
	repo := openFixture(t, reposRoot)

	// c2 -> binary: a.txt renamed to a-renamed.txt, bin.dat added (binary).
	spec := core.CompareSpec{
		Base: fxRev(t, reposRoot, "main^1~2"),
		Head: fxRev(t, reposRoot, "main^1"),
	}
	files, err := repo.DiffStat(ctx(), spec)
	if err != nil {
		t.Fatalf("DiffStat: %v", err)
	}

	byPath := map[string]FileChange{}
	for _, f := range files {
		byPath[f.Path] = f
	}

	ren, ok := byPath["a-renamed.txt"]
	if !ok {
		t.Fatalf("missing rename entry; files = %+v", files)
	}
	if ren.Status != "R" || ren.OldPath != "a.txt" {
		t.Errorf("rename = %+v, want R from a.txt", ren)
	}

	bin, ok := byPath["bin.dat"]
	if !ok {
		t.Fatalf("missing bin.dat; files = %+v", files)
	}
	if bin.Status != "A" || !bin.Binary {
		t.Errorf("bin.dat = %+v, want A binary", bin)
	}
	if bin.Additions != 0 || bin.Deletions != 0 {
		t.Errorf("binary counts = %d/%d, want 0/0", bin.Additions, bin.Deletions)
	}
}

func TestTruncation(t *testing.T) {
	reposRoot := newFixtureRepo(t)
	repo := openFixture(t, reposRoot)

	spec := core.CompareSpec{Base: fxRev(t, reposRoot, "main^1~3"), Head: "main", ThreeDot: true}

	full, err := repo.Diff(ctx(), spec)
	if err != nil {
		t.Fatalf("Diff (uncapped): %v", err)
	}
	if full.Truncated {
		t.Fatalf("baseline diff unexpectedly truncated")
	}
	if len(full.Text) <= 32 {
		t.Fatalf("fixture diff too small (%d bytes) to test truncation", len(full.Text))
	}

	repo.limitOverride = 32
	capped, err := repo.Diff(ctx(), spec)
	if err != nil {
		t.Fatalf("Diff (capped): %v", err)
	}
	if !capped.Truncated {
		t.Errorf("capped diff not marked truncated")
	}
	if len(capped.Text) > 32 {
		t.Errorf("capped diff = %d bytes, want <= 32", len(capped.Text))
	}
}

func TestMergeBase(t *testing.T) {
	reposRoot := newFixtureRepo(t)
	repo := openFixture(t, reposRoot)

	got, err := repo.MergeBase(ctx(), fxRev(t, reposRoot, "main^1"), "feature/with-slash")
	if err != nil {
		t.Fatalf("MergeBase: %v", err)
	}
	want := fxRev(t, reposRoot, "main^1~2") // c2
	if got != want {
		t.Errorf("MergeBase = %q, want %q (c2)", got, want)
	}
}

func TestParents(t *testing.T) {
	reposRoot := newFixtureRepo(t)
	repo := openFixture(t, reposRoot)

	t.Run("merge", func(t *testing.T) {
		parents, err := repo.Parents(ctx(), "main")
		if err != nil {
			t.Fatalf("Parents: %v", err)
		}
		if len(parents) != 2 {
			t.Errorf("parents = %v, want 2", parents)
		}
	})

	t.Run("root", func(t *testing.T) {
		parents, err := repo.Parents(ctx(), fxRev(t, reposRoot, "main^1~3"))
		if err != nil {
			t.Fatalf("Parents: %v", err)
		}
		if len(parents) != 0 {
			t.Errorf("root parents = %v, want none", parents)
		}
	})
}

func TestCommitPatch(t *testing.T) {
	reposRoot := newFixtureRepo(t)
	repo := openFixture(t, reposRoot)

	t.Run("root commit", func(t *testing.T) {
		root := fxRev(t, reposRoot, "main^1~3")
		patch, files, ci, err := repo.CommitPatch(ctx(), root)
		if err != nil {
			t.Fatalf("CommitPatch(root): %v", err)
		}
		if ci.SHA != root || len(ci.ParentSHAs) != 0 {
			t.Errorf("ci = %+v, want root with no parents", ci)
		}
		if !strings.Contains(patch.Text, "a.txt") {
			t.Errorf("root patch missing a.txt")
		}
		if len(files) != 1 || files[0].Path != "a.txt" || files[0].Status != "A" {
			t.Errorf("root files = %+v, want single A a.txt", files)
		}
	})

	t.Run("binary commit", func(t *testing.T) {
		bin := fxRev(t, reposRoot, "main^1")
		patch, files, _, err := repo.CommitPatch(ctx(), bin)
		if err != nil {
			t.Fatalf("CommitPatch(binary): %v", err)
		}
		if len(files) != 1 || files[0].Path != "bin.dat" || !files[0].Binary {
			t.Errorf("binary commit files = %+v, want single binary bin.dat", files)
		}
		if !strings.Contains(patch.Text, "bin.dat") {
			t.Errorf("binary patch missing bin.dat header")
		}
	})

	t.Run("merge commit diffs against first parent", func(t *testing.T) {
		patch, files, ci, err := repo.CommitPatch(ctx(), "main")
		if err != nil {
			t.Fatalf("CommitPatch(merge): %v", err)
		}
		if len(ci.ParentSHAs) != 2 {
			t.Fatalf("expected merge commit, parents = %v", ci.ParentSHAs)
		}
		// vs first parent (binary commit), the merge brings in feature.txt.
		if len(files) != 1 || files[0].Path != "feature.txt" {
			t.Errorf("merge files = %+v, want feature.txt", files)
		}
		if !strings.Contains(patch.Text, "feature.txt") {
			t.Errorf("merge patch missing feature.txt (diff-tree -p on a merge is empty; must diff vs parent)")
		}
	})
}
