package run

import (
	"bytes"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"net/http"
	"net/url"
	"reflect"
	"sort"
	"strings"
	"time"

	"github.com/MakeNowJust/heredoc"
	"github.com/cli/cli/v2/api"
	fd "github.com/cli/cli/v2/internal/featuredetection"
	"github.com/cli/cli/v2/internal/ghrepo"
	"github.com/cli/cli/v2/pkg/cmd/workflow/shared"
	"github.com/cli/cli/v2/pkg/cmdutil"
	"github.com/cli/cli/v2/pkg/iostreams"
	"github.com/spf13/cobra"
	"gopkg.in/yaml.v3"
)

type RunOptions struct {
	HttpClient func() (*http.Client, error)
	IO         *iostreams.IOStreams
	BaseRepo   func() (ghrepo.Interface, error)
	Detector   fd.Detector
	Prompter   iprompter

	Selector  string
	Ref       string
	JSONInput string
	JSON      bool

	MagicFields []string
	RawFields   []string

	Prompt bool
}

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

func NewCmdRun(f *cmdutil.Factory, runF func(*RunOptions) error) *cobra.Command {
	opts := &RunOptions{
		IO:         f.IOStreams,
		HttpClient: f.HttpClient,
		Prompter:   f.Prompter,
	}

	cmd := &cobra.Command{
		Use:   "run [<workflow-id> | <workflow-name>]",
		Short: "Run a workflow by creating a workflow_dispatch event",
		Long: heredoc.Docf(`
			Create a %[1]sworkflow_dispatch%[1]s event for a given workflow.

			This command will trigger GitHub Actions to run a given workflow file. The given workflow file must
			support an %[1]son.workflow_dispatch%[1]s trigger in order to be run in this way.

			If the workflow file supports inputs, they can be specified in a few ways:

			- Interactively
			- Via %[1]s-f/--raw-field%[1]s or %[1]s-F/--field%[1]s flags
			- As JSON, via standard input

			The created workflow run URL will be returned if available.
		`, "`"),
		Example: heredoc.Doc(`
			# Have gh prompt you for what workflow you'd like to run and interactively collect inputs
			$ gh workflow run

			# Run the workflow file 'triage.yml' at the remote's default branch
			$ gh workflow run triage.yml

			# Run the workflow file 'triage.yml' at a specified ref
			$ gh workflow run triage.yml --ref my-branch

			# Run the workflow file 'triage.yml' with command line inputs
			$ gh workflow run triage.yml -f name=scully -f greeting=hello

			# Run the workflow file 'triage.yml' with JSON via standard input
			$ echo '{"name":"scully", "greeting":"hello"}' | gh workflow run triage.yml --json
		`),
		Args: func(cmd *cobra.Command, args []string) error {
			if len(opts.MagicFields)+len(opts.RawFields) > 0 && len(args) == 0 {
				return cmdutil.FlagErrorf("workflow argument required when passing -f or -F")
			}
			return nil
		},
		RunE: func(cmd *cobra.Command, args []string) error {
			// support `-R, --repo` override
			opts.BaseRepo = f.BaseRepo

			inputFieldsPassed := len(opts.MagicFields)+len(opts.RawFields) > 0

			if len(args) > 0 {
				opts.Selector = args[0]
			} else if !opts.IO.CanPrompt() {
				return cmdutil.FlagErrorf("workflow ID, name, or filename required when not running interactively")
			} else {
				opts.Prompt = true
			}

			if opts.JSON && !opts.IO.IsStdinTTY() {
				jsonIn, err := io.ReadAll(opts.IO.In)
				if err != nil {
					return errors.New("failed to read from STDIN")
				}
				opts.JSONInput = string(jsonIn)
			} else if opts.JSON {
				return cmdutil.FlagErrorf("--json specified but nothing on STDIN")
			}

			if opts.Selector == "" {
				if opts.JSONInput != "" {
					return cmdutil.FlagErrorf("workflow argument required when passing JSON")
				}
			} else {
				if opts.JSON && inputFieldsPassed {
					return cmdutil.FlagErrorf("only one of STDIN or -f/-F can be passed")
				}
			}

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

			return runRun(opts)
		},
	}
	cmd.Flags().StringVarP(&opts.Ref, "ref", "r", "", "Branch or tag name which contains the version of the workflow file you'd like to run")
	cmd.Flags().StringArrayVarP(&opts.MagicFields, "field", "F", nil, "Add a string parameter in `key=value` format, respecting @ syntax (see \"gh help api\").")
	cmd.Flags().StringArrayVarP(&opts.RawFields, "raw-field", "f", nil, "Add a string parameter in `key=value` format")
	cmd.Flags().BoolVar(&opts.JSON, "json", false, "Read workflow inputs as JSON via STDIN")

	_ = cmdutil.RegisterBranchCompletionFlags(f.GitClient, cmd, "ref")

	return cmd
}

// TODO copypasta from gh api
func parseField(f string) (string, string, error) {
	idx := strings.IndexRune(f, '=')
	if idx == -1 {
		return f, "", fmt.Errorf("field %q requires a value separated by an '=' sign", f)
	}
	return f[0:idx], f[idx+1:], nil
}

// TODO MODIFIED copypasta from gh api
func magicFieldValue(v string, opts RunOptions) (string, error) {
	if strings.HasPrefix(v, "@") {
		fileBytes, err := opts.IO.ReadUserFile(v[1:])
		if err != nil {
			return "", err
		}
		return string(fileBytes), nil
	}

	return v, nil
}

// TODO copypasta from gh api
func parseFields(opts RunOptions) (map[string]string, error) {
	params := make(map[string]string)
	for _, f := range opts.RawFields {
		key, value, err := parseField(f)
		if err != nil {
			return params, err
		}
		params[key] = value
	}
	for _, f := range opts.MagicFields {
		key, strValue, err := parseField(f)
		if err != nil {
			return params, err
		}
		value, err := magicFieldValue(strValue, opts)
		if err != nil {
			return params, fmt.Errorf("error parsing %q value: %w", key, err)
		}
		params[key] = value
	}
	return params, nil
}

type InputAnswer struct {
	providedInputs map[string]string
}

func (ia *InputAnswer) WriteAnswer(name string, value interface{}) error {
	if s, ok := value.(string); ok {
		ia.providedInputs[name] = s
		return nil
	}

	// TODO i hate this; this is to make tests work:
	if rv, ok := value.(reflect.Value); ok {
		ia.providedInputs[name] = rv.String()
		return nil
	}

	return fmt.Errorf("unexpected value type: %v", value)
}

func collectInputs(p iprompter, yamlContent []byte) (map[string]string, error) {
	inputs, err := findInputs(yamlContent)
	if err != nil {
		return nil, err
	}

	providedInputs := map[string]string{}

	if len(inputs) == 0 {
		return providedInputs, nil
	}

	for _, input := range inputs {
		var answer string

		if input.Type == "choice" {
			name := input.Name
			if input.Required {
				name += " (required)"
			}
			selected, err := p.Select(name, input.Default, input.Options)
			if err != nil {
				return nil, err
			}
			answer = input.Options[selected]
		} else if input.Required {
			for answer == "" {
				answer, err = p.Input(input.Name+" (required)", input.Default)
				if err != nil {
					break
				}
			}
		} else {
			answer, err = p.Input(input.Name, input.Default)
		}

		if err != nil {
			return nil, err
		}

		providedInputs[input.Name] = answer
	}
	return providedInputs, nil
}

func runRun(opts *RunOptions) error {
	c, err := opts.HttpClient()
	if err != nil {
		return fmt.Errorf("could not build http client: %w", err)
	}
	client := api.NewClientFromHTTP(c)

	repo, err := opts.BaseRepo()
	if err != nil {
		return err
	}

	if opts.Detector == nil {
		cachedClient := api.NewCachedHTTPClient(c, time.Hour*24)
		opts.Detector = fd.NewDetector(cachedClient, repo.RepoHost())
	}

	ref := opts.Ref

	if ref == "" {
		ref, err = api.RepoDefaultBranch(client, repo)
		if err != nil {
			return fmt.Errorf("unable to determine default branch for %s: %w", ghrepo.FullName(repo), err)
		}
	}

	states := []shared.WorkflowState{shared.Active}
	workflow, err := shared.ResolveWorkflow(opts.Prompter,
		opts.IO, client, repo, opts.Prompt, opts.Selector, states)
	if err != nil {
		var fae shared.FilteredAllError
		if errors.As(err, &fae) {
			return errors.New("no workflows are enabled on this repository")
		}
		return err
	}

	providedInputs := map[string]string{}

	if len(opts.MagicFields)+len(opts.RawFields) > 0 {
		providedInputs, err = parseFields(*opts)
		if err != nil {
			return err
		}
	} else if opts.JSONInput != "" {
		err := json.Unmarshal([]byte(opts.JSONInput), &providedInputs)
		if err != nil {
			return fmt.Errorf("could not parse provided JSON: %w", err)
		}
	} else if opts.Prompt {
		yamlContent, err := shared.GetWorkflowContent(client, repo, *workflow, ref)
		if err != nil {
			return fmt.Errorf("unable to fetch workflow file content: %w", err)
		}
		providedInputs, err = collectInputs(opts.Prompter, yamlContent)
		if err != nil {
			return err
		}
	}

	features, err := opts.Detector.ActionsFeatures()
	if err != nil {
		return err
	}

	path := fmt.Sprintf("repos/%s/%s/actions/workflows/%d/dispatches", url.PathEscape(repo.RepoOwner()), url.PathEscape(repo.RepoName()), workflow.ID)

	requestBody := map[string]interface{}{
		"ref":    ref,
		"inputs": providedInputs,
	}

	// TODO workflowDispatchRunDetailsCleanup
	// We will have to always set the `return_run_details` field to true, unless
	// we opt into the the new REST API version, which will probably return the
	// details by default.
	if features.DispatchRunDetails {
		requestBody["return_run_details"] = true
	}

	requestByte, err := json.Marshal(requestBody)
	if err != nil {
		return fmt.Errorf("failed to serialize workflow inputs: %w", err)
	}

	body := bytes.NewReader(requestByte)

	var response struct {
		WorkflowRunID int64  `json:"workflow_run_id"`
		RunURL        string `json:"run_url"`
		HtmlURL       string `json:"html_url"`
	}

	// Note that the workflow dispatch endpoint used to return 204 No Content
	// (with no body, obviously). Now it's possible for the endpoint to also
	// return 200 OK with created run details. So, we have to handle both cases
	// because old GHE versions still return 204. Even on github.com, we
	// may still get 204 for any reason.
	//
	// Our REST client library is smart enough to ignore JSON unmarshal when it
	// receives 204, so we're safe here anyway.
	//
	// As a related note, the new REST API version (which will come with breaking
	// changes) will probably default to return 200 + run details.
	err = client.REST(repo.RepoHost(), "POST", path, body, &response)
	if err != nil {
		return fmt.Errorf("could not create workflow dispatch event: %w", err)
	}

	if opts.IO.IsStdoutTTY() {
		cs := opts.IO.ColorScheme()
		fmt.Fprintf(opts.IO.Out, "%s Created workflow_dispatch event for %s at %s\n",
			cs.SuccessIcon(), cs.Cyan(workflow.Base()), cs.Bold(ref))

		if response.HtmlURL != "" {
			fmt.Fprintln(opts.IO.Out, response.HtmlURL)
		}

		fmt.Fprintln(opts.IO.Out)

		if response.WorkflowRunID != 0 {
			fmt.Fprintf(opts.IO.Out, "To see the created workflow run, try: %s\n",
				cs.Boldf("gh run view %d", response.WorkflowRunID))
		}

		fmt.Fprintf(opts.IO.Out, "To see runs for this workflow, try: %s\n",
			cs.Boldf("gh run list --workflow=%q", workflow.Base()))
	} else {
		if response.HtmlURL != "" {
			fmt.Fprintln(opts.IO.Out, response.HtmlURL)
		}
	}

	return nil
}

type WorkflowInput struct {
	Name        string
	Required    bool
	Default     string
	Description string
	Type        string
	Options     []string
}

func findInputs(yamlContent []byte) ([]WorkflowInput, error) {
	var rootNode yaml.Node
	err := yaml.Unmarshal(yamlContent, &rootNode)
	if err != nil {
		return nil, fmt.Errorf("unable to parse workflow YAML: %w", err)
	}

	if len(rootNode.Content) != 1 {
		return nil, errors.New("invalid YAML file")
	}

	var onKeyNode *yaml.Node
	var dispatchKeyNode *yaml.Node
	var inputsKeyNode *yaml.Node
	var inputsMapNode *yaml.Node

	// TODO this is pretty hideous
	for _, node := range rootNode.Content[0].Content {
		if onKeyNode != nil {
			if node.Kind == yaml.MappingNode {
				for _, node := range node.Content {
					if dispatchKeyNode != nil {
						for _, node := range node.Content {
							if inputsKeyNode != nil {
								inputsMapNode = node
								break
							}
							if node.Value == "inputs" {
								inputsKeyNode = node
							}
						}
						break
					}
					if node.Value == "workflow_dispatch" {
						dispatchKeyNode = node
					}
				}
			} else if node.Kind == yaml.SequenceNode {
				for _, node := range node.Content {
					if node.Value == "workflow_dispatch" {
						dispatchKeyNode = node
						break
					}
				}
			} else if node.Kind == yaml.ScalarNode {
				if node.Value == "workflow_dispatch" {
					dispatchKeyNode = node
					break
				}
			}
			break
		}
		if strings.EqualFold(node.Value, "on") {
			onKeyNode = node
		}
	}

	if onKeyNode == nil {
		return nil, errors.New("invalid workflow: no 'on' key")
	}

	if dispatchKeyNode == nil {
		return nil, errors.New("unable to manually run a workflow without a workflow_dispatch event")
	}

	out := []WorkflowInput{}

	m := map[string]WorkflowInput{}

	if inputsKeyNode == nil || inputsMapNode == nil {
		return out, nil
	}

	err = inputsMapNode.Decode(&m)
	if err != nil {
		return nil, fmt.Errorf("could not decode workflow inputs: %w", err)
	}

	for name, input := range m {
		if input.Type == "choice" && len(input.Options) == 0 {
			return nil, fmt.Errorf("workflow input %q is of type choice, but has no options", name)
		}
		out = append(out, WorkflowInput{
			Name:        name,
			Default:     input.Default,
			Description: input.Description,
			Required:    input.Required,
			Options:     input.Options,
			Type:        input.Type,
		})
	}

	sort.Slice(out, func(i, j int) bool {
		return out[i].Name < out[j].Name
	})

	return out, nil
}
