package api

import (
	"bytes"
	"encoding/json"
	"fmt"
	"net/url"
	"strings"
	"time"

	"github.com/cli/cli/v2/internal/ghrepo"
	"github.com/shurcooL/githubv4"
)

type PullRequestReviewState int

const (
	ReviewApprove PullRequestReviewState = iota
	ReviewRequestChanges
	ReviewComment
)

type PullRequestReviewInput struct {
	Body  string
	State PullRequestReviewState
}

type PullRequestReviews struct {
	Nodes    []PullRequestReview
	PageInfo struct {
		HasNextPage bool
		EndCursor   string
	}
	TotalCount int
}

type PullRequestReview struct {
	ID                  string         `json:"id"`
	Author              CommentAuthor  `json:"author"`
	AuthorAssociation   string         `json:"authorAssociation"`
	Body                string         `json:"body"`
	SubmittedAt         *time.Time     `json:"submittedAt"`
	IncludesCreatedEdit bool           `json:"includesCreatedEdit"`
	ReactionGroups      ReactionGroups `json:"reactionGroups"`
	State               string         `json:"state"`
	URL                 string         `json:"url,omitempty"`
	Commit              Commit         `json:"commit"`
}

func (prr PullRequestReview) Identifier() string {
	return prr.ID
}

func (prr PullRequestReview) AuthorLogin() string {
	return prr.Author.DisplayName()
}

func (prr PullRequestReview) Association() string {
	return prr.AuthorAssociation
}

func (prr PullRequestReview) Content() string {
	return prr.Body
}

func (prr PullRequestReview) Created() time.Time {
	if prr.SubmittedAt == nil {
		return time.Time{}
	}
	return *prr.SubmittedAt
}

func (prr PullRequestReview) HiddenReason() string {
	return ""
}

func (prr PullRequestReview) IsEdited() bool {
	return prr.IncludesCreatedEdit
}

func (prr PullRequestReview) IsHidden() bool {
	return false
}

func (prr PullRequestReview) Link() string {
	return prr.URL
}

func (prr PullRequestReview) Reactions() ReactionGroups {
	return prr.ReactionGroups
}

func (prr PullRequestReview) Status() string {
	return prr.State
}

type PullRequestReviewStatus struct {
	ChangesRequested bool
	Approved         bool
	ReviewRequired   bool
}

func (pr *PullRequest) ReviewStatus() PullRequestReviewStatus {
	var status PullRequestReviewStatus
	switch pr.ReviewDecision {
	case "CHANGES_REQUESTED":
		status.ChangesRequested = true
	case "APPROVED":
		status.Approved = true
	case "REVIEW_REQUIRED":
		status.ReviewRequired = true
	}
	return status
}

func (pr *PullRequest) DisplayableReviews() PullRequestReviews {
	published := []PullRequestReview{}
	for _, prr := range pr.Reviews.Nodes {
		//Dont display pending reviews
		//Dont display commenting reviews without top level comment body
		if prr.State != "PENDING" && !(prr.State == "COMMENTED" && prr.Body == "") {
			published = append(published, prr)
		}
	}
	return PullRequestReviews{Nodes: published, TotalCount: len(published)}
}

type ReviewRequests struct {
	Nodes []struct {
		RequestedReviewer RequestedReviewer
	}
}

type RequestedReviewer struct {
	TypeName     string `json:"__typename"`
	Login        string `json:"login"`
	Name         string `json:"name"`
	Slug         string `json:"slug"`
	Organization struct {
		Login string `json:"login"`
	} `json:"organization"`
}

const teamTypeName = "Team"
const botTypeName = "Bot"
const userTypeName = "User"

func (r RequestedReviewer) LoginOrSlug() string {
	if r.TypeName == teamTypeName {
		return fmt.Sprintf("%s/%s", r.Organization.Login, r.Slug)
	}
	return r.Login
}

// DisplayName returns a user-friendly name for the reviewer via actorDisplayName.
// Teams are handled separately as "org/slug".
func (r RequestedReviewer) DisplayName() string {
	if r.TypeName == teamTypeName {
		return fmt.Sprintf("%s/%s", r.Organization.Login, r.Slug)
	}
	return actorDisplayName(r.TypeName, r.Login, r.Name)
}

func (r ReviewRequests) Logins() []string {
	logins := make([]string, len(r.Nodes))
	for i, r := range r.Nodes {
		logins[i] = r.RequestedReviewer.LoginOrSlug()
	}
	return logins
}

// DisplayNames returns user-friendly display names for all requested reviewers.
func (r ReviewRequests) DisplayNames() []string {
	names := make([]string, len(r.Nodes))
	for i, r := range r.Nodes {
		names[i] = r.RequestedReviewer.DisplayName()
	}
	return names
}

// ReviewerCandidate represents a potential reviewer for a pull request.
// This can be a User, Bot, or Team. It mirrors AssignableActor but adds
// team support (teams can review but not be assigned) and drops the ID method.
// ReviewerUser and ReviewerBot are thin wrappers around AssignableUser and
// AssignableBot that satisfy this interface.
type ReviewerCandidate interface {
	DisplayName() string
	Login() string

	sealedReviewerCandidate()
}

// ReviewerUser is a user who can review a pull request.
type ReviewerUser struct {
	AssignableUser
}

func NewReviewerUser(login, name string) ReviewerUser {
	return ReviewerUser{
		AssignableUser: NewAssignableUser("", login, name),
	}
}

func (r ReviewerUser) sealedReviewerCandidate() {}

// ReviewerBot is a bot who can review a pull request.
type ReviewerBot struct {
	AssignableBot
}

func NewReviewerBot(login string) ReviewerBot {
	return ReviewerBot{
		AssignableBot: NewAssignableBot("", login),
	}
}

func (b ReviewerBot) DisplayName() string {
	return actorDisplayName("Bot", b.login, "")
}

func (r ReviewerBot) sealedReviewerCandidate() {}

// ReviewerTeam is a team that can review a pull request.
type ReviewerTeam struct {
	org      string
	teamSlug string
}

// NewReviewerTeam creates a new ReviewerTeam.
func NewReviewerTeam(orgName, teamSlug string) ReviewerTeam {
	return ReviewerTeam{org: orgName, teamSlug: teamSlug}
}

func (r ReviewerTeam) DisplayName() string {
	return fmt.Sprintf("%s/%s", r.org, r.teamSlug)
}

func (r ReviewerTeam) Login() string {
	return fmt.Sprintf("%s/%s", r.org, r.teamSlug)
}

func (r ReviewerTeam) Slug() string {
	return r.teamSlug
}

func (r ReviewerTeam) sealedReviewerCandidate() {}

func AddReview(client *Client, repo ghrepo.Interface, pr *PullRequest, input *PullRequestReviewInput) error {
	var mutation struct {
		AddPullRequestReview struct {
			ClientMutationID string
		} `graphql:"addPullRequestReview(input:$input)"`
	}

	state := githubv4.PullRequestReviewEventComment
	switch input.State {
	case ReviewApprove:
		state = githubv4.PullRequestReviewEventApprove
	case ReviewRequestChanges:
		state = githubv4.PullRequestReviewEventRequestChanges
	}

	body := githubv4.String(input.Body)
	variables := map[string]interface{}{
		"input": githubv4.AddPullRequestReviewInput{
			PullRequestID: pr.ID,
			Event:         &state,
			Body:          &body,
		},
	}

	return client.Mutate(repo.RepoHost(), "PullRequestReviewAdd", &mutation, variables)
}

// AddPullRequestReviews adds the given user and team reviewers to a pull request using the REST API.
// Team identifiers can be in "org/slug" format.
func AddPullRequestReviews(client *Client, repo ghrepo.Interface, prNumber int, users, teams []string) error {
	if len(users) == 0 && len(teams) == 0 {
		return nil
	}

	// The API requires empty arrays instead of null values
	if users == nil {
		users = []string{}
	}

	path := fmt.Sprintf(
		"repos/%s/%s/pulls/%d/requested_reviewers",
		url.PathEscape(repo.RepoOwner()),
		url.PathEscape(repo.RepoName()),
		prNumber,
	)
	body := struct {
		Reviewers     []string `json:"reviewers"`
		TeamReviewers []string `json:"team_reviewers"`
	}{
		Reviewers:     users,
		TeamReviewers: extractTeamSlugs(teams),
	}
	buf := &bytes.Buffer{}
	if err := json.NewEncoder(buf).Encode(body); err != nil {
		return err
	}
	// The endpoint responds with the updated pull request object; we don't need it here.
	return client.REST(repo.RepoHost(), "POST", path, buf, nil)
}

// RemovePullRequestReviews removes requested reviewers from a pull request using the REST API.
// Team identifiers can be in "org/slug" format.
func RemovePullRequestReviews(client *Client, repo ghrepo.Interface, prNumber int, users, teams []string) error {
	if len(users) == 0 && len(teams) == 0 {
		return nil
	}

	// The API requires empty arrays instead of null values
	if users == nil {
		users = []string{}
	}

	path := fmt.Sprintf(
		"repos/%s/%s/pulls/%d/requested_reviewers",
		url.PathEscape(repo.RepoOwner()),
		url.PathEscape(repo.RepoName()),
		prNumber,
	)
	body := struct {
		Reviewers     []string `json:"reviewers"`
		TeamReviewers []string `json:"team_reviewers"`
	}{
		Reviewers:     users,
		TeamReviewers: extractTeamSlugs(teams),
	}
	buf := &bytes.Buffer{}
	if err := json.NewEncoder(buf).Encode(body); err != nil {
		return err
	}
	// The endpoint responds with the updated pull request object; we don't need it here.
	return client.REST(repo.RepoHost(), "DELETE", path, buf, nil)
}

// RequestReviewsByLogin sets requested reviewers on a pull request using the GraphQL mutation.
// This mutation replaces existing reviewers with the provided set unless union is true.
// Only available on github.com, not GHES.
// Bot logins should be passed without the [bot] suffix; it is appended automatically.
// Team slugs must be in the format "org/team-slug".
// When union is false (replace mode), passing empty slices will remove all reviewers.
func RequestReviewsByLogin(client *Client, repo ghrepo.Interface, prID string, userLogins, botLogins, teamSlugs []string, union bool) error {
	// In union mode (additive), nothing to do if all lists are empty.
	// In replace mode, we may still need to call the mutation to clear reviewers.
	if union && len(userLogins) == 0 && len(botLogins) == 0 && len(teamSlugs) == 0 {
		return nil
	}

	var mutation struct {
		RequestReviewsByLogin struct {
			ClientMutationId string `graphql:"clientMutationId"`
		} `graphql:"requestReviewsByLogin(input: $input)"`
	}

	type RequestReviewsByLoginInput struct {
		PullRequestID githubv4.ID        `json:"pullRequestId"`
		UserLogins    *[]githubv4.String `json:"userLogins,omitempty"`
		BotLogins     *[]githubv4.String `json:"botLogins,omitempty"`
		TeamSlugs     *[]githubv4.String `json:"teamSlugs,omitempty"`
		Union         githubv4.Boolean   `json:"union"`
	}

	input := RequestReviewsByLoginInput{
		PullRequestID: githubv4.ID(prID),
		Union:         githubv4.Boolean(union),
	}

	userLoginValues := toGitHubV4Strings(userLogins, "")
	input.UserLogins = &userLoginValues

	// Bot logins require the [bot] suffix for the mutation
	botLoginValues := toGitHubV4Strings(botLogins, "[bot]")
	input.BotLogins = &botLoginValues

	teamSlugValues := toGitHubV4Strings(teamSlugs, "")
	input.TeamSlugs = &teamSlugValues

	variables := map[string]interface{}{
		"input": input,
	}

	return client.Mutate(repo.RepoHost(), "RequestReviewsByLogin", &mutation, variables)
}

// SuggestedReviewerActors fetches suggested reviewers for a pull request.
// It combines results from three sources using a cascading quota system:
// - suggestedReviewerActors - suggested based on PR activity (base quota: 5)
// - repository collaborators - all collaborators (base quota: 5 + unfilled from suggestions)
// - organization teams - all teams for org repos (base quota: 5 + unfilled from collaborators)
//
// This ensures we show up to 15 total candidates, with each source filling any
// unfilled quota from the previous source. Results are deduplicated.
// Returns the candidates, a MoreResults count, and an error.
func SuggestedReviewerActors(client *Client, repo ghrepo.Interface, prID string, query string) ([]ReviewerCandidate, int, error) {
	// Fetch 10 from each source to allow cascading quota to fill from available results.
	// Organization teams are fetched via repository.owner inline fragment, which
	// gracefully returns empty data for personal (User-owned) repos.
	// We also fetch unfiltered total counts via aliases for the "X more" display.
	type responseData struct {
		Node struct {
			PullRequest struct {
				Author struct {
					Login string
				}
				SuggestedActors struct {
					Nodes []struct {
						IsAuthor    bool
						IsCommenter bool
						Reviewer    struct {
							TypeName string `graphql:"__typename"`
							User     struct {
								Login string
								Name  string
							} `graphql:"... on User"`
							Bot struct {
								Login string
							} `graphql:"... on Bot"`
						}
					}
				} `graphql:"suggestedReviewerActors(first: 10, query: $query)"`
			} `graphql:"... on PullRequest"`
		} `graphql:"node(id: $id)"`
		Repository struct {
			Owner struct {
				TypeName     string `graphql:"__typename"`
				Organization struct {
					Teams struct {
						Nodes []struct {
							Slug string
						}
					} `graphql:"teams(first: 10, query: $query)"`
					TeamsTotalCount struct {
						TotalCount int
					} `graphql:"teamsTotalCount: teams(first: 0)"`
				} `graphql:"... on Organization"`
			}
			Collaborators struct {
				Nodes []struct {
					Login string
					Name  string
				}
			} `graphql:"collaborators(first: 10, query: $query)"`
			CollaboratorsTotalCount struct {
				TotalCount int
			} `graphql:"collaboratorsTotalCount: collaborators(first: 0)"`
		} `graphql:"repository(owner: $owner, name: $name)"`
	}

	variables := map[string]interface{}{
		"id":    githubv4.ID(prID),
		"query": githubv4.String(query),
		"owner": githubv4.String(repo.RepoOwner()),
		"name":  githubv4.String(repo.RepoName()),
	}

	var result responseData
	err := client.Query(repo.RepoHost(), "SuggestedReviewerActors", &result, variables)
	if err != nil {
		return nil, 0, err
	}

	// Build candidates using cascading quota logic:
	// Each source has a base quota of 5, plus any unfilled quota from previous sources.
	// This ensures we show up to 15 total candidates, filling gaps when earlier sources have fewer.
	// Pre-seed seen with the PR author since you cannot review your own PR.
	seen := make(map[string]bool)
	if authorLogin := result.Node.PullRequest.Author.Login; authorLogin != "" {
		seen[authorLogin] = true
	}
	var candidates []ReviewerCandidate
	const baseQuota = 5

	// Suggested reviewers (excluding author)
	suggestionsAdded := 0
	for _, n := range result.Node.PullRequest.SuggestedActors.Nodes {
		if suggestionsAdded >= baseQuota {
			break
		}
		if n.IsAuthor {
			continue
		}
		var candidate ReviewerCandidate
		var login string
		if n.Reviewer.TypeName == "User" && n.Reviewer.User.Login != "" {
			login = n.Reviewer.User.Login
			candidate = NewReviewerUser(login, n.Reviewer.User.Name)
		} else if n.Reviewer.TypeName == "Bot" && n.Reviewer.Bot.Login != "" {
			login = n.Reviewer.Bot.Login
			candidate = NewReviewerBot(login)
		} else {
			continue
		}
		if !seen[login] {
			seen[login] = true
			candidates = append(candidates, candidate)
			suggestionsAdded++
		}
	}

	// Collaborators: quota = base + unfilled from suggestions
	collaboratorsQuota := baseQuota + (baseQuota - suggestionsAdded)
	collaboratorsAdded := 0
	for _, c := range result.Repository.Collaborators.Nodes {
		if collaboratorsAdded >= collaboratorsQuota {
			break
		}
		if c.Login == "" {
			continue
		}
		if !seen[c.Login] {
			seen[c.Login] = true
			candidates = append(candidates, NewReviewerUser(c.Login, c.Name))
			collaboratorsAdded++
		}
	}

	// Teams: quota = base + unfilled from collaborators
	teamsQuota := baseQuota + (collaboratorsQuota - collaboratorsAdded)
	teamsAdded := 0
	ownerName := repo.RepoOwner()
	for _, t := range result.Repository.Owner.Organization.Teams.Nodes {
		if teamsAdded >= teamsQuota {
			break
		}
		if t.Slug == "" {
			continue
		}
		teamLogin := fmt.Sprintf("%s/%s", ownerName, t.Slug)
		if !seen[teamLogin] {
			seen[teamLogin] = true
			candidates = append(candidates, NewReviewerTeam(ownerName, t.Slug))
			teamsAdded++
		}
	}

	// MoreResults uses unfiltered total counts (teams will be 0 for personal repos)
	moreResults := result.Repository.CollaboratorsTotalCount.TotalCount + result.Repository.Owner.Organization.TeamsTotalCount.TotalCount

	return candidates, moreResults, nil
}

// SuggestedReviewerActorsForRepo fetches potential reviewers for a repository.
// Unlike SuggestedReviewerActors, this doesn't require an existing PR - used for gh pr create.
// It combines results from two sources using a cascading quota system:
// - repository collaborators (base quota: 5)
// - organization teams (base quota: 5 + unfilled from collaborators)
//
// This ensures we show up to 10 total candidates, with each source filling any
// unfilled quota from the previous source. Results are deduplicated.
// Returns the candidates, a MoreResults count, and an error.
func SuggestedReviewerActorsForRepo(client *Client, repo ghrepo.Interface, query string) ([]ReviewerCandidate, int, error) {
	type responseData struct {
		Viewer struct {
			Login string
		}
		Repository struct {
			// HACK: There's no repo-level API to check Copilot reviewer eligibility,
			// so we piggyback on an open PR's suggestedReviewerActors to detect
			// whether Copilot is available as a reviewer for this repository.
			PullRequests struct {
				Nodes []struct {
					SuggestedActors struct {
						Nodes []struct {
							Reviewer struct {
								TypeName string `graphql:"__typename"`
								Bot      struct {
									Login string
								} `graphql:"... on Bot"`
							}
						}
					} `graphql:"suggestedReviewerActors(first: 10)"`
				}
			} `graphql:"pullRequests(first: 1, states: [OPEN])"`
			Owner struct {
				TypeName     string `graphql:"__typename"`
				Organization struct {
					Teams struct {
						Nodes []struct {
							Slug string
						}
					} `graphql:"teams(first: 10, query: $query)"`
					TeamsTotalCount struct {
						TotalCount int
					} `graphql:"teamsTotalCount: teams(first: 0)"`
				} `graphql:"... on Organization"`
			}
			Collaborators struct {
				Nodes []struct {
					Login string
					Name  string
				}
			} `graphql:"collaborators(first: 10, query: $query)"`
			CollaboratorsTotalCount struct {
				TotalCount int
			} `graphql:"collaboratorsTotalCount: collaborators(first: 0)"`
		} `graphql:"repository(owner: $owner, name: $name)"`
	}

	variables := map[string]interface{}{
		"query": githubv4.String(query),
		"owner": githubv4.String(repo.RepoOwner()),
		"name":  githubv4.String(repo.RepoName()),
	}

	var result responseData
	err := client.Query(repo.RepoHost(), "SuggestedReviewerActorsForRepo", &result, variables)
	if err != nil {
		return nil, 0, err
	}

	// Build candidates using cascading quota logic.
	// Pre-seed seen with the current user to exclude them from results
	// since you cannot review your own PR.
	seen := make(map[string]bool)
	if result.Viewer.Login != "" {
		seen[result.Viewer.Login] = true
	}
	var candidates []ReviewerCandidate
	const baseQuota = 5

	// Check for Copilot availability from open PR's suggested reviewers
	for _, pr := range result.Repository.PullRequests.Nodes {
		for _, actor := range pr.SuggestedActors.Nodes {
			if actor.Reviewer.TypeName == "Bot" && actor.Reviewer.Bot.Login == CopilotReviewerLogin {
				candidates = append(candidates, NewReviewerBot(CopilotReviewerLogin))
				seen[CopilotReviewerLogin] = true
				break
			}
		}
	}

	// Collaborators
	collaboratorsAdded := 0
	for _, c := range result.Repository.Collaborators.Nodes {
		if collaboratorsAdded >= baseQuota {
			break
		}
		if c.Login == "" {
			continue
		}
		if !seen[c.Login] {
			seen[c.Login] = true
			candidates = append(candidates, NewReviewerUser(c.Login, c.Name))
			collaboratorsAdded++
		}
	}

	// Teams: quota = base + unfilled from collaborators
	teamsQuota := baseQuota + (baseQuota - collaboratorsAdded)
	teamsAdded := 0
	ownerName := repo.RepoOwner()
	for _, t := range result.Repository.Owner.Organization.Teams.Nodes {
		if teamsAdded >= teamsQuota {
			break
		}
		if t.Slug == "" {
			continue
		}
		teamLogin := fmt.Sprintf("%s/%s", ownerName, t.Slug)
		if !seen[teamLogin] {
			seen[teamLogin] = true
			candidates = append(candidates, NewReviewerTeam(ownerName, t.Slug))
			teamsAdded++
		}
	}

	// MoreResults uses unfiltered total counts (teams will be 0 for personal repos)
	moreResults := result.Repository.CollaboratorsTotalCount.TotalCount + result.Repository.Owner.Organization.TeamsTotalCount.TotalCount

	return candidates, moreResults, nil
}

// extractTeamSlugs extracts just the slug portion from team identifiers.
// Team identifiers can be in "org/slug" format; this returns just the slug.
func extractTeamSlugs(teams []string) []string {
	slugs := make([]string, 0, len(teams))
	for _, t := range teams {
		if t == "" {
			continue
		}
		s := strings.SplitN(t, "/", 2)
		slugs = append(slugs, s[len(s)-1])
	}
	return slugs
}

// toGitHubV4Strings converts a string slice to a githubv4.String slice,
// optionally appending a suffix to each element.
func toGitHubV4Strings(strs []string, suffix string) []githubv4.String {
	result := make([]githubv4.String, len(strs))
	for i, s := range strs {
		result[i] = githubv4.String(s + suffix)
	}
	return result
}
