package spindle

import (
	"bytes"
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"net/http"
	"net/http/cgi"
	"net/http/httptest"
	"net/url"
	"os"
	"os/exec"
	"path/filepath"
	"slices"
	"strings"
	"sync"
	"testing"

	"github.com/bluesky-social/indigo/atproto/identity"
	"github.com/bluesky-social/indigo/atproto/syntax"

	"tangled.org/core/gitutil"
	"tangled.org/core/spindle/config"
	"tangled.org/core/spindle/db"
	"tangled.org/core/spindle/knotclient"
)

const defsWorkflow = "engine: nixery\n"

type defsFile struct{ name, mode, body string }

// a knot whose git is a bare repo per did behind git http-backend; it logs
// smart http requests as "caps", "ls-refs <prefixes>" or "fetch <wants>"
type defsKnot struct {
	srv  *httptest.Server
	root string
	git  *cgi.Handler

	mu       sync.Mutex
	requests []string
}

func newDefsKnot(t *testing.T) *defsKnot {
	t.Helper()
	k := &defsKnot{root: t.TempDir()}
	k.git = &cgi.Handler{
		Path:       gitExecPath(t),
		Root:       "/",
		Env:        []string{"GIT_PROJECT_ROOT=" + k.root, "GIT_HTTP_EXPORT_ALL=1"},
		InheritEnv: []string{"HOME", "GIT_CONFIG_GLOBAL", "GIT_CONFIG_SYSTEM"},
	}
	k.srv = httptest.NewServer(http.HandlerFunc(k.serve))
	t.Cleanup(k.srv.Close)
	return k
}

func (k *defsKnot) host() string {
	u, _ := url.Parse(k.srv.URL)
	return u.Host
}

func (k *defsKnot) bare(t *testing.T, repo syntax.DID) string {
	t.Helper()
	dir := filepath.Join(k.root, repo.String())
	if _, err := os.Stat(dir); err == nil {
		return dir
	}
	gitInDir(t, k.root, "init", "-q", "--bare", dir)
	gitInDir(t, dir, "config", "uploadpack.allowFilter", "true")
	gitInDir(t, dir, "config", "uploadpack.allowAnySHA1InWant", "true")
	return dir
}

// a commit holding files under .tangled/workflows, made with plumbing so any
// mode goes; it stays fetchable by oid without a branch
func (k *defsKnot) commit(t *testing.T, repo syntax.DID, files ...defsFile) string {
	t.Helper()
	dir := k.bare(t, repo)
	env := append(os.Environ(), "GIT_INDEX_FILE="+filepath.Join(t.TempDir(), "index"))
	git := func(stdin string, args ...string) string {
		cmd := exec.Command("git", append([]string{"-C", dir}, args...)...)
		cmd.Env = env
		cmd.Stdin = strings.NewReader(stdin)
		out, err := cmd.CombinedOutput()
		if err != nil {
			t.Fatalf("git %v: %v\n%s", args, err, out)
		}
		return strings.TrimSpace(string(out))
	}
	git("", "read-tree", "--empty")
	for _, f := range files {
		path, mode := ".tangled/workflows/"+f.name, strings.TrimLeft(f.mode, "0")
		var oid string
		switch mode {
		case "160000":
			oid = strings.Repeat("5", 40)
		case "40000":
			path, mode = path+"/inner.yaml", "100644"
			fallthrough
		default:
			oid = git(f.body, "hash-object", "-w", "--stdin")
		}
		git("", "update-index", "--add", "--cacheinfo", mode+","+oid+","+path)
	}
	tree := git("", "write-tree")
	commit := git("", "-c", "user.name=ci", "-c", "user.email=ci@nel.pet", "commit-tree", tree, "-m", "wip")
	git("", "update-ref", "refs/keep/"+commit, commit)
	return commit
}

func (k *defsKnot) setBranch(t *testing.T, repo syntax.DID, name, commit string) {
	gitInDir(t, k.bare(t, repo), "update-ref", "refs/heads/"+name, commit)
}

func (k *defsKnot) setTag(t *testing.T, repo syntax.DID, name, commit string) {
	gitInDir(t, k.bare(t, repo), "-c", "user.name=ci", "-c", "user.email=ci@nel.pet", "tag", "-a", "-m", name, name, commit)
}

func (k *defsKnot) asked() []string {
	k.mu.Lock()
	defer k.mu.Unlock()
	return append([]string(nil), k.requests...)
}

func (k *defsKnot) count(kind string) int {
	n := 0
	for _, r := range k.asked() {
		if r == kind || strings.HasPrefix(r, kind+" ") {
			n++
		}
	}
	return n
}

func (k *defsKnot) log(r *http.Request) {
	entry := "caps"
	if r.Method == http.MethodPost {
		body, _ := io.ReadAll(r.Body)
		r.Body = io.NopCloser(bytes.NewReader(body))
		var command string
		var args []string
		for _, line := range strings.Split(string(body), "\n") {
			if len(line) < 4 {
				continue
			}
			line = line[4:]
			if c, ok := strings.CutPrefix(line, "command="); ok {
				command = c
			} else if a, ok := strings.CutPrefix(line, "want "); ok {
				args = append(args, a)
			} else if a, ok := strings.CutPrefix(line, "ref-prefix "); ok {
				args = append(args, a)
			}
		}
		entry = strings.TrimSpace(command + " " + strings.Join(args, ","))
	}
	k.mu.Lock()
	k.requests = append(k.requests, entry)
	k.mu.Unlock()
}

func (k *defsKnot) serve(w http.ResponseWriter, r *http.Request) {
	if r.URL.Path == "/xrpc/com.atproto.repo.getRecord" {
		json.NewEncoder(w).Encode(map[string]any{"value": map[string]any{"defaultBranch": "main"}})
		return
	}
	if strings.HasPrefix(r.URL.Path, "/xrpc/") {
		w.WriteHeader(http.StatusNotFound)
		json.NewEncoder(w).Encode(map[string]string{"error": "MethodNotImplemented"})
		return
	}
	k.log(r)
	k.git.ServeHTTP(w, r)
}

func newDefsSpindle(t *testing.T, knot *defsKnot, repos ...syntax.DID) *Spindle {
	t.Helper()
	ctx := context.Background()
	d, err := db.Make(ctx, filepath.Join(t.TempDir(), "spindle.db"))
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { d.Close() })
	for i, repo := range repos {
		if err := d.AddRepo(db.Repo{Knot: knot.host(), Owner: "did:plc:owner", Rkey: syntax.RecordKey(fmt.Sprintf("repo%d", i)), RepoDid: repo}); err != nil {
			t.Fatal(err)
		}
	}
	cfg := &config.Config{}
	cfg.Server.RepoDir = filepath.Join(t.TempDir(), "repos")
	return &Spindle{db: d, cfg: cfg, knots: knotclient.New(identity.NewMockDirectory(), http.DefaultClient, true)}
}

func regular(name string) defsFile { return defsFile{name, "0100644", defsWorkflow} }

// the origin is a dead path on purpose, like a clone whose remote is gone
func cloneWithCommit(t *testing.T, s *Spindle, repo syntax.DID) (src, commit string) {
	t.Helper()
	src = newSourceRepo(t, "sha1")
	writeTestFile(t, filepath.Join(src, ".tangled/workflows/local.yaml"), defsWorkflow)
	commitEverything(t, src)
	commit = gitInDir(t, src, "rev-parse", "HEAD")
	path := s.newRepoPath(repo)
	if err := gitutil.SparseSync(context.Background(), src, path, commit, sparseWorkflowDir); err != nil {
		t.Fatal(err)
	}
	gitInDir(t, path, "remote", "set-url", "origin", filepath.Join(t.TempDir(), "gone"))
	return src, commit
}

func definitionNames(t *testing.T, s *Spindle, repo syntax.DID, ref string) (names []string, commits []string) {
	t.Helper()
	defs, err := s.ListWorkflowDefinitions(context.Background(), repo, ref)
	if err != nil {
		t.Fatal(err)
	}
	for _, d := range defs {
		names = append(names, d.Name)
		commits = append(commits, d.Source.File.Commit)
	}
	return names, commits
}

func TestDefinitionMissReadsTheLocalCloneWithoutTheKnotOrAFetch(t *testing.T) {
	knot := newDefsKnot(t)
	repo := syntax.DID("did:plc:defsrepo")
	s := newDefsSpindle(t, knot, repo)
	_, commit := cloneWithCommit(t, s, repo)

	names, commits := definitionNames(t, s, repo, commit)
	if len(names) != 1 || names[0] != "local.yaml" || commits[0] != commit {
		t.Fatalf("definitions = %v at %v, want local.yaml at %s", names, commits, commit)
	}
	if got := knot.asked(); len(got) != 0 {
		t.Fatalf("a clone that had the commit still cost the knot %v", got)
	}
}

func TestDefinitionMissWithoutTheCommitLocallyReadsTheKnot(t *testing.T) {
	knot := newDefsKnot(t)
	repo := syntax.DID("did:plc:defsrepo")
	s := newDefsSpindle(t, knot, repo)
	cloneWithCommit(t, s, repo)
	other := knot.commit(t, repo, regular("remote.yaml"))

	names, commits := definitionNames(t, s, repo, other)
	if len(names) != 1 || names[0] != "remote.yaml" || commits[0] != other {
		t.Fatalf("definitions = %v at %v, want remote.yaml at %s", names, commits, other)
	}
}

func TestDefinitionMissWithoutACloneReadsTheKnotAndNeverRunsGit(t *testing.T) {
	knot := newDefsKnot(t)
	repo := syntax.DID("did:plc:defsrepo")
	s := newDefsSpindle(t, knot, repo)
	commit := knot.commit(t, repo, regular("ci.yaml"), regular("deploy.yaml"))
	t.Setenv("PATH", "")

	names, commits := definitionNames(t, s, repo, commit)
	if strings.Join(names, ",") != "ci.yaml,deploy.yaml" || commits[0] != commit {
		t.Fatalf("definitions = %v at %v", names, commits)
	}
	// the commit with its root, .tangled, then workflows with its blobs
	if knot.count("caps") != 1 || knot.count("ls-refs") != 0 || knot.count("fetch") != 3 {
		t.Fatalf("knot saw %v, want caps and three fetches without a ref lookup", knot.asked())
	}
	if _, err := os.Stat(s.newRepoPath(repo)); !os.IsNotExist(err) {
		t.Fatalf("a read made a clone dir: %v", err)
	}
}

func TestDefinitionKnotReadSkipsEverythingButRegularFiles(t *testing.T) {
	knot := newDefsKnot(t)
	repo := syntax.DID("did:plc:defsrepo")
	s := newDefsSpindle(t, knot, repo)
	commit := knot.commit(t, repo,
		regular("ci.yaml"),
		defsFile{"exec.yaml", "0100755", defsWorkflow},
		defsFile{"link.yaml", "0120000", "/etc/passwd"},
		defsFile{"sub", "0160000", ""},
		defsFile{"nested", "0040000", defsWorkflow},
	)
	t.Setenv("PATH", "")

	names, _ := definitionNames(t, s, repo, commit)
	if strings.Join(names, ",") != "ci.yaml,exec.yaml" {
		t.Fatalf("definitions = %v, want only the regular files", names)
	}
}

func TestDefinitionKnotReadRefusesMoreFilesThanSpindleRuns(t *testing.T) {
	knot := newDefsKnot(t)
	repo := syntax.DID("did:plc:defsrepo")
	s := newDefsSpindle(t, knot, repo)

	var files []defsFile
	for i := range maxWorkflowFiles + 1 {
		files = append(files, regular(fmt.Sprintf("w%03d.yaml", i)))
	}
	over := knot.commit(t, repo, files...)
	exact := knot.commit(t, repo, files[:maxWorkflowFiles]...)
	t.Setenv("PATH", "")
	if _, err := s.ListWorkflowDefinitions(context.Background(), repo, over); !errors.Is(err, errTooManyWorkflowFiles) {
		t.Fatalf("%d files read with err = %v, want errTooManyWorkflowFiles", len(files), err)
	}
	if names, _ := definitionNames(t, s, repo, exact); len(names) != maxWorkflowFiles {
		t.Fatalf("%d definitions, want %d", len(names), maxWorkflowFiles)
	}
}

func TestDefinitionKnotReadOfAMissingDirIsEmpty(t *testing.T) {
	knot := newDefsKnot(t)
	repo := syntax.DID("did:plc:defsrepo")
	s := newDefsSpindle(t, knot, repo)
	commit := knot.commit(t, repo)
	t.Setenv("PATH", "")

	names, _ := definitionNames(t, s, repo, commit)
	if len(names) != 0 {
		t.Fatalf("definitions = %v, want none", names)
	}
}

func TestDefinitionKnotReadOfAGoneRepoFails(t *testing.T) {
	knot := newDefsKnot(t)
	repo := syntax.DID("did:plc:defsrepo")
	s := newDefsSpindle(t, knot, repo)
	t.Setenv("PATH", "")

	// a 404 from the knot is no proof the dir is missing
	if _, err := s.ListWorkflowDefinitions(context.Background(), repo, strings.Repeat("a", 40)); err == nil {
		t.Fatal("a repo the knot doesn't have read as having no workflows")
	}
}

func TestDefinitionBranchRefsResolveToACommitBeforeReading(t *testing.T) {
	for _, ref := range []string{"HEAD", "", "main", "refs/heads/main"} {
		t.Run(ref, func(t *testing.T) {
			knot := newDefsKnot(t)
			repo := syntax.DID("did:plc:defsrepo")
			s := newDefsSpindle(t, knot, repo)
			commit := knot.commit(t, repo, regular("ci.yaml"))
			knot.setBranch(t, repo, "main", commit)
			knot.setBranch(t, repo, "other", knot.commit(t, repo, regular("other.yaml")))
			t.Setenv("PATH", "")

			names, commits := definitionNames(t, s, repo, ref)
			if len(names) != 1 || commits[0] != commit {
				t.Fatalf("definitions = %v at %v, want ci.yaml at %s", names, commits, commit)
			}
			asked := knot.asked()
			if i := slices.IndexFunc(asked, func(r string) bool { return strings.HasPrefix(r, "fetch ") }); i < 0 || asked[i] != "fetch "+commit {
				t.Fatalf("knot saw %v, want the first fetch to be the resolved commit", asked)
			}
			if knot.count("caps") != 1 {
				t.Fatalf("knot saw %v, want its caps asked once per read", knot.asked())
			}
		})
	}
}

func TestDefinitionBranchRefReadsLocallyAfterResolving(t *testing.T) {
	knot := newDefsKnot(t)
	repo := syntax.DID("did:plc:defsrepo")
	s := newDefsSpindle(t, knot, repo)
	src, commit := cloneWithCommit(t, s, repo)
	gitInDir(t, src, "push", "-q", knot.bare(t, repo), "HEAD:refs/heads/main")

	_, commits := definitionNames(t, s, repo, "main")
	if commits[0] != commit {
		t.Fatalf("commit = %s, want %s", commits[0], commit)
	}
	if knot.count("fetch") != 0 {
		t.Fatalf("knot saw %v, want only the branch lookup", knot.asked())
	}
}

func TestDefinitionTagRefsResolveToTheirCommitWithoutAClone(t *testing.T) {
	for _, ref := range []string{"v1.0", "refs/tags/v1.0"} {
		t.Run(ref, func(t *testing.T) {
			knot := newDefsKnot(t)
			repo := syntax.DID("did:plc:defsrepo")
			s := newDefsSpindle(t, knot, repo)
			commit := knot.commit(t, repo, regular("ci.yaml"))
			knot.setTag(t, repo, "v1.0", commit)
			t.Setenv("PATH", "")

			// annotated, so the read has to land on the peeled commit
			names, commits := definitionNames(t, s, repo, ref)
			if len(names) != 1 || commits[0] != commit {
				t.Fatalf("definitions = %v at %v, want ci.yaml at %s", names, commits, commit)
			}
		})
	}
}

func TestDefinitionBareRefIsABranchBeforeATag(t *testing.T) {
	knot := newDefsKnot(t)
	repo := syntax.DID("did:plc:defsrepo")
	s := newDefsSpindle(t, knot, repo)
	branchCommit := knot.commit(t, repo, regular("ci.yaml"))
	tagCommit := knot.commit(t, repo, regular("old.yaml"))
	knot.setBranch(t, repo, "release", branchCommit)
	knot.setTag(t, repo, "release", tagCommit)
	t.Setenv("PATH", "")

	if names, _ := definitionNames(t, s, repo, "release"); len(names) != 1 || names[0] != "ci.yaml" {
		t.Fatalf("definitions = %v, want the branch's", names)
	}
	if got := knot.count("ls-refs"); got != 1 {
		t.Fatalf("%d ref lookups, want one for both branch and tag", got)
	}
}

func TestDefinitionRefThatIsNeitherBranchNorTagFails(t *testing.T) {
	knot := newDefsKnot(t)
	repo := syntax.DID("did:plc:defsrepo")
	s := newDefsSpindle(t, knot, repo)
	knot.setBranch(t, repo, "main", knot.commit(t, repo, regular("ci.yaml")))
	t.Setenv("PATH", "")
	if _, err := s.ListWorkflowDefinitions(context.Background(), repo, "ghost"); err == nil {
		t.Fatal("an unknown ref resolved")
	}
}
