package git

import (
	"errors"
	"fmt"
	"os"
	"os/exec"
	"path/filepath"
	"strings"
)

var ErrPathTraversal = errors.New("repo path outside of configured root")

var repoRoot string

func SetRepoRoot(root string) {
	repoRoot = filepath.Clean(root)
}

// run executes a git command inside repoPath with strict safety guarantees:
//   - repoPath is validated to be under the configured repoRoot (path traversal guard)
//   - args are passed as discrete values — never via shell interpolation
//   - the process inherits only a minimal, fixed environment
func run(repoPath string, args ...string) ([]byte, error) {
	clean := filepath.Clean(repoPath)
	if repoRoot != "" {
		root := repoRoot + string(filepath.Separator)
		if !strings.HasPrefix(clean+string(filepath.Separator), root) && clean != repoRoot {
			return nil, ErrPathTraversal
		}
	}

	cmd := exec.Command("git", args...)
	cmd.Dir = clean
	cmd.Env = []string{
		"GIT_TERMINAL_PROMPT=0",
		"HOME=/tmp", // prevent .gitconfig side-effects
	}
	out, err := cmd.Output()
	if err != nil {
		if exitErr, ok := err.(*exec.ExitError); ok {
			return nil, fmt.Errorf("git %s: %w: %s", args[0], err, exitErr.Stderr)
		}
		return nil, fmt.Errorf("git %s: %w", args[0], err)
	}
	return out, nil
}

// IsEmpty returns true when the repo has no commits on any branch.
// Uses for-each-ref so it doesn't depend on HEAD pointing to a valid branch
// (bare repos created without --initial-branch default to master, but clients
// may push to main, leaving HEAD dangling).
func IsEmpty(repoPath string) bool {
	out, err := run(repoPath, "for-each-ref", "--count=1", "refs/heads/")
	if err != nil {
		return true
	}
	return len(strings.TrimSpace(string(out))) == 0
}

func Init(path string) error {
	cmd := exec.Command("git", "init", "--bare", path)
	cmd.Env = []string{"GIT_TERMINAL_PROMPT=0", "HOME=/tmp"}
	if out, err := cmd.CombinedOutput(); err != nil {
		return fmt.Errorf("git init --bare: %w: %s", err, out)
	}
	env := []string{"GIT_TERMINAL_PROMPT=0", "HOME=/tmp"}

	// Point HEAD at main so clients pushing 'main' don't leave a dangling HEAD.
	head := exec.Command("git", "-C", path, "symbolic-ref", "HEAD", "refs/heads/main")
	head.Env = env
	_ = head.Run()

	// Allow pushes over HTTP (git http-backend checks this config key).
	cfg := exec.Command("git", "-C", path, "config", "http.receivepack", "true")
	cfg.Env = env
	_ = cfg.Run()

	return nil
}

type Commit struct {
	Hash    string `json:"hash"`
	Author  string `json:"author"`
	Message string `json:"message"`
	Date    string `json:"date"`
}

func Log(repoPath, branch string, limit int) ([]Commit, error) {
	out, err := run(repoPath,
		"log", branch,
		"--format=%H\x1f%an\x1f%s\x1f%ci",
		fmt.Sprintf("--max-count=%d", limit),
	)
	if err != nil {
		return nil, err
	}

	raw := strings.TrimSpace(string(out))
	if raw == "" {
		return nil, nil
	}

	var commits []Commit
	for _, line := range strings.Split(raw, "\n") {
		parts := strings.SplitN(line, "\x1f", 4)
		if len(parts) != 4 {
			continue
		}
		commits = append(commits, Commit{
			Hash:    parts[0],
			Author:  parts[1],
			Message: parts[2],
			Date:    parts[3],
		})
	}
	return commits, nil
}

type TreeEntry struct {
	Mode       string `json:"mode"`
	Type       string `json:"type"`
	Hash       string `json:"hash"`
	Name       string `json:"name"`
	Size       int64  `json:"size"`
	CommitHash string `json:"commitHash"`
	CommitMsg  string `json:"commitMsg"`
	CommitDate string `json:"commitDate"`
}

func TreeLS(repoPath, ref, subPath string) ([]TreeEntry, error) {
	if IsEmpty(repoPath) {
		return nil, nil
	}
	treeRef := ref
	if subPath != "" {
		treeRef = ref + ":" + subPath
	}
	// -l adds size column: <mode> SP <type> SP <hash> SP <size> TAB <name>
	out, err := run(repoPath, "ls-tree", "-l", treeRef)
	if err != nil {
		return nil, err
	}

	var entries []TreeEntry
	for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
		if line == "" {
			continue
		}
		tabIdx := strings.Index(line, "\t")
		if tabIdx < 0 {
			continue
		}
		name := line[tabIdx+1:]
		fields := strings.Fields(line[:tabIdx])
		if len(fields) < 4 {
			continue
		}
		e := TreeEntry{
			Mode: fields[0],
			Type: fields[1],
			Hash: fields[2],
			Name: name,
		}
		if fields[3] != "-" {
			fmt.Sscanf(fields[3], "%d", &e.Size)
		}
		entries = append(entries, e)
	}

	// Fetch last commit info for each entry.
	for i, e := range entries {
		filePath := e.Name
		if subPath != "" {
			filePath = subPath + "/" + e.Name
		}
		commitOut, err := run(repoPath, "log", "-1", "--format=%h\x1f%s\x1f%aI", "--", filePath)
		if err == nil {
			parts := strings.SplitN(strings.TrimSpace(string(commitOut)), "\x1f", 3)
			if len(parts) == 3 {
				entries[i].CommitHash = parts[0]
				entries[i].CommitMsg = parts[1]
				entries[i].CommitDate = parts[2]
			}
		}
	}
	return entries, nil
}

// WriteFile writes content to filePath on branch inside a temporary worktree,
// then commits with the given author and message. Uses git plumbing directly
// so it works on bare repositories.
func WriteFile(repoPath, branch, filePath, content, authorName, authorEmail, message string) error {
	clean := filepath.Clean(filepath.FromSlash(filePath))
	if strings.HasPrefix(clean, "..") || filepath.IsAbs(clean) {
		return errors.New("invalid file path")
	}

	tmpDir, err := os.MkdirTemp("", "fb-edit-*")
	if err != nil {
		return fmt.Errorf("mktemp: %w", err)
	}

	baseEnv := []string{"GIT_TERMINAL_PROMPT=0", "HOME=/tmp"}
	authorEnv := append(baseEnv,
		"GIT_AUTHOR_NAME="+authorName,
		"GIT_AUTHOR_EMAIL="+authorEmail,
		"GIT_COMMITTER_NAME="+authorName,
		"GIT_COMMITTER_EMAIL="+authorEmail,
	)

	addWt := exec.Command("git", "worktree", "add", "--force", tmpDir, branch)
	addWt.Dir = filepath.Clean(repoPath)
	addWt.Env = baseEnv
	if out, err := addWt.CombinedOutput(); err != nil {
		os.RemoveAll(tmpDir)
		return fmt.Errorf("worktree add: %w: %s", err, out)
	}

	defer func() {
		rmWt := exec.Command("git", "worktree", "remove", "--force", tmpDir)
		rmWt.Dir = filepath.Clean(repoPath)
		rmWt.Env = baseEnv
		rmWt.Run()
		os.RemoveAll(tmpDir)
	}()

	fullPath := filepath.Join(tmpDir, clean)
	if err := os.MkdirAll(filepath.Dir(fullPath), 0755); err != nil {
		return fmt.Errorf("mkdirall: %w", err)
	}
	if err := os.WriteFile(fullPath, []byte(content), 0644); err != nil {
		return fmt.Errorf("writefile: %w", err)
	}

	addC := exec.Command("git", "add", clean)
	addC.Dir = tmpDir
	addC.Env = authorEnv
	if out, err := addC.CombinedOutput(); err != nil {
		return fmt.Errorf("git add: %w: %s", err, out)
	}

	commitC := exec.Command("git", "commit", "-m", message)
	commitC.Dir = tmpDir
	commitC.Env = authorEnv
	if out, err := commitC.CombinedOutput(); err != nil {
		return fmt.Errorf("git commit: %w: %s", err, out)
	}

	return nil
}

type Branch struct {
	Name string `json:"name"`
}

func Branches(repoPath string) ([]Branch, error) {
	if IsEmpty(repoPath) {
		return nil, nil
	}
	out, err := run(repoPath, "branch", "--format=%(refname:short)")
	if err != nil {
		return nil, err
	}
	var branches []Branch
	for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
		if line != "" {
			branches = append(branches, Branch{Name: line})
		}
	}
	return branches, nil
}

// RepoSize returns the total byte size of the bare repo directory by walking it.
func RepoSize(repoPath string) int64 {
	var total int64
	filepath.Walk(repoPath, func(_ string, info os.FileInfo, err error) error {
		if err == nil && !info.IsDir() {
			total += info.Size()
		}
		return nil
	})
	return total
}

// SetDefaultBranch updates HEAD to point at the given branch name.
func SetDefaultBranch(repoPath, branch string) error {
	_, err := run(repoPath, "symbolic-ref", "HEAD", "refs/heads/"+branch)
	return err
}

// InitWithFiles creates an initial commit in a bare repo by:
// 1. git init in a temp dir → write files → commit → git push file://repoPath.
// Use this right after Init() when initReadme or initGitignore is requested.
func InitWithFiles(repoPath, branch, repoName, authorName, authorEmail string, initReadme, initGitignore bool) error {
	tmpDir, err := os.MkdirTemp("", "fb-init-*")
	if err != nil {
		return fmt.Errorf("mktemp: %w", err)
	}
	defer os.RemoveAll(tmpDir)

	env := []string{
		"GIT_TERMINAL_PROMPT=0",
		"HOME=/tmp",
		"GIT_AUTHOR_NAME=" + authorName,
		"GIT_AUTHOR_EMAIL=" + authorEmail,
		"GIT_COMMITTER_NAME=" + authorName,
		"GIT_COMMITTER_EMAIL=" + authorEmail,
	}

	initCmd := exec.Command("git", "init", tmpDir)
	initCmd.Env = env
	if out, err := initCmd.CombinedOutput(); err != nil {
		return fmt.Errorf("git init: %w: %s", err, out)
	}

	// Point the working-tree HEAD at the desired branch before the first commit.
	symRef := exec.Command("git", "-C", tmpDir, "symbolic-ref", "HEAD", "refs/heads/"+branch)
	symRef.Env = env
	symRef.Run()

	if initReadme {
		if err := os.WriteFile(filepath.Join(tmpDir, "README.md"), []byte(readmeContent(repoName)), 0644); err != nil {
			return fmt.Errorf("write README: %w", err)
		}
	}
	if initGitignore {
		if err := os.WriteFile(filepath.Join(tmpDir, ".gitignore"), []byte(gitignoreContent()), 0644); err != nil {
			return fmt.Errorf("write .gitignore: %w", err)
		}
	}

	addCmd := exec.Command("git", "add", ".")
	addCmd.Dir = tmpDir
	addCmd.Env = env
	if out, err := addCmd.CombinedOutput(); err != nil {
		return fmt.Errorf("git add: %w: %s", err, out)
	}

	commitCmd := exec.Command("git", "commit", "-m", "Initial commit")
	commitCmd.Dir = tmpDir
	commitCmd.Env = env
	if out, err := commitCmd.CombinedOutput(); err != nil {
		return fmt.Errorf("git commit: %w: %s", err, out)
	}

	// Push to the bare repo via file:// — works on all git versions.
	pushCmd := exec.Command("git", "push", "file://"+filepath.ToSlash(filepath.Clean(repoPath)), branch)
	pushCmd.Dir = tmpDir
	pushCmd.Env = env
	if out, err := pushCmd.CombinedOutput(); err != nil {
		return fmt.Errorf("git push: %w: %s", err, out)
	}
	return nil
}

// CloneRepo mirrors a remote HTTP/HTTPS git repository into destPath (bare).
func CloneRepo(srcURL, destPath string) error {
	if !strings.HasPrefix(srcURL, "http://") && !strings.HasPrefix(srcURL, "https://") {
		return errors.New("only http and https source URLs are supported")
	}
	env := []string{"GIT_TERMINAL_PROMPT=0", "HOME=/tmp"}

	cmd := exec.Command("git", "clone", "--mirror", srcURL, destPath)
	cmd.Env = env
	if out, err := cmd.CombinedOutput(); err != nil {
		// Scrub the URL (which may contain credentials) from the error.
		msg := strings.ReplaceAll(string(out), srcURL, "<url>")
		return fmt.Errorf("git clone: %w: %s", err, msg)
	}
	// Enable pushes over HTTP.
	cfgCmd := exec.Command("git", "-C", destPath, "config", "http.receivepack", "true")
	cfgCmd.Env = env
	cfgCmd.Run()
	return nil
}

func readmeContent(repoName string) string {
	return fmt.Sprintf("# %s\n\n## Description\n\nAdd your project description here.\n\n## Getting Started\n\n### Prerequisites\n\nList your prerequisites here.\n\n### Installation\n\n1. Clone the repository\n2. Follow setup instructions\n\n## Usage\n\nDescribe how to use your project.\n\n## Contributing\n\nContributions are welcome! Please open an issue or pull request.\n\n## License\n\nThis project is licensed under the MIT License.\n", repoName)
}

func gitignoreContent() string {
	return "# OS\n.DS_Store\nThumbs.db\n\n# Editor\n.vscode/\n.idea/\n*.swp\n*.swo\n\n# Logs\n*.log\nlogs/\n\n# Dependencies\nnode_modules/\nvendor/\n\n# Build artifacts\ndist/\nbuild/\n*.o\n*.a\n*.so\n"
}

func BlobCat(repoPath, ref, filePath string) ([]byte, error) {
	return run(repoPath, "show", ref+":"+filePath)
}

// FileDiff represents a single changed file in a diff.
type FileDiff struct {
	Path      string `json:"path"`
	OldPath   string `json:"oldPath,omitempty"` // set on renames
	Additions int    `json:"additions"`
	Deletions int    `json:"deletions"`
	Patch     string `json:"patch"` // unified diff hunk(s) for this file
}

// Diff returns per-file diffs between two refs.
func Diff(repoPath, base, head string) ([]FileDiff, error) {
	out, err := run(repoPath,
		"diff", "--unified=5", "--no-color",
		"--diff-filter=ACDMRT", // exclude untracked
		base+".."+head,
	)
	if err != nil {
		return nil, err
	}
	return parseUnifiedDiff(string(out)), nil
}

// parseUnifiedDiff splits a multi-file unified diff into per-file FileDiff entries.
func parseUnifiedDiff(raw string) []FileDiff {
	var files []FileDiff
	var cur *FileDiff
	var patchLines []string

	commit := func() {
		if cur != nil {
			cur.Patch = strings.Join(patchLines, "\n")
			files = append(files, *cur)
		}
	}

	for _, line := range strings.Split(raw, "\n") {
		if strings.HasPrefix(line, "diff --git ") {
			commit()
			cur = &FileDiff{}
			patchLines = nil
			continue
		}
		if cur == nil {
			continue
		}
		switch {
		case strings.HasPrefix(line, "--- a/"):
			cur.OldPath = strings.TrimPrefix(line, "--- a/")
		case strings.HasPrefix(line, "+++ b/"):
			cur.Path = strings.TrimPrefix(line, "+++ b/")
		case strings.HasPrefix(line, "+") && !strings.HasPrefix(line, "+++"):
			cur.Additions++
			patchLines = append(patchLines, line)
		case strings.HasPrefix(line, "-") && !strings.HasPrefix(line, "---"):
			cur.Deletions++
			patchLines = append(patchLines, line)
		default:
			patchLines = append(patchLines, line)
		}
	}
	commit()
	return files
}
