package view

import (
	"fmt"
	"sort"
	"strconv"
	"strings"
	"time"

	"github.com/MakeNowJust/heredoc"
	"github.com/cli/cli/v2/api"
	"github.com/cli/cli/v2/internal/browser"
	fd "github.com/cli/cli/v2/internal/featuredetection"
	"github.com/cli/cli/v2/internal/ghrepo"
	"github.com/cli/cli/v2/internal/text"
	"github.com/cli/cli/v2/pkg/cmd/pr/shared"
	"github.com/cli/cli/v2/pkg/cmdutil"
	"github.com/cli/cli/v2/pkg/iostreams"
	"github.com/cli/cli/v2/pkg/markdown"
	"github.com/spf13/cobra"
)

type ViewOptions struct {
	IO      *iostreams.IOStreams
	Browser browser.Browser
	// TODO projectsV1Deprecation
	// Remove this detector since it is only used for test validation.
	Detector fd.Detector

	Finder   shared.PRFinder
	Exporter cmdutil.Exporter

	SelectorArg string
	BrowserMode bool
	Comments    bool

	Now func() time.Time
}

func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Command {
	opts := &ViewOptions{
		IO:      f.IOStreams,
		Browser: f.Browser,
		Now:     time.Now,
	}

	cmd := &cobra.Command{
		Use:   "view [<number> | <url> | <branch>]",
		Short: "View a pull request",
		Long: heredoc.Docf(`
			Display the title, body, and other information about a pull request.

			Without an argument, the pull request that belongs to the current branch
			is displayed.

			With %[1]s--web%[1]s flag, open the pull request in a web browser instead.
		`, "`"),
		Args: cobra.MaximumNArgs(1),
		RunE: func(cmd *cobra.Command, args []string) error {
			opts.Finder = shared.NewFinder(f)

			if repoOverride, _ := cmd.Flags().GetString("repo"); repoOverride != "" && len(args) == 0 {
				return cmdutil.FlagErrorf("argument required when using the --repo flag")
			}

			if len(args) > 0 {
				opts.SelectorArg = args[0]
			}

			if runF != nil {
				return runF(opts)
			}
			return viewRun(opts)
		},
	}

	cmd.Flags().BoolVarP(&opts.BrowserMode, "web", "w", false, "Open a pull request in the browser")
	cmd.Flags().BoolVarP(&opts.Comments, "comments", "c", false, "View pull request comments")
	cmdutil.AddJSONFlags(cmd, &opts.Exporter, api.PullRequestFields)

	return cmd
}

var defaultFields = []string{
	"url", "number", "title", "state", "body", "author", "autoMergeRequest",
	"isDraft", "maintainerCanModify", "mergeable", "additions", "deletions", "commitsCount",
	"baseRefName", "headRefName", "headRepositoryOwner", "headRepository", "isCrossRepository",
	"reviewRequests", "reviews", "assignees", "labels", "projectCards", "projectItems", "milestone",
	"comments", "reactionGroups", "createdAt", "statusCheckRollup",
}

func viewRun(opts *ViewOptions) error {
	findOptions := shared.FindOptions{
		Selector: opts.SelectorArg,
		Fields:   defaultFields,
		Detector: opts.Detector,
	}
	if opts.BrowserMode {
		findOptions.Fields = []string{"url"}
	} else if opts.Exporter != nil {
		findOptions.Fields = opts.Exporter.Fields()
	}
	pr, baseRepo, err := opts.Finder.Find(findOptions)
	if err != nil {
		return err
	}

	connectedToTerminal := opts.IO.IsStdoutTTY()

	if opts.BrowserMode {
		openURL := pr.URL
		if connectedToTerminal {
			fmt.Fprintf(opts.IO.ErrOut, "Opening %s in your browser.\n", text.DisplayURL(openURL))
		}
		return opts.Browser.Browse(openURL)
	}

	opts.IO.DetectTerminalTheme()
	if err := opts.IO.StartPager(); err == nil {
		defer opts.IO.StopPager()
	} else {
		fmt.Fprintf(opts.IO.ErrOut, "failed to start pager: %v\n", err)
	}

	if opts.Exporter != nil {
		return opts.Exporter.Write(opts.IO, pr)
	}

	if connectedToTerminal {
		return printHumanPrPreview(opts, baseRepo, pr)
	}

	if opts.Comments {
		fmt.Fprint(opts.IO.Out, shared.RawCommentList(pr.Comments, pr.DisplayableReviews()))
		return nil
	}

	return printRawPrPreview(opts.IO, pr)
}

func printRawPrPreview(io *iostreams.IOStreams, pr *api.PullRequest) error {
	out := io.Out
	cs := io.ColorScheme()

	reviewers := prReviewerList(*pr, cs)
	assignees := prAssigneeList(*pr)
	labels := prLabelList(*pr, cs)
	projects := prProjectList(*pr)

	fmt.Fprintf(out, "title:\t%s\n", pr.Title)
	fmt.Fprintf(out, "state:\t%s\n", prStateWithDraft(pr))
	fmt.Fprintf(out, "author:\t%s\n", pr.Author.DisplayName())
	fmt.Fprintf(out, "labels:\t%s\n", labels)
	fmt.Fprintf(out, "assignees:\t%s\n", assignees)
	fmt.Fprintf(out, "reviewers:\t%s\n", reviewers)
	fmt.Fprintf(out, "projects:\t%s\n", projects)
	var milestoneTitle string
	if pr.Milestone != nil {
		milestoneTitle = pr.Milestone.Title
	}
	fmt.Fprintf(out, "milestone:\t%s\n", milestoneTitle)
	fmt.Fprintf(out, "number:\t%d\n", pr.Number)
	fmt.Fprintf(out, "url:\t%s\n", pr.URL)
	fmt.Fprintf(out, "additions:\t%s\n", cs.Green(strconv.Itoa(pr.Additions)))
	fmt.Fprintf(out, "deletions:\t%s\n", cs.Red(strconv.Itoa(pr.Deletions)))
	var autoMerge string
	if pr.AutoMergeRequest == nil {
		autoMerge = "disabled"
	} else {
		autoMerge = fmt.Sprintf("enabled\t%s\t%s",
			pr.AutoMergeRequest.EnabledBy.Login,
			strings.ToLower(pr.AutoMergeRequest.MergeMethod))
	}
	fmt.Fprintf(out, "auto-merge:\t%s\n", autoMerge)

	fmt.Fprintln(out, "--")
	fmt.Fprintln(out, pr.Body)

	return nil
}

func printHumanPrPreview(opts *ViewOptions, baseRepo ghrepo.Interface, pr *api.PullRequest) error {
	out := opts.IO.Out
	cs := opts.IO.ColorScheme()

	// Header (Title and State)
	fmt.Fprintf(out, "%s %s#%d\n", cs.Bold(pr.Title), ghrepo.FullName(baseRepo), pr.Number)
	fmt.Fprintf(out,
		"%s • %s wants to merge %s into %s from %s • %s\n",
		shared.StateTitleWithColor(cs, *pr),
		pr.Author.DisplayName(),
		text.Pluralize(pr.Commits.TotalCount, "commit"),
		pr.BaseRefName,
		pr.HeadRefName,
		text.FuzzyAgo(opts.Now(), pr.CreatedAt),
	)

	// added/removed
	fmt.Fprintf(out,
		"%s %s",
		cs.Green("+"+strconv.Itoa(pr.Additions)),
		cs.Red("-"+strconv.Itoa(pr.Deletions)),
	)

	// checks
	checks := pr.ChecksStatus()
	if summary := shared.PrCheckStatusSummaryWithColor(cs, checks); summary != "" {
		fmt.Fprintf(out, " • %s\n", summary)
	} else {
		fmt.Fprintln(out)
	}

	// Reactions
	if reactions := shared.ReactionGroupList(pr.ReactionGroups); reactions != "" {
		fmt.Fprint(out, reactions)
		fmt.Fprintln(out)
	}

	// Metadata
	if reviewers := prReviewerList(*pr, cs); reviewers != "" {
		fmt.Fprint(out, cs.Bold("Reviewers: "))
		fmt.Fprintln(out, reviewers)
	}
	if assignees := prAssigneeList(*pr); assignees != "" {
		fmt.Fprint(out, cs.Bold("Assignees: "))
		fmt.Fprintln(out, assignees)
	}
	if labels := prLabelList(*pr, cs); labels != "" {
		fmt.Fprint(out, cs.Bold("Labels: "))
		fmt.Fprintln(out, labels)
	}
	if projects := prProjectList(*pr); projects != "" {
		fmt.Fprint(out, cs.Bold("Projects: "))
		fmt.Fprintln(out, projects)
	}
	if pr.Milestone != nil {
		fmt.Fprint(out, cs.Bold("Milestone: "))
		fmt.Fprintln(out, pr.Milestone.Title)
	}

	// Auto-Merge status
	autoMerge := pr.AutoMergeRequest
	if autoMerge != nil {
		var mergeMethod string
		switch autoMerge.MergeMethod {
		case "MERGE":
			mergeMethod = "a merge commit"
		case "REBASE":
			mergeMethod = "rebase and merge"
		case "SQUASH":
			mergeMethod = "squash and merge"
		default:
			mergeMethod = fmt.Sprintf("an unknown merge method (%s)", autoMerge.MergeMethod)
		}
		fmt.Fprintf(out,
			"%s %s by %s, using %s\n",
			cs.Bold("Auto-merge:"),
			cs.Green("enabled"),
			autoMerge.EnabledBy.Login,
			mergeMethod,
		)
	}

	// Body
	var md string
	var err error
	if pr.Body == "" {
		md = fmt.Sprintf("\n  %s\n\n", cs.Muted("No description provided"))
	} else {
		md, err = markdown.Render(pr.Body,
			markdown.WithTheme(opts.IO.TerminalTheme()),
			markdown.WithWrap(opts.IO.TerminalWidth()))
		if err != nil {
			return err
		}
	}
	fmt.Fprintf(out, "\n%s\n", md)

	// Reviews and Comments
	if pr.Comments.TotalCount > 0 || pr.Reviews.TotalCount > 0 {
		preview := !opts.Comments
		comments, err := shared.CommentList(opts.IO, pr.Comments, pr.DisplayableReviews(), preview)
		if err != nil {
			return err
		}
		fmt.Fprint(out, comments)
	}

	// Footer
	fmt.Fprintf(out, cs.Muted("View this pull request on GitHub: %s\n"), pr.URL)

	return nil
}

const (
	requestedReviewState        = "REQUESTED" // This is our own state for review request
	approvedReviewState         = "APPROVED"
	changesRequestedReviewState = "CHANGES_REQUESTED"
	commentedReviewState        = "COMMENTED"
	dismissedReviewState        = "DISMISSED"
	pendingReviewState          = "PENDING"
)

type reviewerState struct {
	Name  string
	State string
}

// formattedReviewerState formats a reviewerState with state color
func formattedReviewerState(cs *iostreams.ColorScheme, reviewer *reviewerState) string {
	var displayState string
	switch reviewer.State {
	case requestedReviewState:
		displayState = cs.Yellow("Requested")
	case approvedReviewState:
		displayState = cs.Green("Approved")
	case changesRequestedReviewState:
		displayState = cs.Red("Changes requested")
	case commentedReviewState, dismissedReviewState:
		// Show "DISMISSED" review as "COMMENTED", since "dismissed" only makes
		// sense when displayed in an events timeline but not in the final tally.
		displayState = "Commented"
	default:
		displayState = text.Title(reviewer.State)
	}

	return fmt.Sprintf("%s (%s)", reviewer.Name, displayState)
}

// prReviewerList generates a reviewer list with their last state
func prReviewerList(pr api.PullRequest, cs *iostreams.ColorScheme) string {
	reviewerStates := parseReviewers(pr)
	reviewers := make([]string, 0, len(reviewerStates))

	sortReviewerStates(reviewerStates)

	for _, reviewer := range reviewerStates {
		reviewers = append(reviewers, formattedReviewerState(cs, reviewer))
	}

	reviewerList := strings.Join(reviewers, ", ")

	return reviewerList
}

const ghostName = "ghost"

// parseReviewers parses given Reviews and ReviewRequests
func parseReviewers(pr api.PullRequest) []*reviewerState {
	reviewerStates := make(map[string]*reviewerState)

	for _, review := range pr.Reviews.Nodes {
		if review.Author.Login != pr.Author.Login {
			name := review.AuthorLogin()
			if name == "" {
				name = ghostName
			}
			reviewerStates[name] = &reviewerState{
				Name:  name,
				State: review.State,
			}
		}
	}

	// Overwrite reviewer's state if a review request for the same reviewer exists.
	for _, reviewRequest := range pr.ReviewRequests.Nodes {
		name := reviewRequest.RequestedReviewer.DisplayName()
		reviewerStates[name] = &reviewerState{
			Name:  name,
			State: requestedReviewState,
		}
	}

	// Convert map to slice for ease of sort
	result := make([]*reviewerState, 0, len(reviewerStates))
	for _, reviewer := range reviewerStates {
		if reviewer.State == pendingReviewState {
			continue
		}
		result = append(result, reviewer)
	}

	return result
}

// sortReviewerStates puts completed reviews before review requests and sorts names alphabetically
func sortReviewerStates(reviewerStates []*reviewerState) {
	sort.Slice(reviewerStates, func(i, j int) bool {
		if reviewerStates[i].State == requestedReviewState &&
			reviewerStates[j].State != requestedReviewState {
			return false
		}
		if reviewerStates[j].State == requestedReviewState &&
			reviewerStates[i].State != requestedReviewState {
			return true
		}

		return reviewerStates[i].Name < reviewerStates[j].Name
	})
}

func prAssigneeList(pr api.PullRequest) string {
	if len(pr.Assignees.Nodes) == 0 {
		return ""
	}

	AssigneeNames := make([]string, 0, len(pr.Assignees.Nodes))
	for _, assignee := range pr.Assignees.Nodes {
		AssigneeNames = append(AssigneeNames, assignee.DisplayName())
	}

	list := strings.Join(AssigneeNames, ", ")
	if pr.Assignees.TotalCount > len(pr.Assignees.Nodes) {
		list += ", …"
	}
	return list
}

func prLabelList(pr api.PullRequest, cs *iostreams.ColorScheme) string {
	if len(pr.Labels.Nodes) == 0 {
		return ""
	}

	// ignore case sort
	sort.SliceStable(pr.Labels.Nodes, func(i, j int) bool {
		return strings.ToLower(pr.Labels.Nodes[i].Name) < strings.ToLower(pr.Labels.Nodes[j].Name)
	})

	labelNames := make([]string, 0, len(pr.Labels.Nodes))
	for _, label := range pr.Labels.Nodes {
		labelNames = append(labelNames, cs.Label(label.Color, label.Name))
	}

	list := strings.Join(labelNames, ", ")
	if pr.Labels.TotalCount > len(pr.Labels.Nodes) {
		list += ", …"
	}
	return list
}

func prProjectList(pr api.PullRequest) string {
	totalCount := pr.ProjectCards.TotalCount + pr.ProjectItems.TotalCount
	count := len(pr.ProjectCards.Nodes) + len(pr.ProjectItems.Nodes)

	if count == 0 {
		return ""
	}

	projectNames := make([]string, 0, len(pr.ProjectCards.Nodes))

	for _, project := range pr.ProjectItems.Nodes {
		colName := project.Status.Name
		if colName == "" {
			colName = "No Status"
		}
		projectNames = append(projectNames, fmt.Sprintf("%s (%s)", project.Project.Title, colName))
	}

	for _, project := range pr.ProjectCards.Nodes {
		if project == nil {
			continue
		}
		colName := project.Column.Name
		if colName == "" {
			colName = "Awaiting triage"
		}
		projectNames = append(projectNames, fmt.Sprintf("%s (%s)", project.Project.Name, colName))
	}

	list := strings.Join(projectNames, ", ")
	if totalCount > count {
		list += ", …"
	}
	return list
}

func prStateWithDraft(pr *api.PullRequest) string {
	if pr.IsDraft && pr.State == "OPEN" {
		return "DRAFT"
	}

	return pr.State
}
