package gitstore

import (
	"context"
	"errors"
	"fmt"
	"os"
	"os/exec"
	"strconv"
	"strings"
)

var ErrRefChanged = errors.New("ref changed")

// ReadFile reads a file content from the bare git repo at HEAD.
// If HEAD points to a non-existent branch (e.g. repo created with default
// "main" but client pushed "master"), it falls back to the first available branch.
func (s *Store) ReadFile(ctx context.Context, fullName, path string) ([]byte, error) {
	return s.ReadFileAtRef(ctx, fullName, path, "")
}

// ResolveContentCommit resolves ref to the exact commit used for content reads.
// When ref is empty, it resolves HEAD and falls back to the first available
// branch only if HEAD itself is dangling.
func (s *Store) ResolveContentCommit(ctx context.Context, fullName, ref string) (string, error) {
	dir, err := s.repoPath(ctx, fullName)
	if err != nil {
		return "", err
	}
	return s.resolveContentCommit(ctx, dir, ref)
}

// ReadFileAtRef reads a file content from the bare git repo at the given ref.
// When ref is empty, it resolves HEAD and falls back to the first available
// branch only if HEAD itself is dangling.
func (s *Store) ReadFileAtRef(ctx context.Context, fullName, path, ref string) ([]byte, error) {
	dir, err := s.repoPath(ctx, fullName)
	if err != nil {
		return nil, err
	}
	commit, err := s.resolveContentCommit(ctx, dir, ref)
	if err != nil {
		return nil, err
	}
	return exec.CommandContext(ctx, "git", "-C", dir, "show", commit+":"+path).Output()
}

// FileExistsAtRef reports whether path exists in the tree for the given ref.
// When ref is empty, it resolves HEAD and applies the same dangling-HEAD
// fallback behavior as ReadFileAtRef.
func (s *Store) FileExistsAtRef(ctx context.Context, fullName, path, ref string) (bool, error) {
	dir, err := s.repoPath(ctx, fullName)
	if err != nil {
		return false, err
	}
	commit, err := s.resolveContentCommit(ctx, dir, ref)
	if err != nil {
		return false, err
	}
	out, err := exec.CommandContext(ctx, "git", "-C", dir, "ls-tree", "--name-only", commit, "--", path).Output()
	if err != nil {
		return false, err
	}
	return strings.TrimSpace(string(out)) == path, nil
}

// ReadFileWithSHAAtRef resolves ref once, then reads both file contents and the
// corresponding blob SHA from that exact commit snapshot.
func (s *Store) ReadFileWithSHAAtRef(ctx context.Context, fullName, path, ref string) ([]byte, string, error) {
	dir, err := s.repoPath(ctx, fullName)
	if err != nil {
		return nil, "", err
	}
	commit, err := s.resolveContentCommit(ctx, dir, ref)
	if err != nil {
		return nil, "", err
	}

	body, err := exec.CommandContext(ctx, "git", "-C", dir, "show", commit+":"+path).Output()
	if err != nil {
		return nil, "", err
	}

	cmd := exec.CommandContext(ctx, "git", "-C", dir, "ls-tree", "-z", commit, "--", path)
	cmd.Env = append(os.Environ(), "GIT_LITERAL_PATHSPECS=1")
	out, err := cmd.CombinedOutput()
	if err != nil {
		return nil, "", fmt.Errorf("ls-tree failed: %w, output: %s", err, out)
	}
	raw := strings.TrimRight(string(out), "\x00")
	if raw == "" {
		return nil, "", fmt.Errorf("path not found")
	}
	parts := strings.SplitN(raw, "\t", 2)
	if len(parts) != 2 {
		return nil, "", fmt.Errorf("invalid ls-tree entry")
	}
	meta := strings.Fields(parts[0])
	if len(meta) < 3 || meta[1] != "blob" {
		return nil, "", fmt.Errorf("path is not a file")
	}
	if parts[1] != path {
		return nil, "", fmt.Errorf("path not found")
	}
	return body, meta[2], nil
}

// ListTreeFiles returns the file paths in the repository tree at HEAD.
func (s *Store) ListTreeFiles(ctx context.Context, fullName string) ([]string, error) {
	return s.ListTreeFilesAtRef(ctx, fullName, "")
}

// ListTreeFilesAtRef returns the file paths in the repository tree at ref.
// When ref is empty, it resolves HEAD and falls back to the first available
// branch only if HEAD itself is dangling.
func (s *Store) ListTreeFilesAtRef(ctx context.Context, fullName, ref string) ([]string, error) {
	dir, err := s.repoPath(ctx, fullName)
	if err != nil {
		return nil, err
	}
	commit, err := s.resolveContentCommit(ctx, dir, ref)
	if err != nil {
		return nil, err
	}
	out, err := exec.CommandContext(ctx, "git", "-C", dir, "ls-tree", "-r", commit, "--name-only").Output()
	if err != nil {
		return nil, err
	}
	var files []string
	for _, line := range strings.Split(string(out), "\n") {
		if line != "" {
			files = append(files, line)
		}
	}
	return files, nil
}

// GrepFilesAtRef returns paths whose contents match any fixed string pattern at
// ref. Git grep returns exit 1 for no matches; callers receive an empty slice.
func (s *Store) GrepFilesAtRef(ctx context.Context, fullName, ref string, patterns []string) ([]string, error) {
	dir, err := s.repoPath(ctx, fullName)
	if err != nil {
		return nil, err
	}
	commit, err := s.resolveContentCommit(ctx, dir, ref)
	if err != nil {
		return nil, err
	}

	seenPatterns := make(map[string]struct{}, len(patterns))
	args := []string{"-C", dir, "grep", "-I", "-i", "-l", "-F"}
	for _, pattern := range patterns {
		pattern = strings.TrimSpace(pattern)
		if pattern == "" {
			continue
		}
		if _, ok := seenPatterns[pattern]; ok {
			continue
		}
		seenPatterns[pattern] = struct{}{}
		args = append(args, "-e", pattern)
	}
	if len(seenPatterns) == 0 {
		return []string{}, nil
	}
	args = append(args, commit, "--")

	out, err := exec.CommandContext(ctx, "git", args...).CombinedOutput()
	if err != nil {
		if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 {
			return []string{}, nil
		}
		return nil, fmt.Errorf("git grep files failed: %w, output: %s", err, out)
	}

	seenPaths := make(map[string]struct{})
	var paths []string
	prefix := commit + ":"
	for _, raw := range strings.Split(strings.TrimRight(string(out), "\n"), "\n") {
		if raw == "" {
			continue
		}
		path := raw
		if strings.HasPrefix(path, prefix) {
			path = strings.TrimPrefix(path, prefix)
		} else if idx := strings.IndexByte(path, ':'); idx >= 0 {
			path = path[idx+1:]
		}
		if path == "" {
			continue
		}
		if _, ok := seenPaths[path]; ok {
			continue
		}
		seenPaths[path] = struct{}{}
		paths = append(paths, path)
	}
	return paths, nil
}

// GrepFileMatchCountsAtRef returns per-path matching line counts for fixed
// string patterns at ref. Git grep returns exit 1 for no matches; callers
// receive an empty map.
func (s *Store) GrepFileMatchCountsAtRef(ctx context.Context, fullName, ref string, patterns []string) (map[string]int, error) {
	dir, err := s.repoPath(ctx, fullName)
	if err != nil {
		return nil, err
	}
	commit, err := s.resolveContentCommit(ctx, dir, ref)
	if err != nil {
		return nil, err
	}

	seenPatterns := make(map[string]struct{}, len(patterns))
	args := []string{"-C", dir, "grep", "-I", "-i", "-c", "-F"}
	for _, pattern := range patterns {
		pattern = strings.TrimSpace(pattern)
		if pattern == "" {
			continue
		}
		if _, ok := seenPatterns[pattern]; ok {
			continue
		}
		seenPatterns[pattern] = struct{}{}
		args = append(args, "-e", pattern)
	}
	if len(seenPatterns) == 0 {
		return map[string]int{}, nil
	}
	args = append(args, commit, "--")

	out, err := exec.CommandContext(ctx, "git", args...).CombinedOutput()
	if err != nil {
		if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 {
			return map[string]int{}, nil
		}
		return nil, fmt.Errorf("git grep count failed: %w, output: %s", err, out)
	}

	counts := make(map[string]int)
	prefix := commit + ":"
	for _, raw := range strings.Split(strings.TrimRight(string(out), "\n"), "\n") {
		if raw == "" {
			continue
		}
		pathAndCount := raw
		if strings.HasPrefix(pathAndCount, prefix) {
			pathAndCount = strings.TrimPrefix(pathAndCount, prefix)
		} else if idx := strings.IndexByte(pathAndCount, ':'); idx >= 0 {
			pathAndCount = pathAndCount[idx+1:]
		}
		idx := strings.LastIndexByte(pathAndCount, ':')
		if idx <= 0 || idx == len(pathAndCount)-1 {
			continue
		}
		count, err := strconv.Atoi(pathAndCount[idx+1:])
		if err != nil || count <= 0 {
			continue
		}
		counts[pathAndCount[:idx]] += count
	}
	return counts, nil
}

// TreeEntry represents an entry in a git tree (file or directory).
type TreeEntry struct {
	Type string // "blob" or "tree"
	Name string // filename or directory name
	Path string // full path relative to repo root
	SHA  string // object SHA
	Size int64  // size in bytes (0 for directories)
}

// ListDir returns the contents of a directory at the given path in the repository.
// Returns a list of TreeEntry objects for files and subdirectories.
func (s *Store) ListDir(ctx context.Context, fullName, dirPath string) ([]TreeEntry, error) {
	return s.ListDirAtRef(ctx, fullName, dirPath, "")
}

// ListDirAtRef returns the contents of a directory at the given path and ref.
// When ref is empty, it resolves HEAD and falls back to the first available
// branch only if HEAD itself is dangling.
func (s *Store) ListDirAtRef(ctx context.Context, fullName, dirPath, ref string) ([]TreeEntry, error) {
	dir, err := s.repoPath(ctx, fullName)
	if err != nil {
		return nil, err
	}
	commit, err := s.resolveContentCommit(ctx, dir, ref)
	if err != nil {
		return nil, err
	}

	// Use ls-tree to list directory contents (not recursive)
	// Format: <mode> <type> <object>\t<path>
	// Use "." for root directory, add trailing slash for subdirectories
	lsPath := dirPath
	if lsPath == "" {
		lsPath = "."
	} else if !strings.HasSuffix(lsPath, "/") {
		lsPath = lsPath + "/"
	}
	// `ls-tree -l` emits blob sizes inline as a 4th meta column (`-` for
	// non-blobs), avoiding a `git cat-file -s` fork per blob entry.
	cmd := exec.CommandContext(ctx, "git", "-C", dir, "ls-tree", "-l", commit, lsPath)
	out, err := cmd.Output()
	if err != nil {
		return nil, err
	}

	var entries []TreeEntry
	for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
		if line == "" {
			continue
		}
		// Parse: "100644 blob <sha> <size>\t<path>" or "040000 tree <sha> -\t<path>"
		parts := strings.SplitN(line, "\t", 2)
		if len(parts) != 2 {
			continue
		}
		metaParts := strings.Fields(parts[0])
		if len(metaParts) != 4 {
			continue
		}
		objType := metaParts[1]
		sha := metaParts[2]
		sizeField := metaParts[3]
		fullPath := parts[1]
		name := fullPath
		if idx := strings.LastIndex(fullPath, "/"); idx >= 0 {
			name = fullPath[idx+1:]
		}

		entry := TreeEntry{
			Type: objType,
			Name: name,
			Path: fullPath,
			SHA:  sha,
			Size: 0,
		}
		if objType == "blob" && sizeField != "-" {
			fmt.Sscanf(sizeField, "%d", &entry.Size)
		}

		entries = append(entries, entry)
	}

	return entries, nil
}

// BlobSHAAtRef returns the blob SHA for an exact path at ref.
func (s *Store) BlobSHAAtRef(ctx context.Context, fullName, path, ref string) (string, error) {
	dir, err := s.repoPath(ctx, fullName)
	if err != nil {
		return "", err
	}
	commit, err := s.resolveContentCommit(ctx, dir, ref)
	if err != nil {
		return "", err
	}

	cmd := exec.CommandContext(ctx, "git", "-C", dir, "ls-tree", "-z", commit, "--", path)
	cmd.Env = append(os.Environ(), "GIT_LITERAL_PATHSPECS=1")
	out, err := cmd.CombinedOutput()
	if err != nil {
		return "", fmt.Errorf("ls-tree failed: %w, output: %s", err, out)
	}
	raw := strings.TrimRight(string(out), "\x00")
	if raw == "" {
		return "", fmt.Errorf("path not found")
	}
	parts := strings.SplitN(raw, "\t", 2)
	if len(parts) != 2 {
		return "", fmt.Errorf("invalid ls-tree entry")
	}
	meta := strings.Fields(parts[0])
	if len(meta) < 3 || meta[1] != "blob" {
		return "", fmt.Errorf("path is not a file")
	}
	if parts[1] != path {
		return "", fmt.Errorf("path not found")
	}
	return meta[2], nil
}

// IsDir checks if the given path is a directory in the repository.
func (s *Store) IsDir(ctx context.Context, fullName, path string) (bool, error) {
	return s.IsDirAtRef(ctx, fullName, path, "")
}

// IsDirAtRef checks if the given path is a directory in the repository at ref.
// When ref is empty, it resolves HEAD and falls back to the first available
// branch only if HEAD itself is dangling.
func (s *Store) IsDirAtRef(ctx context.Context, fullName, path, ref string) (bool, error) {
	dir, err := s.repoPath(ctx, fullName)
	if err != nil {
		return false, err
	}
	commit, err := s.resolveContentCommit(ctx, dir, ref)
	if err != nil {
		return false, err
	}

	// Use ls-tree to check if path is a tree (directory)
	cmd := exec.CommandContext(ctx, "git", "-C", dir, "ls-tree", commit, path)
	out, err := cmd.Output()
	if err != nil {
		return false, err
	}

	// If the output contains entries with "tree" type, it's a directory
	lines := strings.Split(strings.TrimSpace(string(out)), "\n")
	for _, line := range lines {
		if line == "" {
			continue
		}
		parts := strings.SplitN(line, "\t", 2)
		if len(parts) != 2 {
			continue
		}
		metaParts := strings.Fields(parts[0])
		if len(metaParts) >= 2 && metaParts[1] == "tree" {
			return true, nil
		}
	}

	// Check if it's a single tree entry (when path is a directory itself)
	// git ls-tree on a directory shows its contents, not the directory itself
	// So if we got multiple entries or entries with "tree" type, it's a directory
	if len(lines) > 0 && lines[0] != "" {
		// Multiple entries means it's a directory
		if len(lines) > 1 {
			return true, nil
		}
		// Single entry - check if it's a tree
		parts := strings.SplitN(lines[0], "\t", 2)
		if len(parts) == 2 {
			metaParts := strings.Fields(parts[0])
			if len(metaParts) >= 2 && metaParts[1] == "tree" {
				return true, nil
			}
		}
	}

	return false, nil
}

func (s *Store) resolveContentCommit(ctx context.Context, dir, ref string) (string, error) {
	if ref != "" {
		return resolveCommitish(ctx, dir, ref)
	}

	commit, err := resolveCommitish(ctx, dir, "HEAD")
	if err == nil {
		return commit, nil
	}

	branch, branchErr := firstAvailableBranch(ctx, dir)
	if branchErr != nil || branch == "" {
		return "", err
	}
	return resolveCommitish(ctx, dir, branch)
}

func resolveCommitish(ctx context.Context, dir, ref string) (string, error) {
	cmd := exec.CommandContext(ctx, "git", "-C", dir, "rev-parse", "--verify", "--end-of-options", ref+"^{commit}")
	out, err := cmd.Output()
	if err != nil {
		return "", err
	}
	return strings.TrimSpace(string(out)), nil
}

func firstAvailableBranch(ctx context.Context, dir string) (string, error) {
	cmd := exec.CommandContext(ctx, "git", "-C", dir, "for-each-ref", "--format=%(refname:short)", RefsHeadsPrefix, "--count=1")
	out, err := cmd.Output()
	if err != nil {
		return "", err
	}
	return strings.TrimSpace(string(out)), nil
}

// TagInfo holds a tag name and the commit SHA it points to.
type TagInfo struct {
	Name string
	SHA  string
}

// ListTags returns all tags in a repository.
func (s *Store) ListTags(ctx context.Context, fullName string) ([]TagInfo, error) {
	dir, err := s.repoPath(ctx, fullName)
	if err != nil {
		return nil, err
	}
	cmd := exec.CommandContext(ctx, "git", "-C", dir, "for-each-ref", "--sort=-creatordate",
		"--format=%(refname:short) %(objectname:short)", RefsTagsPrefix)
	out, err := cmd.Output()
	if err != nil {
		return nil, err
	}
	var tags []TagInfo
	for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
		if line == "" {
			continue
		}
		parts := strings.SplitN(line, " ", 2)
		if len(parts) == 2 {
			tags = append(tags, TagInfo{Name: parts[0], SHA: parts[1]})
		}
	}
	return tags, nil
}

// DiffNameStatus returns the `git diff --name-status base...head` output.
func (s *Store) DiffNameStatus(ctx context.Context, fullName, base, head string) (string, error) {
	if !IsValidRev(base) || !IsValidRev(head) {
		return "", fmt.Errorf("invalid base or head revision")
	}
	dir, err := s.repoPath(ctx, fullName)
	if err != nil {
		return "", err
	}
	cmd := exec.CommandContext(ctx, "git", "diff", "--name-status", base+"..."+head)
	cmd.Dir = dir
	out, err := cmd.CombinedOutput()
	if err != nil {
		return "", fmt.Errorf("git diff failed: %v\n%s", err, out)
	}
	return string(out), nil
}

// DiffNumStat returns the `git diff --numstat base...head` output with per-file additions/deletions.
func (s *Store) DiffNumStat(ctx context.Context, fullName, base, head string) (string, error) {
	if !IsValidRev(base) || !IsValidRev(head) {
		return "", fmt.Errorf("invalid base or head revision")
	}
	dir, err := s.repoPath(ctx, fullName)
	if err != nil {
		return "", err
	}
	cmd := exec.CommandContext(ctx, "git", "diff", "--numstat", base+"..."+head)
	cmd.Dir = dir
	out, err := cmd.CombinedOutput()
	if err != nil {
		return "", fmt.Errorf("git diff --numstat failed: %v\n%s", err, out)
	}
	return string(out), nil
}

// DiffRaw returns the full unified diff output for base...head.
func (s *Store) DiffRaw(ctx context.Context, fullName, base, head string) (string, error) {
	if !IsValidRev(base) || !IsValidRev(head) {
		return "", fmt.Errorf("invalid base or head revision")
	}
	dir, err := s.repoPath(ctx, fullName)
	if err != nil {
		return "", err
	}
	cmd := exec.CommandContext(ctx, "git", "diff", base+"..."+head)
	cmd.Dir = dir
	out, err := cmd.CombinedOutput()
	if err != nil {
		return "", fmt.Errorf("git diff failed: %v\n%s", err, out)
	}
	return string(out), nil
}

func isRefChangedOutput(out string) bool {
	return strings.Contains(out, "cannot lock ref")
}

// GetDiffHunk extracts the specific unified diff hunk from a PR diff spanning base to head for a given file and target RHS line.
func (s *Store) GetDiffHunk(ctx context.Context, fullName, base, head, path string, line int) (string, error) {
	dir, err := s.repoPath(ctx, fullName)
	if err != nil {
		return "", err
	}
	cmd := exec.CommandContext(ctx, "git", "diff", "-U4", base+"..."+head, "--", path)
	cmd.Dir = dir
	out, err := cmd.CombinedOutput()
	if err != nil {
		return "", fmt.Errorf("git diff failed: %w", err)
	}

	lines := strings.Split(string(out), "\n")
	var currentHunk []string
	rhsLine := 0
	targetFound := false

	for _, l := range lines {
		if strings.HasPrefix(l, "@@ ") {
			if targetFound {
				break
			}
			currentHunk = []string{l}
			parts := strings.Split(l, " ")
			if len(parts) >= 3 {
				rhsHeader := parts[2]
				if strings.HasPrefix(rhsHeader, "+") {
					rhsParts := strings.Split(rhsHeader[1:], ",")
					fmt.Sscanf(rhsParts[0], "%d", &rhsLine)
				}
			}
			continue
		}

		if len(currentHunk) > 0 {
			if strings.HasPrefix(l, "diff ") || strings.HasPrefix(l, "index ") || strings.HasPrefix(l, "--- ") || strings.HasPrefix(l, "+++ ") {
				if targetFound {
					break
				}
				currentHunk = nil
				continue
			}

			currentHunk = append(currentHunk, l)

			if strings.HasPrefix(l, "+") || strings.HasPrefix(l, " ") {
				if rhsLine == line {
					targetFound = true
				}
				rhsLine++
			} else if strings.HasPrefix(l, "-") || strings.HasPrefix(l, "\\") {
				// rhsLine doesn't increment
			} else {
				if targetFound {
					break
				}
				currentHunk = nil
			}
		}
	}

	if targetFound {
		return strings.Join(currentHunk, "\n"), nil
	}
	return "", nil
}
