package status

import (
	"errors"
	"fmt"
	"net"
	"net/http"
	"path/filepath"
	"slices"
	"strings"

	"github.com/MakeNowJust/heredoc"
	"github.com/cli/cli/v2/api"
	"github.com/cli/cli/v2/internal/config"
	"github.com/cli/cli/v2/pkg/cmd/auth/shared"
	"github.com/cli/cli/v2/pkg/cmdutil"
	"github.com/cli/cli/v2/pkg/iostreams"
	"github.com/spf13/cobra"
)

type validEntry struct {
	active      bool
	host        string
	user        string
	token       string
	tokenSource string
	gitProtocol string
	scopes      string
}

func (e validEntry) String(cs *iostreams.ColorScheme) string {
	var sb strings.Builder

	sb.WriteString(
		fmt.Sprintf("  %s Logged in to %s account %s (%s)\n", cs.SuccessIcon(), e.host, cs.Bold(e.user), e.tokenSource),
	)
	activeStr := fmt.Sprintf("%v", e.active)
	sb.WriteString(fmt.Sprintf("  - Active account: %s\n", cs.Bold(activeStr)))
	sb.WriteString(fmt.Sprintf("  - Git operations protocol: %s\n", cs.Bold(e.gitProtocol)))
	sb.WriteString(fmt.Sprintf("  - Token: %s\n", cs.Bold(e.token)))

	if expectScopes(e.token) {
		sb.WriteString(fmt.Sprintf("  - Token scopes: %s\n", cs.Bold(displayScopes(e.scopes))))
		if err := shared.HeaderHasMinimumScopes(e.scopes); err != nil {
			var missingScopesError *shared.MissingScopesError
			if errors.As(err, &missingScopesError) {
				missingScopes := strings.Join(missingScopesError.MissingScopes, ",")
				sb.WriteString(fmt.Sprintf("  %s Missing required token scopes: %s\n",
					cs.WarningIcon(),
					cs.Bold(displayScopes(missingScopes))))
				refreshInstructions := fmt.Sprintf("gh auth refresh -h %s", e.host)
				sb.WriteString(fmt.Sprintf("  - To request missing scopes, run: %s\n", cs.Bold(refreshInstructions)))
			}
		}
	}

	return sb.String()
}

type invalidTokenEntry struct {
	active           bool
	host             string
	user             string
	tokenSource      string
	tokenIsWriteable bool
}

func (e invalidTokenEntry) String(cs *iostreams.ColorScheme) string {
	var sb strings.Builder

	if e.user != "" {
		sb.WriteString(fmt.Sprintf("  %s Failed to log in to %s account %s (%s)\n", cs.Red("X"), e.host, cs.Bold(e.user), e.tokenSource))
	} else {
		sb.WriteString(fmt.Sprintf("  %s Failed to log in to %s using token (%s)\n", cs.Red("X"), e.host, e.tokenSource))
	}
	activeStr := fmt.Sprintf("%v", e.active)
	sb.WriteString(fmt.Sprintf("  - Active account: %s\n", cs.Bold(activeStr)))
	sb.WriteString(fmt.Sprintf("  - The token in %s is invalid.\n", e.tokenSource))
	if e.tokenIsWriteable {
		loginInstructions := fmt.Sprintf("gh auth login -h %s", e.host)
		logoutInstructions := fmt.Sprintf("gh auth logout -h %s -u %s", e.host, e.user)
		sb.WriteString(fmt.Sprintf("  - To re-authenticate, run: %s\n", cs.Bold(loginInstructions)))
		sb.WriteString(fmt.Sprintf("  - To forget about this account, run: %s\n", cs.Bold(logoutInstructions)))
	}

	return sb.String()
}

type timeoutErrorEntry struct {
	active      bool
	host        string
	user        string
	tokenSource string
}

func (e timeoutErrorEntry) String(cs *iostreams.ColorScheme) string {
	var sb strings.Builder

	if e.user != "" {
		sb.WriteString(fmt.Sprintf("  %s Timeout trying to log in to %s account %s (%s)\n", cs.Red("X"), e.host, cs.Bold(e.user), e.tokenSource))
	} else {
		sb.WriteString(fmt.Sprintf("  %s Timeout trying to log in to %s using token (%s)\n", cs.Red("X"), e.host, e.tokenSource))
	}
	activeStr := fmt.Sprintf("%v", e.active)
	sb.WriteString(fmt.Sprintf("  - Active account: %s\n", cs.Bold(activeStr)))

	return sb.String()
}

type Entry interface {
	String(cs *iostreams.ColorScheme) string
}

type Entries []Entry

func (e Entries) Strings(cs *iostreams.ColorScheme) []string {
	var out []string
	for _, entry := range e {
		out = append(out, entry.String(cs))
	}
	return out
}

type StatusOptions struct {
	HttpClient func() (*http.Client, error)
	IO         *iostreams.IOStreams
	Config     func() (config.Config, error)

	Hostname  string
	ShowToken bool
}

func NewCmdStatus(f *cmdutil.Factory, runF func(*StatusOptions) error) *cobra.Command {
	opts := &StatusOptions{
		HttpClient: f.HttpClient,
		IO:         f.IOStreams,
		Config:     f.Config,
	}

	cmd := &cobra.Command{
		Use:   "status",
		Args:  cobra.ExactArgs(0),
		Short: "View all accounts and authentication status",
		Long: heredoc.Doc(`Verifies and displays information about your authentication state.

			This command will test your authentication state for each GitHub host that gh knows about and
			report on any issues.
		`),
		RunE: func(cmd *cobra.Command, args []string) error {
			if runF != nil {
				return runF(opts)
			}

			return statusRun(opts)
		},
	}

	cmd.Flags().StringVarP(&opts.Hostname, "hostname", "h", "", "Check a specific hostname's auth status")
	cmd.Flags().BoolVarP(&opts.ShowToken, "show-token", "t", false, "Display the auth token")

	return cmd
}

func statusRun(opts *StatusOptions) error {
	cfg, err := opts.Config()
	if err != nil {
		return err
	}
	authCfg := cfg.Authentication()

	stderr := opts.IO.ErrOut
	stdout := opts.IO.Out
	cs := opts.IO.ColorScheme()

	statuses := make(map[string]Entries)

	hostnames := authCfg.Hosts()
	if len(hostnames) == 0 {
		fmt.Fprintf(stderr,
			"You are not logged into any GitHub hosts. To log in, run: %s\n", cs.Bold("gh auth login"))
		return cmdutil.SilentError
	}

	if opts.Hostname != "" && !slices.Contains(hostnames, opts.Hostname) {
		fmt.Fprintf(stderr,
			"You are not logged into any accounts on %s\n", opts.Hostname)
		return cmdutil.SilentError
	}

	httpClient, err := opts.HttpClient()
	if err != nil {
		return err
	}

	for _, hostname := range hostnames {
		if opts.Hostname != "" && opts.Hostname != hostname {
			continue
		}

		var activeUser string
		gitProtocol := cfg.GitProtocol(hostname)
		activeUserToken, activeUserTokenSource := authCfg.ActiveToken(hostname)
		if authTokenWriteable(activeUserTokenSource) {
			activeUser, _ = authCfg.ActiveUser(hostname)
		}
		entry := buildEntry(httpClient, buildEntryOptions{
			active:      true,
			gitProtocol: gitProtocol,
			hostname:    hostname,
			showToken:   opts.ShowToken,
			token:       activeUserToken,
			tokenSource: activeUserTokenSource,
			username:    activeUser,
		})
		statuses[hostname] = append(statuses[hostname], entry)

		users := authCfg.UsersForHost(hostname)
		for _, username := range users {
			if username == activeUser {
				continue
			}
			token, tokenSource, _ := authCfg.TokenForUser(hostname, username)
			entry := buildEntry(httpClient, buildEntryOptions{
				active:      false,
				gitProtocol: gitProtocol,
				hostname:    hostname,
				showToken:   opts.ShowToken,
				token:       token,
				tokenSource: tokenSource,
				username:    username,
			})
			statuses[hostname] = append(statuses[hostname], entry)
		}
	}

	prevEntry := false
	for _, hostname := range hostnames {
		entries, ok := statuses[hostname]
		if !ok {
			continue
		}

		if prevEntry {
			fmt.Fprint(stdout, "\n")
		}
		prevEntry = true
		fmt.Fprintf(stdout, "%s\n", cs.Bold(hostname))
		fmt.Fprintf(stdout, "%s", strings.Join(entries.Strings(cs), "\n"))
	}

	return nil
}

func displayToken(token string, printRaw bool) string {
	if printRaw {
		return token
	}

	if idx := strings.LastIndexByte(token, '_'); idx > -1 {
		prefix := token[0 : idx+1]
		return prefix + strings.Repeat("*", len(token)-len(prefix))
	}

	return strings.Repeat("*", len(token))
}

func displayScopes(scopes string) string {
	if scopes == "" {
		return "none"
	}
	list := strings.Split(scopes, ",")
	for i, s := range list {
		list[i] = fmt.Sprintf("'%s'", strings.TrimSpace(s))
	}
	return strings.Join(list, ", ")
}

func expectScopes(token string) bool {
	return strings.HasPrefix(token, "ghp_") || strings.HasPrefix(token, "gho_")
}

type buildEntryOptions struct {
	active      bool
	gitProtocol string
	hostname    string
	showToken   bool
	token       string
	tokenSource string
	username    string
}

func buildEntry(httpClient *http.Client, opts buildEntryOptions) Entry {
	tokenIsWriteable := authTokenWriteable(opts.tokenSource)

	if opts.tokenSource == "oauth_token" {
		// The go-gh function TokenForHost returns this value as source for tokens read from the
		// config file, but we want the file path instead. This attempts to reconstruct it.
		opts.tokenSource = filepath.Join(config.ConfigDir(), "hosts.yml")
	}

	// If token is not writeable, then it came from an environment variable and
	// we need to fetch the username as it won't be stored in the config.
	if !tokenIsWriteable {
		// The httpClient will automatically use the correct token here as
		// the token from the environment variable take highest precedence.
		apiClient := api.NewClientFromHTTP(httpClient)
		var err error
		opts.username, err = api.CurrentLoginName(apiClient, opts.hostname)
		if err != nil {
			return invalidTokenEntry{
				active:           opts.active,
				host:             opts.hostname,
				user:             opts.username,
				tokenIsWriteable: tokenIsWriteable,
				tokenSource:      opts.tokenSource,
			}
		}
	}

	// Get scopes for token.
	scopesHeader, err := shared.GetScopes(httpClient, opts.hostname, opts.token)
	if err != nil {
		var networkError net.Error
		if errors.As(err, &networkError) && networkError.Timeout() {
			return timeoutErrorEntry{
				active:      opts.active,
				host:        opts.hostname,
				user:        opts.username,
				tokenSource: opts.tokenSource,
			}
		}

		return invalidTokenEntry{
			active:           opts.active,
			host:             opts.hostname,
			user:             opts.username,
			tokenIsWriteable: tokenIsWriteable,
			tokenSource:      opts.tokenSource,
		}
	}

	return validEntry{
		active:      opts.active,
		gitProtocol: opts.gitProtocol,
		host:        opts.hostname,
		scopes:      scopesHeader,
		token:       displayToken(opts.token, opts.showToken),
		tokenSource: opts.tokenSource,
		user:        opts.username,
	}
}

func authTokenWriteable(src string) bool {
	return !strings.HasSuffix(src, "_TOKEN")
}
