package shared

import (
	"errors"
	"fmt"
	"net/http"
	"net/url"
	"reflect"
	"strings"
	"time"

	"github.com/cli/cli/v2/api"
	"github.com/cli/cli/v2/internal/ghrepo"
	workflowShared "github.com/cli/cli/v2/pkg/cmd/workflow/shared"
	"github.com/cli/cli/v2/pkg/iostreams"
)

type Prompter interface {
	Select(string, string, []string) (int, error)
}

const (
	// Run statuses
	Queued     Status = "queued"
	Completed  Status = "completed"
	InProgress Status = "in_progress"
	Requested  Status = "requested"
	Waiting    Status = "waiting"
	Pending    Status = "pending"

	// Run conclusions
	ActionRequired Conclusion = "action_required"
	Cancelled      Conclusion = "cancelled"
	Failure        Conclusion = "failure"
	Neutral        Conclusion = "neutral"
	Skipped        Conclusion = "skipped"
	Stale          Conclusion = "stale"
	StartupFailure Conclusion = "startup_failure"
	Success        Conclusion = "success"
	TimedOut       Conclusion = "timed_out"

	AnnotationFailure Level = "failure"
	AnnotationWarning Level = "warning"
)

type Status string
type Conclusion string
type Level string

var AllStatuses = []string{
	"queued",
	"completed",
	"in_progress",
	"requested",
	"waiting",
	"pending",
	"action_required",
	"cancelled",
	"failure",
	"neutral",
	"skipped",
	"stale",
	"startup_failure",
	"success",
	"timed_out",
}

var RunFields = []string{
	"name",
	"displayTitle",
	"headBranch",
	"headSha",
	"createdAt",
	"updatedAt",
	"startedAt",
	"attempt",
	"status",
	"conclusion",
	"event",
	"number",
	"databaseId",
	"workflowDatabaseId",
	"workflowName",
	"url",
}

var SingleRunFields = append(RunFields, "jobs")

type Run struct {
	Name           string    `json:"name"` // the semantics of this field are unclear
	DisplayTitle   string    `json:"display_title"`
	CreatedAt      time.Time `json:"created_at"`
	UpdatedAt      time.Time `json:"updated_at"`
	StartedAt      time.Time `json:"run_started_at"`
	Status         Status
	Conclusion     Conclusion
	Event          string
	ID             int64
	workflowName   string // cache column
	WorkflowID     int64  `json:"workflow_id"`
	Number         int64  `json:"run_number"`
	Attempt        uint64 `json:"run_attempt"`
	HeadBranch     string `json:"head_branch"`
	JobsURL        string `json:"jobs_url"`
	HeadCommit     Commit `json:"head_commit"`
	HeadSha        string `json:"head_sha"`
	URL            string `json:"html_url"`
	HeadRepository Repo   `json:"head_repository"`
	Jobs           []Job  `json:"-"` // populated by GetJobs
}

func (r *Run) StartedTime() time.Time {
	if r.StartedAt.IsZero() {
		return r.CreatedAt
	}
	return r.StartedAt
}

func (r *Run) Duration(now time.Time) time.Duration {
	endTime := r.UpdatedAt
	if r.Status != Completed {
		endTime = now
	}
	d := endTime.Sub(r.StartedTime())
	if d < 0 {
		return 0
	}
	return d.Round(time.Second)
}

type Repo struct {
	Owner struct {
		Login string
	}
	Name string
}

type Commit struct {
	Message string
}

// Title is the display title for a run, falling back to the commit subject if unavailable
func (r Run) Title() string {
	if r.DisplayTitle != "" {
		return r.DisplayTitle
	}

	commitLines := strings.Split(r.HeadCommit.Message, "\n")
	if len(commitLines) > 0 {
		return commitLines[0]
	} else {
		return r.HeadSha[0:8]
	}
}

// WorkflowName returns the human-readable name of the workflow that this run belongs to.
// TODO: consider lazy-loading the underlying API data to avoid extra API calls unless necessary
func (r Run) WorkflowName() string {
	return r.workflowName
}

func (r *Run) ExportData(fields []string) map[string]interface{} {
	v := reflect.ValueOf(r).Elem()
	fieldByName := func(v reflect.Value, field string) reflect.Value {
		return v.FieldByNameFunc(func(s string) bool {
			return strings.EqualFold(field, s)
		})
	}
	data := map[string]interface{}{}

	for _, f := range fields {
		switch f {
		case "databaseId":
			data[f] = r.ID
		case "workflowDatabaseId":
			data[f] = r.WorkflowID
		case "workflowName":
			data[f] = r.WorkflowName()
		case "jobs":
			jobs := make([]interface{}, 0, len(r.Jobs))
			for _, j := range r.Jobs {
				steps := make([]interface{}, 0, len(j.Steps))
				for _, s := range j.Steps {
					var stepCompletedAt time.Time
					if !s.CompletedAt.IsZero() {
						stepCompletedAt = s.CompletedAt
					}
					steps = append(steps, map[string]interface{}{
						"name":        s.Name,
						"status":      s.Status,
						"conclusion":  s.Conclusion,
						"number":      s.Number,
						"startedAt":   s.StartedAt,
						"completedAt": stepCompletedAt,
					})
				}
				var jobCompletedAt time.Time
				if !j.CompletedAt.IsZero() {
					jobCompletedAt = j.CompletedAt
				}
				jobs = append(jobs, map[string]interface{}{
					"databaseId":  j.ID,
					"status":      j.Status,
					"conclusion":  j.Conclusion,
					"name":        j.Name,
					"steps":       steps,
					"startedAt":   j.StartedAt,
					"completedAt": jobCompletedAt,
					"url":         j.URL,
				})
			}
			data[f] = jobs
		default:
			sf := fieldByName(v, f)
			data[f] = sf.Interface()
		}
	}

	return data
}

type Job struct {
	ID          int64
	Status      Status
	Conclusion  Conclusion
	Name        string
	Steps       Steps
	StartedAt   time.Time `json:"started_at"`
	CompletedAt time.Time `json:"completed_at"`
	URL         string    `json:"html_url"`
	RunID       int64     `json:"run_id"`
}

type Step struct {
	Name        string
	Status      Status
	Conclusion  Conclusion
	Number      int
	StartedAt   time.Time `json:"started_at"`
	CompletedAt time.Time `json:"completed_at"`
}

type Steps []Step

func (s Steps) Len() int           { return len(s) }
func (s Steps) Less(i, j int) bool { return s[i].Number < s[j].Number }
func (s Steps) Swap(i, j int)      { s[i], s[j] = s[j], s[i] }

type Annotation struct {
	JobName   string
	Message   string
	Path      string
	Level     Level `json:"annotation_level"`
	StartLine int   `json:"start_line"`
}

func AnnotationSymbol(cs *iostreams.ColorScheme, a Annotation) string {
	switch a.Level {
	case AnnotationFailure:
		return cs.FailureIcon()
	case AnnotationWarning:
		return cs.WarningIcon()
	default:
		return "-"
	}
}

type CheckRun struct {
	ID int64
}

var ErrMissingAnnotationsPermissions = errors.New("missing annotations permissions error")

// GetAnnotations fetches annotations from the REST API.
//
// If the job has no annotations, an empty slice is returned.
// If the API returns a 403, a custom ErrMissingAnnotationsPermissions error is returned.
//
// When fine-grained PATs support checks:read permission, we can remove the need for this at the call sites.
func GetAnnotations(client *api.Client, repo ghrepo.Interface, job Job) ([]Annotation, error) {
	var result []*Annotation

	path := fmt.Sprintf("repos/%s/check-runs/%d/annotations", ghrepo.FullName(repo), job.ID)

	err := client.REST(repo.RepoHost(), "GET", path, nil, &result)
	if err != nil {
		var httpError api.HTTPError
		if !errors.As(err, &httpError) {
			return nil, err
		}

		if httpError.StatusCode == http.StatusNotFound {
			return []Annotation{}, nil
		}

		if httpError.StatusCode == http.StatusForbidden {
			return nil, ErrMissingAnnotationsPermissions
		}

		return nil, err
	}

	out := []Annotation{}

	for _, annotation := range result {
		annotation.JobName = job.Name
		out = append(out, *annotation)
	}

	return out, nil
}

func IsFailureState(c Conclusion) bool {
	switch c {
	case ActionRequired, Failure, StartupFailure, TimedOut:
		return true
	default:
		return false
	}
}

func IsSkipped(c Conclusion) bool {
	return c == Skipped
}

type RunsPayload struct {
	TotalCount   int   `json:"total_count"`
	WorkflowRuns []Run `json:"workflow_runs"`
}

type FilterOptions struct {
	Branch     string
	Actor      string
	WorkflowID int64
	// avoid loading workflow name separately and use the provided one
	WorkflowName string
	Status       string
	Event        string
	Created      string
	Commit       string
}

// GetRunsWithFilter fetches 50 runs from the API and filters them in-memory
func GetRunsWithFilter(client *api.Client, repo ghrepo.Interface, opts *FilterOptions, limit int, f func(Run) bool) ([]Run, error) {
	runs, err := GetRuns(client, repo, opts, 50)
	if err != nil {
		return nil, err
	}

	var filtered []Run
	for _, run := range runs.WorkflowRuns {
		if f(run) {
			filtered = append(filtered, run)
		}
		if len(filtered) == limit {
			break
		}
	}

	return filtered, nil
}

func GetRuns(client *api.Client, repo ghrepo.Interface, opts *FilterOptions, limit int) (*RunsPayload, error) {
	path := fmt.Sprintf("repos/%s/actions/runs", ghrepo.FullName(repo))
	if opts != nil && opts.WorkflowID > 0 {
		path = fmt.Sprintf("repos/%s/actions/workflows/%d/runs", ghrepo.FullName(repo), opts.WorkflowID)
	}

	perPage := limit
	if limit > 100 {
		perPage = 100
	}
	path += fmt.Sprintf("?per_page=%d", perPage)
	path += "&exclude_pull_requests=true" // significantly reduces payload size

	if opts != nil {
		if opts.Branch != "" {
			path += fmt.Sprintf("&branch=%s", url.QueryEscape(opts.Branch))
		}
		if opts.Actor != "" {
			path += fmt.Sprintf("&actor=%s", url.QueryEscape(opts.Actor))
		}
		if opts.Status != "" {
			path += fmt.Sprintf("&status=%s", url.QueryEscape(opts.Status))
		}
		if opts.Event != "" {
			path += fmt.Sprintf("&event=%s", url.QueryEscape(opts.Event))
		}
		if opts.Created != "" {
			path += fmt.Sprintf("&created=%s", url.QueryEscape(opts.Created))
		}
		if opts.Commit != "" {
			path += fmt.Sprintf("&head_sha=%s", url.QueryEscape(opts.Commit))
		}
	}

	var result *RunsPayload

pagination:
	for path != "" {
		var response RunsPayload
		var err error
		path, err = client.RESTWithNext(repo.RepoHost(), "GET", path, nil, &response)
		if err != nil {
			return nil, err
		}

		if result == nil {
			result = &response
			if len(result.WorkflowRuns) == limit {
				break pagination
			}
		} else {
			for _, run := range response.WorkflowRuns {
				result.WorkflowRuns = append(result.WorkflowRuns, run)
				if len(result.WorkflowRuns) == limit {
					break pagination
				}
			}
		}
	}

	if opts != nil && opts.WorkflowName != "" {
		for i := range result.WorkflowRuns {
			result.WorkflowRuns[i].workflowName = opts.WorkflowName
		}
	} else if len(result.WorkflowRuns) > 0 {
		if err := preloadWorkflowNames(client, repo, result.WorkflowRuns); err != nil {
			return result, err
		}
	}

	return result, nil
}

func preloadWorkflowNames(client *api.Client, repo ghrepo.Interface, runs []Run) error {
	workflows, err := workflowShared.GetWorkflows(client, repo, 0)
	if err != nil {
		return err
	}

	workflowMap := map[int64]string{}
	for _, wf := range workflows {
		workflowMap[wf.ID] = wf.Name
	}

	for i, run := range runs {
		if _, ok := workflowMap[run.WorkflowID]; !ok {
			// Look up workflow by ID because it may have been deleted
			workflow, err := workflowShared.GetWorkflow(client, repo, run.WorkflowID)
			// If the error is an httpError and it is a 404, this is likely a
			// organization or enterprise ruleset workflow. The user does not
			// have permissions to view the details of the workflow, so we cannot
			// look it up directly without receiving a 404, but it is nonetheless
			// in the workflow run list. To handle this, we set the workflow name
			// to an empty string.
			// Deciding to put this here instead of in GetWorkflow to allow
			// the caller to decide what a 404 means.
			if httpErr, ok := err.(api.HTTPError); ok && httpErr.StatusCode == 404 {
				workflowMap[run.WorkflowID] = ""
				continue
			}
			if err != nil {
				return err
			}
			workflowMap[run.WorkflowID] = workflow.Name
		}
		runs[i].workflowName = workflowMap[run.WorkflowID]
	}
	return nil
}

type JobsPayload struct {
	TotalCount int `json:"total_count"`
	Jobs       []Job
}

func GetJobs(client *api.Client, repo ghrepo.Interface, run *Run, attempt uint64) ([]Job, error) {
	if run.Jobs != nil {
		return run.Jobs, nil
	}

	query := url.Values{}
	query.Set("per_page", "100")
	jobsPath := fmt.Sprintf("%s?%s", run.JobsURL, query.Encode())

	if attempt > 0 {
		jobsPath = fmt.Sprintf("repos/%s/actions/runs/%d/attempts/%d/jobs?%s", ghrepo.FullName(repo), run.ID, attempt, query.Encode())
	}

	for jobsPath != "" {
		var resp JobsPayload
		var err error
		jobsPath, err = client.RESTWithNext(repo.RepoHost(), http.MethodGet, jobsPath, nil, &resp)
		if err != nil {
			run.Jobs = nil
			return nil, err
		}

		run.Jobs = append(run.Jobs, resp.Jobs...)
	}
	return run.Jobs, nil
}

func GetJob(client *api.Client, repo ghrepo.Interface, jobID string) (*Job, error) {
	path := fmt.Sprintf("repos/%s/actions/jobs/%s", ghrepo.FullName(repo), jobID)

	var result Job
	err := client.REST(repo.RepoHost(), "GET", path, nil, &result)
	if err != nil {
		return nil, err
	}

	return &result, nil
}

// SelectRun prompts the user to select a run from a list of runs by using the recommended prompter interface
func SelectRun(p Prompter, cs *iostreams.ColorScheme, runs []Run) (string, error) {
	now := time.Now()

	candidates := []string{}

	for _, run := range runs {
		symbol, _ := Symbol(cs, run.Status, run.Conclusion)
		candidates = append(candidates,
			// TODO truncate commit message, long ones look terrible
			fmt.Sprintf("%s %s, %s [%s] %s", symbol, run.Title(), run.WorkflowName(), run.HeadBranch, preciseAgo(now, run.StartedTime())))
	}

	selected, err := p.Select("Select a workflow run", "", candidates)

	if err != nil {
		return "", err
	}

	return fmt.Sprintf("%d", runs[selected].ID), nil
}

func GetRun(client *api.Client, repo ghrepo.Interface, runID string, attempt uint64) (*Run, error) {
	var result Run

	path := fmt.Sprintf("repos/%s/actions/runs/%s?exclude_pull_requests=true", ghrepo.FullName(repo), runID)

	if attempt > 0 {
		path = fmt.Sprintf("repos/%s/actions/runs/%s/attempts/%d?exclude_pull_requests=true", ghrepo.FullName(repo), runID, attempt)
	}

	err := client.REST(repo.RepoHost(), "GET", path, nil, &result)
	if err != nil {
		return nil, err
	}

	if attempt > 0 {
		result.URL, err = url.JoinPath(result.URL, fmt.Sprintf("/attempts/%d", attempt))
		if err != nil {
			return nil, err
		}
	}

	// Set name to workflow name
	workflow, err := workflowShared.GetWorkflow(client, repo, result.WorkflowID)
	if err != nil {
		return nil, err
	} else {
		result.workflowName = workflow.Name
	}

	return &result, nil
}

type colorFunc func(string) string

func Symbol(cs *iostreams.ColorScheme, status Status, conclusion Conclusion) (string, colorFunc) {
	noColor := func(s string) string { return s }
	if status == Completed {
		switch conclusion {
		case Success:
			return cs.SuccessIconWithColor(noColor), cs.Green
		case Skipped, Neutral:
			return "-", cs.Muted
		default:
			return cs.FailureIconWithColor(noColor), cs.Red
		}
	}

	return "*", cs.Yellow
}

func PullRequestForRun(client *api.Client, repo ghrepo.Interface, run Run) (int, error) {
	type response struct {
		Repository struct {
			PullRequests struct {
				Nodes []struct {
					Number         int
					HeadRepository struct {
						Owner struct {
							Login string
						}
						Name string
					}
				}
			}
		}
		Number int
	}

	variables := map[string]interface{}{
		"owner":       repo.RepoOwner(),
		"repo":        repo.RepoName(),
		"headRefName": run.HeadBranch,
	}

	query := `
		query PullRequestForRun($owner: String!, $repo: String!, $headRefName: String!) {
			repository(owner: $owner, name: $repo) {
				pullRequests(headRefName: $headRefName, first: 1, orderBy: { field: CREATED_AT, direction: DESC }) {
					nodes {
						number
						headRepository {
							owner {
								login
							}
							name
						}
					}
				}
			}
		}`

	var resp response

	err := client.GraphQL(repo.RepoHost(), query, variables, &resp)
	if err != nil {
		return -1, err
	}

	prs := resp.Repository.PullRequests.Nodes
	if len(prs) == 0 {
		return -1, fmt.Errorf("no matching PR found for %s", run.HeadBranch)
	}

	number := -1

	for _, pr := range prs {
		if pr.HeadRepository.Owner.Login == run.HeadRepository.Owner.Login && pr.HeadRepository.Name == run.HeadRepository.Name {
			number = pr.Number
		}
	}

	if number == -1 {
		return number, fmt.Errorf("no matching PR found for %s", run.HeadBranch)
	}

	return number, nil
}

func preciseAgo(now time.Time, createdAt time.Time) string {
	ago := now.Sub(createdAt)

	if ago < 30*24*time.Hour {
		s := ago.Truncate(time.Second).String()
		return fmt.Sprintf("%s ago", s)
	}

	return createdAt.Format("Jan _2, 2006")
}
