package checks

import (
	"errors"
	"fmt"
	"net/http"
	"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/spf13/cobra"
)

const defaultInterval time.Duration = 10 * time.Second

var prCheckFields = []string{
	"name",
	"state",
	"startedAt",
	"completedAt",
	"link",
	"bucket",
	"event",
	"workflow",
	"description",
}

type ChecksOptions struct {
	HttpClient func() (*http.Client, error)
	IO         *iostreams.IOStreams
	Browser    browser.Browser
	Exporter   cmdutil.Exporter

	Finder   shared.PRFinder
	Detector fd.Detector

	SelectorArg string
	WebMode     bool
	Interval    time.Duration
	Watch       bool
	FailFast    bool
	Required    bool
}

func NewCmdChecks(f *cmdutil.Factory, runF func(*ChecksOptions) error) *cobra.Command {
	var interval int
	opts := &ChecksOptions{
		HttpClient: f.HttpClient,
		IO:         f.IOStreams,
		Browser:    f.Browser,
		Interval:   defaultInterval,
	}

	cmd := &cobra.Command{
		Use:   "checks [<number> | <url> | <branch>]",
		Short: "Show CI status for a single pull request",
		Long: heredoc.Docf(`
			Show CI status for a single pull request.

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

			When the %[1]s--json%[1]s flag is used, it includes a %[1]sbucket%[1]s field, which categorizes
			the %[1]sstate%[1]s field into %[1]spass%[1]s, %[1]sfail%[1]s, %[1]spending%[1]s, %[1]sskipping%[1]s, or %[1]scancel%[1]s.

			Additional exit codes:
				8: Checks pending
		`, "`"),
		Args: cobra.MaximumNArgs(1),
		RunE: func(cmd *cobra.Command, args []string) error {
			opts.Finder = shared.NewFinder(f)

			if opts.Exporter != nil && opts.Watch {
				return cmdutil.FlagErrorf("cannot use `--watch` with `--json` flag")
			}

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

			if opts.FailFast && !opts.Watch {
				return cmdutil.FlagErrorf("cannot use `--fail-fast` flag without `--watch` flag")
			}

			intervalChanged := cmd.Flags().Changed("interval")
			if !opts.Watch && intervalChanged {
				return cmdutil.FlagErrorf("cannot use `--interval` flag without `--watch` flag")
			}

			if intervalChanged {
				var err error
				opts.Interval, err = time.ParseDuration(fmt.Sprintf("%ds", interval))
				if err != nil {
					return cmdutil.FlagErrorf("could not parse `--interval` flag: %w", err)
				}
			}

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

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

			return checksRun(opts)
		},
	}

	cmd.Flags().BoolVarP(&opts.WebMode, "web", "w", false, "Open the web browser to show details about checks")
	cmd.Flags().BoolVarP(&opts.Watch, "watch", "", false, "Watch checks until they finish")
	cmd.Flags().BoolVarP(&opts.FailFast, "fail-fast", "", false, "Exit watch mode on first check failure")
	cmd.Flags().IntVarP(&interval, "interval", "i", 10, "Refresh interval in seconds in watch mode")
	cmd.Flags().BoolVar(&opts.Required, "required", false, "Only show checks that are required")

	cmdutil.AddJSONFlags(cmd, &opts.Exporter, prCheckFields)

	return cmd
}

func checksRunWebMode(opts *ChecksOptions) error {
	findOptions := shared.FindOptions{
		Selector: opts.SelectorArg,
		Fields:   []string{"number"},
	}
	pr, baseRepo, err := opts.Finder.Find(findOptions)
	if err != nil {
		return err
	}

	isTerminal := opts.IO.IsStdoutTTY()
	openURL := ghrepo.GenerateRepoURL(baseRepo, "pull/%d/checks", pr.Number)

	if isTerminal {
		fmt.Fprintf(opts.IO.ErrOut, "Opening %s in your browser.\n", text.DisplayURL(openURL))
	}

	return opts.Browser.Browse(openURL)
}

func checksRun(opts *ChecksOptions) error {
	if opts.WebMode {
		return checksRunWebMode(opts)
	}

	findOptions := shared.FindOptions{
		Selector: opts.SelectorArg,
		Fields:   []string{"number", "headRefName"},
	}

	var pr *api.PullRequest
	pr, repo, findErr := opts.Finder.Find(findOptions)
	if findErr != nil {
		return findErr
	}

	client, clientErr := opts.HttpClient()
	if clientErr != nil {
		return clientErr
	}

	var checks []check
	var counts checkCounts
	var err error
	var includeEvent bool

	if opts.Detector == nil {
		cachedClient := api.NewCachedHTTPClient(client, time.Hour*24)
		opts.Detector = fd.NewDetector(cachedClient, repo.RepoHost())
	}
	if features, featuresErr := opts.Detector.PullRequestFeatures(); featuresErr != nil {
		return featuresErr
	} else {
		includeEvent = features.CheckRunEvent
	}

	checks, counts, err = populateStatusChecks(client, repo, pr, opts.Required, includeEvent)
	if err != nil {
		return err
	}

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

	if opts.Watch {
		opts.IO.StartAlternateScreenBuffer()
	} else {
		// Only start pager in non-watch mode
		if err := opts.IO.StartPager(); err == nil {
			defer opts.IO.StopPager()
		} else {
			fmt.Fprintf(opts.IO.ErrOut, "failed to start pager: %v\n", err)
		}
	}

	// Do not return err until we can StopAlternateScreenBuffer()
	for {
		if counts.Pending != 0 && opts.Watch {
			opts.IO.RefreshScreen()
			cs := opts.IO.ColorScheme()
			fmt.Fprintln(opts.IO.Out, cs.Boldf("Refreshing checks status every %v seconds. Press Ctrl+C to quit.\n", opts.Interval.Seconds()))
		}

		printSummary(opts.IO, counts)
		err = printTable(opts.IO, checks)
		if err != nil {
			break
		}

		if counts.Pending == 0 || !opts.Watch {
			break
		}

		if opts.FailFast && counts.Failed > 0 {
			break
		}

		time.Sleep(opts.Interval)

		checks, counts, err = populateStatusChecks(client, repo, pr, opts.Required, includeEvent)
		if err != nil {
			break
		}
	}

	opts.IO.StopAlternateScreenBuffer()
	if err != nil {
		return err
	}

	if opts.Watch {
		// Print final summary to original screen buffer
		printSummary(opts.IO, counts)
		err = printTable(opts.IO, checks)
		if err != nil {
			return err
		}
	}

	if counts.Failed > 0 {
		return cmdutil.SilentError
	} else if counts.Pending > 0 {
		return cmdutil.PendingError
	}

	return nil
}

func populateStatusChecks(client *http.Client, repo ghrepo.Interface, pr *api.PullRequest, requiredChecks bool, includeEvent bool) ([]check, checkCounts, error) {
	apiClient := api.NewClientFromHTTP(client)

	type response struct {
		Node *api.PullRequest
	}

	query := fmt.Sprintf(`
	query PullRequestStatusChecks($id: ID!, $endCursor: String) {
		node(id: $id) {
			...on PullRequest {
				%s
			}
		}
	}`, api.RequiredStatusCheckRollupGraphQL("$id", "$endCursor", includeEvent))

	variables := map[string]interface{}{
		"id": pr.ID,
	}

	statusCheckRollup := api.CheckContexts{}

	for {
		var resp response
		err := apiClient.GraphQL(repo.RepoHost(), query, variables, &resp)
		if err != nil {
			return nil, checkCounts{}, err
		}

		if len(resp.Node.StatusCheckRollup.Nodes) == 0 {
			return nil, checkCounts{}, errors.New("no commit found on the pull request")
		}

		result := resp.Node.StatusCheckRollup.Nodes[0].Commit.StatusCheckRollup.Contexts
		statusCheckRollup.Nodes = append(
			statusCheckRollup.Nodes,
			result.Nodes...,
		)

		if !result.PageInfo.HasNextPage {
			break
		}
		variables["endCursor"] = result.PageInfo.EndCursor
	}

	if len(statusCheckRollup.Nodes) == 0 {
		return nil, checkCounts{}, fmt.Errorf("no checks reported on the '%s' branch", pr.HeadRefName)
	}

	checks, counts := aggregateChecks(statusCheckRollup.Nodes, requiredChecks)
	if len(checks) == 0 && requiredChecks {
		return checks, counts, fmt.Errorf("no required checks reported on the '%s' branch", pr.HeadRefName)
	}
	return checks, counts, nil
}
