package code

import (
	"fmt"
	"strings"

	"github.com/MakeNowJust/heredoc"
	"github.com/cli/cli/v2/internal/browser"
	"github.com/cli/cli/v2/internal/text"
	"github.com/cli/cli/v2/pkg/cmd/search/shared"
	"github.com/cli/cli/v2/pkg/cmdutil"
	"github.com/cli/cli/v2/pkg/iostreams"
	"github.com/cli/cli/v2/pkg/search"
	"github.com/spf13/cobra"
)

type CodeOptions struct {
	Browser  browser.Browser
	Exporter cmdutil.Exporter
	IO       *iostreams.IOStreams
	Query    search.Query
	Searcher search.Searcher
	WebMode  bool
}

func NewCmdCode(f *cmdutil.Factory, runF func(*CodeOptions) error) *cobra.Command {
	opts := &CodeOptions{
		Browser: f.Browser,
		IO:      f.IOStreams,
		Query:   search.Query{Kind: search.KindCode},
	}

	cmd := &cobra.Command{
		Use:   "code <query>",
		Short: "Search within code",
		Long: heredoc.Doc(`
			Search within code in GitHub repositories.

			The search syntax is documented at:
			<https://docs.github.com/search-github/searching-on-github/searching-code>

			Note that these search results are powered by what is now a legacy GitHub code search engine.
			The results might not match what is seen on <github.com>, and new features like regex search
			are not yet available via the GitHub API.
		`),
		Example: heredoc.Doc(`
			# Search code matching "react" and "lifecycle"
			$ gh search code react lifecycle

			# Search code matching "error handling"
			$ gh search code "error handling"

			# Search code matching "deque" in Python files
			$ gh search code deque --language=python

			# Search code matching "cli" in repositories owned by microsoft organization
			$ gh search code cli --owner=microsoft

			# Search code matching "panic" in the GitHub CLI repository
			$ gh search code panic --repo cli/cli

			# Search code matching keyword "lint" in package.json files
			$ gh search code lint --filename package.json
		`),
		RunE: func(c *cobra.Command, args []string) error {
			if len(args) == 0 && c.Flags().NFlag() == 0 {
				return cmdutil.FlagErrorf("specify search keywords or flags")
			}
			if opts.Query.Limit < 1 || opts.Query.Limit > shared.SearchMaxResults {
				return cmdutil.FlagErrorf("`--limit` must be between 1 and 1000")
			}
			opts.Query.Keywords = args
			if runF != nil {
				return runF(opts)
			}
			var err error
			opts.Searcher, err = shared.Searcher(f)
			if err != nil {
				return err
			}
			return codeRun(opts)
		},
	}

	// Output flags
	cmdutil.AddJSONFlags(cmd, &opts.Exporter, search.CodeFields)
	cmd.Flags().BoolVarP(&opts.WebMode, "web", "w", false, "Open the search query in the web browser")

	// Query parameter flags
	cmd.Flags().IntVarP(&opts.Query.Limit, "limit", "L", 30, "Maximum number of code results to fetch")

	// Query qualifier flags
	cmd.Flags().StringVar(&opts.Query.Qualifiers.Extension, "extension", "", "Filter on file extension")
	cmd.Flags().StringVar(&opts.Query.Qualifiers.Filename, "filename", "", "Filter on filename")
	cmdutil.StringSliceEnumFlag(cmd, &opts.Query.Qualifiers.In, "match", "", nil, []string{"file", "path"}, "Restrict search to file contents or file path")
	cmd.Flags().StringVarP(&opts.Query.Qualifiers.Language, "language", "", "", "Filter results by language")
	cmd.Flags().StringSliceVarP(&opts.Query.Qualifiers.Repo, "repo", "R", nil, "Filter on repository")
	cmd.Flags().StringVar(&opts.Query.Qualifiers.Size, "size", "", "Filter on size range, in kilobytes")
	cmd.Flags().StringSliceVar(&opts.Query.Qualifiers.User, "owner", nil, "Filter on owner")

	return cmd
}

func codeRun(opts *CodeOptions) error {
	io := opts.IO
	if opts.WebMode {
		// FIXME: convert legacy `filename` and `extension` ES qualifiers to Blackbird's `path` qualifier
		// when opening web search, otherwise the Blackbird search UI will complain.
		url := opts.Searcher.URL(opts.Query)
		if io.IsStdoutTTY() {
			fmt.Fprintf(io.ErrOut, "Opening %s in your browser.\n", text.DisplayURL(url))
		}
		return opts.Browser.Browse(url)
	}
	io.StartProgressIndicator()
	results, err := opts.Searcher.Code(opts.Query)
	io.StopProgressIndicator()
	if err != nil {
		return err
	}
	if len(results.Items) == 0 && opts.Exporter == nil {
		return cmdutil.NewNoResultsError("no code results matched your search")
	}
	if err := io.StartPager(); err == nil {
		defer io.StopPager()
	} else {
		fmt.Fprintf(io.ErrOut, "failed to start pager: %v\n", err)
	}
	if opts.Exporter != nil {
		return opts.Exporter.Write(io, results.Items)
	}

	return displayResults(io, results)
}

func displayResults(io *iostreams.IOStreams, results search.CodeResult) error {
	cs := io.ColorScheme()
	if io.IsStdoutTTY() {
		fmt.Fprintf(io.Out, "\nShowing %d of %d results\n\n", len(results.Items), results.Total)
		for i, code := range results.Items {
			if i > 0 {
				fmt.Fprint(io.Out, "\n")
			}
			fmt.Fprintf(io.Out, "%s %s\n", cs.Blue(code.Repository.FullName), cs.GreenBold(code.Path))
			for _, match := range code.TextMatches {
				lines := formatMatch(match.Fragment, match.Matches, io)
				for _, line := range lines {
					fmt.Fprintf(io.Out, "\t%s\n", strings.TrimSpace(line))
				}
			}
		}
		return nil
	}
	for _, code := range results.Items {
		for _, match := range code.TextMatches {
			lines := formatMatch(match.Fragment, match.Matches, io)
			for _, line := range lines {
				fmt.Fprintf(io.Out, "%s:%s: %s\n", cs.Blue(code.Repository.FullName), cs.GreenBold(code.Path), strings.TrimSpace(line))
			}
		}
	}
	return nil
}

func formatMatch(t string, matches []search.Match, io *iostreams.IOStreams) []string {
	cs := io.ColorScheme()

	startIndices := map[int]struct{}{}
	endIndices := map[int]struct{}{}
	for _, m := range matches {
		if len(m.Indices) < 2 {
			continue
		}
		startIndices[m.Indices[0]] = struct{}{}
		endIndices[m.Indices[1]] = struct{}{}
	}

	var lines []string
	var b strings.Builder
	var found bool
	for i, c := range t {
		if c == '\n' {
			if found {
				lines = append(lines, b.String())
			}
			found = false
			b.Reset()
			continue
		}
		if _, ok := startIndices[i]; ok {
			b.WriteString(cs.HighlightStart())
			found = true
		} else if _, ok := endIndices[i]; ok {
			b.WriteString(cs.Reset())
		}
		b.WriteRune(c)
	}
	if found {
		lines = append(lines, b.String())
	}
	return lines
}
