package extension

import (
	"errors"
	"fmt"
	gio "io"
	"os"
	"path/filepath"
	"strings"
	"time"

	"github.com/MakeNowJust/heredoc"
	"github.com/cli/cli/v2/api"
	"github.com/cli/cli/v2/git"
	"github.com/cli/cli/v2/internal/featuredetection"
	"github.com/cli/cli/v2/internal/ghrepo"
	"github.com/cli/cli/v2/internal/tableprinter"
	"github.com/cli/cli/v2/internal/text"
	"github.com/cli/cli/v2/pkg/cmd/extension/browse"
	"github.com/cli/cli/v2/pkg/cmdutil"
	"github.com/cli/cli/v2/pkg/extensions"
	"github.com/cli/cli/v2/pkg/search"
	"github.com/spf13/cobra"
)

var alreadyInstalledError = errors.New("alreadyInstalledError")

func NewCmdExtension(f *cmdutil.Factory) *cobra.Command {
	m := f.ExtensionManager
	io := f.IOStreams
	gc := f.GitClient
	prompter := f.Prompter
	config := f.Config
	browser := f.Browser
	httpClient := f.HttpClient

	extCmd := cobra.Command{
		Use:   "extension",
		Short: "Manage gh extensions",
		Long: heredoc.Docf(`
			GitHub CLI extensions are repositories that provide additional gh commands.

			The name of the extension repository must start with %[1]sgh-%[1]s and it must contain an
			executable of the same name. All arguments passed to the %[1]sgh <extname>%[1]s invocation
			will be forwarded to the %[1]sgh-<extname>%[1]s executable of the extension.

			An extension cannot override any of the core gh commands. If an extension name conflicts
			with a core gh command, you can use %[1]sgh extension exec <extname>%[1]s.

			When an extension is executed, gh will check for new versions once every 24 hours and display
			an upgrade notice. See %[1]sgh help environment%[1]s for information on disabling extension notices.

			For the list of available extensions, see <https://github.com/topics/gh-extension>.
		`, "`"),
		Aliases: []string{"extensions", "ext"},
	}

	upgradeFunc := func(name string, flagForce bool) error {
		cs := io.ColorScheme()
		err := m.Upgrade(name, flagForce)
		if err != nil {
			if name != "" {
				fmt.Fprintf(io.ErrOut, "%s Failed upgrading extension %s: %s\n", cs.FailureIcon(), name, err)
			} else if errors.Is(err, noExtensionsInstalledError) {
				return cmdutil.NewNoResultsError("no installed extensions found")
			} else {
				fmt.Fprintf(io.ErrOut, "%s Failed upgrading extensions\n", cs.FailureIcon())
			}
			return cmdutil.SilentError
		}

		if io.IsStdoutTTY() {
			fmt.Fprintf(io.Out, "%s Successfully checked extension upgrades\n", cs.SuccessIcon())
		}

		return nil
	}

	extCmd.AddCommand(
		func() *cobra.Command {
			query := search.Query{
				Kind: search.KindRepositories,
			}
			qualifiers := search.Qualifiers{
				Topic: []string{"gh-extension"},
			}
			var order string
			var sort string
			var webMode bool
			var exporter cmdutil.Exporter

			cmd := &cobra.Command{
				Use:   "search [<query>]",
				Short: "Search extensions to the GitHub CLI",
				Long: heredoc.Docf(`
					Search for gh extensions.

					With no arguments, this command prints out the first 30 extensions
					available to install sorted by number of stars. More extensions can
					be fetched by specifying a higher limit with the %[1]s--limit%[1]s flag.

					When connected to a terminal, this command prints out three columns.
					The first has a ✓ if the extension is already installed locally. The
					second is the full name of the extension repository in %[1]sOWNER/REPO%[1]s
					format. The third is the extension's description.

					When not connected to a terminal, the ✓ character is rendered as the
					word "installed" but otherwise the order and content of the columns
					are the same.

					This command behaves similarly to %[1]sgh search repos%[1]s but does not
					support as many search qualifiers. For a finer grained search of
					extensions, try using:

						gh search repos --topic "gh-extension"

					and adding qualifiers as needed. See %[1]sgh help search repos%[1]s to learn
					more about repository search.

					For listing just the extensions that are already installed locally,
					see:

						gh ext list
				`, "`"),
				Example: heredoc.Doc(`
					# List the first 30 extensions sorted by star count, descending
					$ gh ext search

					# List more extensions
					$ gh ext search --limit 300

					# List extensions matching the term "branch"
					$ gh ext search branch

					# List extensions owned by organization "github"
					$ gh ext search --owner github

					# List extensions, sorting by recently updated, ascending
					$ gh ext search --sort updated --order asc

					# List extensions, filtering by license
					$ gh ext search --license MIT

					# Open search results in the browser
					$ gh ext search -w
				`),
				RunE: func(cmd *cobra.Command, args []string) error {
					cfg, err := config()
					if err != nil {
						return err
					}
					client, err := httpClient()
					if err != nil {
						return err
					}

					if cmd.Flags().Changed("order") {
						query.Order = order
					}
					if cmd.Flags().Changed("sort") {
						query.Sort = sort
					}

					query.Keywords = args
					query.Qualifiers = qualifiers

					host, _ := cfg.Authentication().DefaultHost()
					detector := featuredetection.NewDetector(client, host)
					searcher := search.NewSearcher(client, host, detector)

					if webMode {
						url := searcher.URL(query)
						if io.IsStdoutTTY() {
							fmt.Fprintf(io.ErrOut, "Opening %s in your browser.\n", text.DisplayURL(url))
						}
						return browser.Browse(url)
					}

					io.StartProgressIndicator()
					result, err := searcher.Repositories(query)
					io.StopProgressIndicator()
					if err != nil {
						return err
					}

					if exporter != nil {
						return exporter.Write(io, result.Items)
					}

					if io.IsStdoutTTY() {
						if len(result.Items) == 0 {
							return errors.New("no extensions found")
						}
						fmt.Fprintf(io.Out, "Showing %d of %d extensions\n", len(result.Items), result.Total)
						fmt.Fprintln(io.Out)
					}

					cs := io.ColorScheme()
					installedExts := m.List()

					isInstalled := func(repo search.Repository) bool {
						searchRepo, err := ghrepo.FromFullName(repo.FullName)
						if err != nil {
							return false
						}
						for _, e := range installedExts {
							// TODO consider a Repo() on Extension interface
							if u, err := git.ParseURL(e.URL()); err == nil {
								if r, err := ghrepo.FromURL(u); err == nil {
									if ghrepo.IsSame(searchRepo, r) {
										return true
									}
								}
							}
						}
						return false
					}

					tp := tableprinter.New(io, tableprinter.WithHeader("", "REPO", "DESCRIPTION"))
					for _, repo := range result.Items {
						if !strings.HasPrefix(repo.Name, "gh-") {
							continue
						}

						installed := ""
						if isInstalled(repo) {
							if io.IsStdoutTTY() {
								installed = "✓"
							} else {
								installed = "installed"
							}
						}

						tp.AddField(installed, tableprinter.WithColor(cs.Green))
						tp.AddField(repo.FullName, tableprinter.WithColor(cs.Bold))
						tp.AddField(repo.Description)
						tp.EndRow()
					}

					return tp.Render()
				},
			}

			// Output flags
			cmd.Flags().BoolVarP(&webMode, "web", "w", false, "Open the search query in the web browser")
			cmdutil.AddJSONFlags(cmd, &exporter, search.RepositoryFields)

			// Query parameter flags
			cmd.Flags().IntVarP(&query.Limit, "limit", "L", 30, "Maximum number of extensions to fetch")
			cmdutil.StringEnumFlag(cmd, &order, "order", "", "desc", []string{"asc", "desc"}, "Order of repositories returned, ignored unless '--sort' flag is specified")
			cmdutil.StringEnumFlag(cmd, &sort, "sort", "", "best-match", []string{"forks", "help-wanted-issues", "stars", "updated"}, "Sort fetched repositories")

			// Qualifier flags
			cmd.Flags().StringSliceVar(&qualifiers.License, "license", nil, "Filter based on license type")
			cmd.Flags().StringSliceVar(&qualifiers.User, "owner", nil, "Filter on owner")

			return cmd
		}(),
		&cobra.Command{
			Use:     "list",
			Short:   "List installed extension commands",
			Aliases: []string{"ls"},
			Args:    cobra.NoArgs,
			RunE: func(cmd *cobra.Command, args []string) error {
				cmds := m.List()
				if len(cmds) == 0 {
					return cmdutil.NewNoResultsError("no installed extensions found")
				}
				cs := io.ColorScheme()
				t := tableprinter.New(io, tableprinter.WithHeader("NAME", "REPO", "VERSION"))
				for _, c := range cmds {
					// TODO consider a Repo() on Extension interface
					var repo string
					if u, err := git.ParseURL(c.URL()); err == nil {
						if r, err := ghrepo.FromURL(u); err == nil {
							repo = ghrepo.FullName(r)
						}
					}

					t.AddField(fmt.Sprintf("gh %s", c.Name()))
					t.AddField(repo)
					version := displayExtensionVersion(c, c.CurrentVersion())
					if c.IsPinned() {
						t.AddField(version, tableprinter.WithColor(cs.Cyan))
					} else {
						t.AddField(version)
					}

					t.EndRow()
				}
				return t.Render()
			},
		},
		func() *cobra.Command {
			var forceFlag bool
			var pinFlag string
			cmd := &cobra.Command{
				Use:   "install <repository>",
				Short: "Install a gh extension from a repository",
				Long: heredoc.Docf(`
					Install a GitHub CLI extension from a GitHub or local repository.

					For GitHub repositories, the repository argument can be specified in
					%[1]sOWNER/REPO%[1]s format or as a full repository URL.
					The URL format is useful when the repository is not hosted on %[1]sgithub.com%[1]s.

					For remote repositories, the GitHub CLI first looks for the release artifacts assuming
					that it's a binary extension i.e. prebuilt binaries provided as part of the release.
					In the absence of a release, the repository itself is cloned assuming that it's a
					script extension i.e. prebuilt executable or script exists on its root.

					The %[1]s--pin%[1]s flag may be used to specify a tag or commit for binary and script
					extensions respectively, the latest version is used otherwise.

					For local repositories, often used while developing extensions, use %[1]s.%[1]s as the
					value of the repository argument. Note the following:

					- After installing an extension from a locally cloned repository, the GitHub CLI will
					manage this extension as a symbolic link (or equivalent mechanism on Windows) pointing
					to an executable file with the same name as the repository in the repository's root.
					For example, if the repository is named %[1]sgh-foobar%[1]s, the symbolic link will point
					to %[1]sgh-foobar%[1]s in the extension repository's root.
					- When executing the extension, the GitHub CLI will run the executable file found
					by following the symbolic link. If no executable file is found, the extension
					will fail to execute.
					- If the extension is precompiled, the executable file must be built manually and placed
					in the repository's root.

					For the list of available extensions, see <https://github.com/topics/gh-extension>.
				`, "`"),
				Example: heredoc.Doc(`
					# Install an extension from a remote repository hosted on GitHub
					$ gh extension install owner/gh-extension

					# Install an extension from a remote repository via full URL
					$ gh extension install https://my.ghes.com/owner/gh-extension

					# Install an extension from a local repository in the current working directory
					$ gh extension install .
				`),
				Args: cmdutil.MinimumArgs(1, "must specify a repository to install from"),
				RunE: func(cmd *cobra.Command, args []string) error {
					if args[0] == "." {
						if pinFlag != "" {
							return fmt.Errorf("local extensions cannot be pinned")
						}
						wd, err := os.Getwd()
						if err != nil {
							return err
						}
						_, err = checkValidExtension(cmd.Root(), m, filepath.Base(wd), "")
						if err != nil {
							return err
						}

						err = m.InstallLocal(wd)
						var ErrExtensionExecutableNotFound *ErrExtensionExecutableNotFound
						if errors.As(err, &ErrExtensionExecutableNotFound) {
							cs := io.ColorScheme()
							if io.IsStdoutTTY() {
								fmt.Fprintf(io.ErrOut, "%s %s", cs.WarningIcon(), ErrExtensionExecutableNotFound.Error())
							}
							return nil
						}
						return err
					}

					repo, err := ghrepo.FromFullName(args[0])
					if err != nil {
						return err
					}

					cs := io.ColorScheme()

					if ext, err := checkValidExtension(cmd.Root(), m, repo.RepoName(), repo.RepoOwner()); err != nil {
						// If an existing extension was found and --force was specified, attempt to upgrade.
						if forceFlag && ext != nil {
							return upgradeFunc(ext.Name(), forceFlag)
						}

						if errors.Is(err, alreadyInstalledError) {
							fmt.Fprintf(io.ErrOut, "%s Extension %s is already installed\n", cs.WarningIcon(), ghrepo.FullName(repo))
							return nil
						}

						return err
					}

					io.StartProgressIndicator()
					err = m.Install(repo, pinFlag)
					io.StopProgressIndicator()

					if err != nil {
						if errors.Is(err, releaseNotFoundErr) {
							return fmt.Errorf("%s Could not find a release of %s for %s",
								cs.FailureIcon(), args[0], cs.Cyan(pinFlag))
						} else if errors.Is(err, commitNotFoundErr) {
							return fmt.Errorf("%s %s does not exist in %s",
								cs.FailureIcon(), cs.Cyan(pinFlag), args[0])
						} else if errors.Is(err, repositoryNotFoundErr) {
							return fmt.Errorf("%s Could not find extension '%s' on host %s",
								cs.FailureIcon(), args[0], repo.RepoHost())
						}
						return err
					}

					if io.IsStdoutTTY() {
						fmt.Fprintf(io.Out, "%s Installed extension %s\n", cs.SuccessIcon(), args[0])
						if pinFlag != "" {
							fmt.Fprintf(io.Out, "%s Pinned extension at %s\n", cs.SuccessIcon(), cs.Cyan(pinFlag))
						}
					}
					return nil
				},
			}
			cmd.Flags().BoolVar(&forceFlag, "force", false, "Force upgrade extension, or ignore if latest already installed")
			cmd.Flags().StringVar(&pinFlag, "pin", "", "Pin extension to a release tag or commit ref")
			cmdutil.DisableAuthCheck(cmd)
			return cmd
		}(),
		func() *cobra.Command {
			var flagAll bool
			var flagForce bool
			var flagDryRun bool
			cmd := &cobra.Command{
				Use:   "upgrade {<name> | --all}",
				Short: "Upgrade installed extensions",
				Args: func(cmd *cobra.Command, args []string) error {
					if len(args) == 0 && !flagAll {
						return cmdutil.FlagErrorf("specify an extension to upgrade or `--all`")
					}
					if len(args) > 0 && flagAll {
						return cmdutil.FlagErrorf("cannot use `--all` with extension name")
					}
					if len(args) > 1 {
						return cmdutil.FlagErrorf("too many arguments")
					}
					return nil
				},
				RunE: func(cmd *cobra.Command, args []string) error {
					var name string
					if len(args) > 0 {
						name = normalizeExtensionSelector(args[0])
					}
					if flagDryRun {
						m.EnableDryRunMode()
					}
					return upgradeFunc(name, flagForce)
				},
			}
			cmd.Flags().BoolVar(&flagAll, "all", false, "Upgrade all extensions")
			cmd.Flags().BoolVar(&flagForce, "force", false, "Force upgrade extension")
			cmd.Flags().BoolVar(&flagDryRun, "dry-run", false, "Only display upgrades")
			return cmd
		}(),
		&cobra.Command{
			Use:   "remove <name>",
			Short: "Remove an installed extension",
			Args:  cobra.ExactArgs(1),
			RunE: func(cmd *cobra.Command, args []string) error {
				extName := normalizeExtensionSelector(args[0])
				if err := m.Remove(extName); err != nil {
					return err
				}
				if io.IsStdoutTTY() {
					cs := io.ColorScheme()
					fmt.Fprintf(io.Out, "%s Removed extension %s\n", cs.SuccessIcon(), extName)
				}
				return nil
			},
		},
		func() *cobra.Command {
			var debug bool
			var singleColumn bool
			cmd := &cobra.Command{
				Use:   "browse",
				Short: "Enter a UI for browsing, adding, and removing extensions",
				Long: heredoc.Docf(`
					This command will take over your terminal and run a fully interactive
					interface for browsing, adding, and removing gh extensions. A terminal
					width greater than 100 columns is recommended.

					To learn how to control this interface, press %[1]s?%[1]s after running to see
					the help text.

					Press %[1]sq%[1]s to quit.

					Running this command with %[1]s--single-column%[1]s should make this command
					more intelligible for users who rely on assistive technology like screen
					readers or high zoom.

					For a more traditional way to discover extensions, see:

						gh ext search

					along with %[1]sgh ext install%[1]s, %[1]sgh ext remove%[1]s, and %[1]sgh repo view%[1]s.
				`, "`"),
				Args: cobra.NoArgs,
				RunE: func(cmd *cobra.Command, args []string) error {
					if !io.CanPrompt() {
						return errors.New("this command runs an interactive UI and needs to be run in a terminal")
					}
					cfg, err := config()
					if err != nil {
						return err
					}
					host, _ := cfg.Authentication().DefaultHost()
					client, err := f.HttpClient()
					if err != nil {
						return err
					}

					detector := featuredetection.NewDetector(client, host)
					searcher := search.NewSearcher(api.NewCachedHTTPClient(client, time.Hour*24), host, detector)

					gc.Stderr = gio.Discard

					opts := browse.ExtBrowseOpts{
						Cmd:          cmd,
						IO:           io,
						Browser:      browser,
						Searcher:     searcher,
						Em:           m,
						Client:       client,
						Cfg:          cfg,
						Debug:        debug,
						SingleColumn: singleColumn,
					}

					return browse.ExtBrowse(opts)
				},
			}
			cmd.Flags().BoolVar(&debug, "debug", false, "Log to /tmp/extBrowse-*")
			cmd.Flags().BoolVarP(&singleColumn, "single-column", "s", false, "Render TUI with only one column of text")
			return cmd
		}(),
		&cobra.Command{
			Use:   "exec <name> [args]",
			Short: "Execute an installed extension",
			Long: heredoc.Docf(`
				Execute an extension using the short name. For example, if the extension repository is
				%[1]sowner/gh-extension%[1]s, you should pass %[1]sextension%[1]s. You can use this command when
				the short name conflicts with a core gh command.

				All arguments after the extension name will be forwarded to the executable
				of the extension.
			`, "`"),
			Example: heredoc.Doc(`
				# Execute a label extension instead of the core gh label command
				$ gh extension exec label
			`),
			Args:               cobra.MinimumNArgs(1),
			DisableFlagParsing: true,
			RunE: func(cmd *cobra.Command, args []string) error {
				if found, err := m.Dispatch(args, io.In, io.Out, io.ErrOut); !found {
					return fmt.Errorf("extension %q not found", args[0])
				} else {
					return err
				}
			},
		},
		func() *cobra.Command {
			promptCreate := func() (string, extensions.ExtTemplateType, error) {
				extName, err := prompter.Input("Extension name:", "")
				if err != nil {
					return extName, -1, err
				}
				options := []string{"Script (Bash, Ruby, Python, etc)", "Go", "Other Precompiled (C++, Rust, etc)"}
				extTmplType, err := prompter.Select("What kind of extension?",
					options[0],
					options)
				return extName, extensions.ExtTemplateType(extTmplType), err
			}
			var flagType string
			cmd := &cobra.Command{
				Use:   "create [<name>]",
				Short: "Create a new extension",
				Example: heredoc.Doc(`
					# Use interactively
					$ gh extension create

					# Create a script-based extension
					$ gh extension create foobar

					# Create a Go extension
					$ gh extension create --precompiled=go foobar

					# Create a non-Go precompiled extension
					$ gh extension create --precompiled=other foobar
				`),
				Args: cobra.MaximumNArgs(1),
				RunE: func(cmd *cobra.Command, args []string) error {
					if cmd.Flags().Changed("precompiled") {
						if flagType != "go" && flagType != "other" {
							return cmdutil.FlagErrorf("value for --precompiled must be 'go' or 'other'. Got '%s'", flagType)
						}
					}
					var extName string
					var err error
					tmplType := extensions.GitTemplateType
					if len(args) == 0 {
						if io.IsStdoutTTY() {
							extName, tmplType, err = promptCreate()
							if err != nil {
								return fmt.Errorf("could not prompt: %w", err)
							}
						}
					} else {
						extName = args[0]
						if flagType == "go" {
							tmplType = extensions.GoBinTemplateType
						} else if flagType == "other" {
							tmplType = extensions.OtherBinTemplateType
						}
					}

					var fullName string

					if strings.HasPrefix(extName, "gh-") {
						fullName = extName
						extName = extName[3:]
					} else {
						fullName = "gh-" + extName
					}

					cs := io.ColorScheme()

					commitIcon := cs.SuccessIcon()
					if err := m.Create(fullName, tmplType); err != nil {
						if errors.Is(err, ErrInitialCommitFailed) {
							commitIcon = cs.FailureIcon()
						} else {
							return err
						}
					}

					if !io.IsStdoutTTY() {
						return nil
					}

					var goBinChecks string

					steps := fmt.Sprintf(
						"- run 'cd %[1]s; gh extension install .; gh %[2]s' to see your new extension in action",
						fullName, extName)

					if tmplType == extensions.GoBinTemplateType {
						goBinChecks = heredoc.Docf(`
						%[1]s Downloaded Go dependencies
						%[1]s Built %[2]s binary
						`, cs.SuccessIcon(), fullName)
						steps = heredoc.Docf(`
						- run 'cd %[1]s; gh extension install .; gh %[2]s' to see your new extension in action
						- run 'go build && gh %[2]s' to see changes in your code as you develop`, fullName, extName)
					} else if tmplType == extensions.OtherBinTemplateType {
						steps = heredoc.Docf(`
						- run 'cd %[1]s; gh extension install .' to install your extension locally
						- fill in script/build.sh with your compilation script for automated builds
						- compile a %[1]s binary locally and run 'gh %[2]s' to see changes`, fullName, extName)
					}
					link := "https://docs.github.com/github-cli/github-cli/creating-github-cli-extensions"
					out := heredoc.Docf(`
						%[1]s Created directory %[2]s
						%[1]s Initialized git repository
						%[7]s Made initial commit
						%[1]s Set up extension scaffolding
						%[6]s
						%[2]s is ready for development!

						%[4]s
						%[5]s
						- run 'gh repo create' to share your extension with others

						For more information on writing extensions:
						%[3]s
					`, cs.SuccessIcon(), fullName, link, cs.Bold("Next Steps"), steps, goBinChecks, commitIcon)
					fmt.Fprint(io.Out, out)
					return nil
				},
			}
			cmd.Flags().StringVar(&flagType, "precompiled", "", "Create a precompiled extension. Possible values: go, other")
			return cmd
		}(),
	)

	return &extCmd
}

func checkValidExtension(rootCmd *cobra.Command, m extensions.ExtensionManager, extName, extOwner string) (extensions.Extension, error) {
	if !strings.HasPrefix(extName, "gh-") {
		return nil, errors.New("extension name must start with `gh-`")
	}

	commandName := strings.TrimPrefix(extName, "gh-")
	if c, _, _ := rootCmd.Find([]string{commandName}); c != rootCmd && c.GroupID != "extension" {
		return nil, fmt.Errorf("%q matches the name of a built-in command or alias", commandName)
	}

	for _, ext := range m.List() {
		if ext.Name() == commandName {
			if extOwner != "" && ext.Owner() == extOwner {
				return ext, alreadyInstalledError
			}
			return ext, fmt.Errorf("there is already an installed extension that provides the %q command", commandName)
		}
	}

	return nil, nil
}

func normalizeExtensionSelector(n string) string {
	if idx := strings.IndexRune(n, '/'); idx >= 0 {
		n = n[idx+1:]
	}
	return strings.TrimPrefix(n, "gh-")
}

func displayExtensionVersion(ext extensions.Extension, version string) string {
	if !ext.IsBinary() && len(version) > 8 {
		return version[:8]
	}
	return version
}
