package git_commands

import (
	"bytes"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"os"
	"os/exec"
	"regexp"
	"strings"
	"time"

	"github.com/cli/go-gh/v2/pkg/auth"
	"github.com/jesseduffield/lazygit/pkg/commands/hosting_service"
	"github.com/jesseduffield/lazygit/pkg/commands/models"
	"github.com/samber/lo"
	"golang.org/x/sync/errgroup"
)

type GitHubCommands struct {
	*GitCommon
}

func NewGitHubCommands(gitCommon *GitCommon) *GitHubCommands {
	return &GitHubCommands{
		GitCommon: gitCommon,
	}
}

// https://github.com/cli/cli/issues/2300
func (self *GitHubCommands) ConfiguredBaseRemoteName() string {
	// TODO: we only support the (common) case where the value of the config is "base", meaning that
	// the remote's URL determines the GitHub repo. Since `gh repo set-default` on the command line
	// sets the config this way, it's probably good enough in practice, but for completeness it
	// would be nice to also support the case where the config value is a full remote name (e.g.
	// "jesseduffield/lazygit").

	cmdArgs := NewGitCmd("config").
		Arg("--local", "--get-regexp", `remote\..*\.gh-resolved`).
		ToArgv()

	output, _, err := self.cmd.New(cmdArgs).DontLog().RunWithOutputs()
	if err != nil {
		return ""
	}

	regex := regexp.MustCompile(`remote\.(.+)\.gh-resolved`)
	matches := regex.FindStringSubmatch(output)
	if len(matches) < 2 {
		return ""
	}

	return matches[1]
}

func (self *GitHubCommands) SetConfiguredBaseRemoteName(remoteName string) error {
	cmdArgs := NewGitCmd("config").
		Arg("--local", "--add", fmt.Sprintf("remote.%s.gh-resolved", remoteName), "base").
		ToArgv()

	return self.cmd.New(cmdArgs).DontLog().Run()
}

type Response struct {
	Data RepositoryQuery `json:"data"`
}

type RepositoryQuery struct {
	Repository map[string]PullRequest `json:"repository"`
}

type PullRequest struct {
	Edges []PullRequestEdge `json:"edges"`
}

type PullRequestEdge struct {
	Node PullRequestNode `json:"node"`
}

type PullRequestNode struct {
	Title               string                `json:"title"`
	HeadRefName         string                `json:"headRefName"`
	Number              int                   `json:"number"`
	Url                 string                `json:"url"`
	HeadRepositoryOwner GithubRepositoryOwner `json:"headRepositoryOwner"`
	State               string                `json:"state"`
	IsDraft             bool                  `json:"isDraft"`
	HeadRef             GithubRef             `json:"headRef"`
}

type GithubRepositoryOwner struct {
	Login string `json:"login"`
}

type GithubRef struct {
	Target GithubGitObject `json:"target"`
}

type GithubGitObject struct {
	StatusCheckRollup GithubStatusCheckRollup `json:"statusCheckRollup"`
}

type GithubStatusCheckRollup struct {
	State string `json:"state"`
}

type graphQLRequest struct {
	Query     string            `json:"query"`
	Variables map[string]string `json:"variables"`
}

func fetchPullRequestsQuery(branches []string, owner string, repo string) (string, map[string]string) {
	variables := make(map[string]string, len(branches)+2)
	variables["owner"] = owner
	variables["repo"] = repo
	varDecls := make([]string, 0, len(branches)+2)
	varDecls = append(varDecls, "$owner: String!", "$repo: String!")
	queries := make([]string, 0, len(branches))
	for i, branch := range branches {
		// We're making a sub-query per branch, and arbitrarily labelling each subquery
		// as a1, a2, etc.
		fieldName := fmt.Sprintf("a%d", i+1)
		varName := fmt.Sprintf("branch%d", i+1)
		variables[varName] = branch
		varDecls = append(varDecls, fmt.Sprintf("$%s: String!", varName))
		// We fetch a few PRs per branch name because multiple forks may have PRs
		// with the same head ref name. The mapping logic filters by owner later.
		queries = append(queries, fmt.Sprintf(`%s: pullRequests(first: 5, headRefName: $%s, orderBy: {field: CREATED_AT, direction: DESC}) {
      edges {
        node {
          title
          headRefName
          state
          number
          url
          isDraft
          headRef {
            target {
              ... on Commit {
                statusCheckRollup {
                  state
                }
              }
            }
          }
          headRepositoryOwner {
            login
          }
        }
      }
    }`, fieldName, varName))
	}

	queryString := fmt.Sprintf(`query(%s) {
  repository(owner: $owner, name: $repo) {
    %s
  }
}`, strings.Join(varDecls, ", "), strings.Join(queries, "\n"))

	return queryString, variables
}

// GetAuthToken returns the token to authenticate against the given host with,
// or an empty string if there is none.
//
// The token has to come from gh itself rather than from an in-process lookup
// with go-gh: that reads gh's config file once per process and answers from
// that snapshot ever after, whereas gh rewrites the file whenever the active
// account changes, and keeps the active account's token either there or in the
// system keyring. Under a long-running lazygit the snapshot therefore drifts
// out of date, leaving us with a token for an account that is no longer active,
// or with no token at all.
func (self *GitHubCommands) GetAuthToken(host string) string {
	ghExe := ghExecutable()
	if ghExe == "" {
		// Without gh installed, the environment variables and config file that
		// gh would have consulted are still worth a look.
		token, _ := auth.TokenFromEnvOrConfig(host)
		return token
	}

	cmdArgs := []string{ghExe, "auth", "token", "--hostname", host}
	output, _, err := self.cmd.New(cmdArgs).DontLog().RunWithOutputs()
	if err != nil {
		// Not being logged in to this host is a normal state rather than
		// something to report; the runner logs gh's stderr for the rest.
		return ""
	}

	return strings.TrimSpace(output)
}

// ghExecutable returns the path of the gh binary, or an empty string if it
// isn't installed.
func ghExecutable() string {
	if ghExe := os.Getenv("GH_PATH"); ghExe != "" {
		return ghExe
	}

	// A gh found in the current directory rather than on PATH comes back as
	// exec.ErrDot, which we treat as not having found one at all.
	ghExe, err := exec.LookPath("gh")
	if err != nil {
		return ""
	}

	return ghExe
}

// FetchRecentPRs fetches recent pull requests using GraphQL. serviceInfo
// identifies the GitHub instance (github.com or a GitHub Enterprise Server)
// and the owner/repo to query against.
func (self *GitHubCommands) FetchRecentPRs(branches []string, serviceInfo *hosting_service.ServiceInfo, token string) ([]*models.GithubPullRequest, error) {
	endpoint := graphQLEndpoint(serviceInfo.WebDomain)
	t := time.Now()

	var g errgroup.Group

	// We want at most 5 concurrent requests, but no less than 10 branches per request
	concurrency := 5
	minBranchesPerRequest := 10
	branchesPerRequest := max(len(branches)/concurrency, minBranchesPerRequest)
	numChunks := (len(branches) + branchesPerRequest - 1) / branchesPerRequest
	results := make(chan []*models.GithubPullRequest, numChunks)

	for i := 0; i < len(branches); i += branchesPerRequest {
		end := i + branchesPerRequest
		if end > len(branches) {
			end = len(branches)
		}
		branchChunk := branches[i:end]

		// Launch a goroutine for each chunk of branches
		g.Go(func() error {
			prs, err := self.fetchRecentPRsAux(endpoint, serviceInfo.Owner, serviceInfo.Repository, branchChunk, token)
			if err != nil {
				return err
			}
			results <- prs
			return nil
		})
	}

	// Wait for all goroutines, then close the channel so the range loop exits
	err := g.Wait()
	close(results)
	if err != nil {
		return nil, err
	}

	// Collect results from all goroutines
	var allPRs []*models.GithubPullRequest
	for prs := range results {
		allPRs = append(allPRs, prs...)
	}

	self.Log.Infof("Fetched %d PRs in %s", len(allPRs), time.Since(t))

	return allPRs, nil
}

func (self *GitHubCommands) fetchRecentPRsAux(endpoint string, repoOwner string, repoName string, branches []string, token string) ([]*models.GithubPullRequest, error) {
	queryString, variables := fetchPullRequestsQuery(branches, repoOwner, repoName)

	bodyBytes, err := json.Marshal(graphQLRequest{Query: queryString, Variables: variables})
	if err != nil {
		return nil, err
	}
	req, err := http.NewRequest("POST", endpoint, bytes.NewBuffer(bodyBytes))
	if err != nil {
		return nil, err
	}

	req.Header.Set("Authorization", "token "+token)
	req.Header.Set("Content-Type", "application/json")

	// Bound the request so that a dead or extremely slow network can't leave
	// the pull-request refresh in flight for minutes. The data is auxiliary,
	// so giving up and retrying on the next refresh beats waiting.
	client := &http.Client{Timeout: 10 * time.Second}
	resp, err := client.Do(req)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		bodyStr := new(bytes.Buffer)
		_, _ = bodyStr.ReadFrom(resp.Body)
		return nil, fmt.Errorf("GraphQL query failed with status: %s. Body: %s", resp.Status, bodyStr.String())
	}

	respBytes, err := io.ReadAll(resp.Body)
	if err != nil {
		return nil, err
	}

	return parsePullRequestsResponse(respBytes)
}

func parsePullRequestsResponse(respBytes []byte) ([]*models.GithubPullRequest, error) {
	var result Response
	if err := json.Unmarshal(respBytes, &result); err != nil {
		return nil, err
	}

	prs := []*models.GithubPullRequest{}
	for _, repoQuery := range result.Data.Repository {
		for _, edge := range repoQuery.Edges {
			node := edge.Node
			pr := &models.GithubPullRequest{
				HeadRefName: node.HeadRefName,
				Number:      node.Number,
				Title:       node.Title,
				State:       lo.Ternary(node.IsDraft && node.State != "CLOSED", "DRAFT", node.State),
				ChecksState: node.HeadRef.Target.StatusCheckRollup.State,
				Url:         node.Url,
				HeadRepositoryOwner: models.GithubRepositoryOwner{
					Login: node.HeadRepositoryOwner.Login,
				},
			}
			prs = append(prs, pr)
		}
	}

	return prs, nil
}

// returns a map from branch name to pull request
func GenerateGithubPullRequestMap(
	prs []*models.GithubPullRequest,
	branches []*models.Branch,
	remotes []*models.Remote,
) map[string]*models.GithubPullRequest {
	res := map[string]*models.GithubPullRequest{}

	if len(prs) == 0 {
		return res
	}

	remotesToOwnersMap := getRemotesToOwnersMap(remotes)

	// A PR can be identified by two things: the owner e.g. 'jesseduffield' and the
	// branch name e.g. 'feature/my-feature'. The owner might be different
	// to the owner of the repo if the PR is from a fork of that repo.
	type prKey struct {
		owner      string
		branchName string
	}

	prByKey := map[prKey]models.GithubPullRequest{}

	for _, pr := range prs {
		key := prKey{owner: strings.ToLower(pr.UserName()), branchName: pr.BranchName()}
		// PRs are returned newest-first from the API, so the first one we
		// see for each key is the most recent and therefore the most relevant.
		if _, exists := prByKey[key]; !exists {
			prByKey[key] = *pr
		}
	}

	for _, branch := range branches {
		if !branch.IsTrackingRemote() {
			continue
		}

		owner, foundRemoteOwner := remotesToOwnersMap[branch.UpstreamRemote]
		if !foundRemoteOwner {
			// UpstreamRemote may be a full URL rather than a remote name;
			// try parsing the owner directly from it.
			repoInfo, err := hosting_service.GetRepoInfoFromURL(branch.UpstreamRemote)
			if err != nil {
				continue
			}
			owner = repoInfo.Owner
		}

		pr, hasPr := prByKey[prKey{owner: strings.ToLower(owner), branchName: branch.UpstreamBranch}]

		if !hasPr {
			continue
		}

		res[branch.Name] = &pr
	}

	return res
}

func getRemotesToOwnersMap(remotes []*models.Remote) map[string]string {
	res := map[string]string{}
	for _, remote := range remotes {
		if len(remote.Urls) == 0 {
			continue
		}

		repoInfo, err := hosting_service.GetRepoInfoFromURL(remote.Urls[0])
		if err != nil {
			continue
		}

		res[remote.Name] = repoInfo.Owner
	}
	return res
}

// graphQLEndpoint returns the GraphQL API URL for a GitHub host. github.com
// uses a dedicated api. subdomain; GitHub Enterprise Server hangs the API off
// the web host under /api/graphql.
func graphQLEndpoint(host string) string {
	if auth.NormalizeHostname(host) == "github.com" {
		return "https://api.github.com/graphql"
	}
	return "https://" + host + "/api/graphql"
}
