package cli

import (
	"bufio"
	"bytes"
	"context"
	"fmt"
	"io"
	"os"
	"os/exec"
	"os/signal"
	"path/filepath"
	"strings"

	"golang.org/x/xerrors"

	"github.com/coder/coder/v2/cli/clibase"
	"github.com/coder/coder/v2/cli/cliui"
)

func (r *RootCmd) gitssh() *clibase.Cmd {
	cmd := &clibase.Cmd{
		Use:    "gitssh",
		Hidden: true,
		Short:  `Wraps the "ssh" command and uses the coder gitssh key for authentication`,
		Handler: func(inv *clibase.Invocation) error {
			ctx := inv.Context()
			env := os.Environ()

			// Catch interrupt signals to ensure the temporary private
			// key file is cleaned up on most cases.
			ctx, stop := signal.NotifyContext(ctx, InterruptSignals...)
			defer stop()

			// Early check so errors are reported immediately.
			identityFiles, err := parseIdentityFilesForHost(ctx, inv.Args, env)
			if err != nil {
				return err
			}

			client, err := r.createAgentClient()
			if err != nil {
				return xerrors.Errorf("create agent client: %w", err)
			}
			key, err := client.GitSSHKey(ctx)
			if err != nil {
				return xerrors.Errorf("get agent git ssh token: %w", err)
			}

			privateKeyFile, err := os.CreateTemp("", "coder-gitsshkey-*")
			if err != nil {
				return xerrors.Errorf("create temp gitsshkey file: %w", err)
			}
			defer func() {
				_ = privateKeyFile.Close()
				_ = os.Remove(privateKeyFile.Name())
			}()
			_, err = privateKeyFile.WriteString(key.PrivateKey)
			if err != nil {
				return xerrors.Errorf("write to temp gitsshkey file: %w", err)
			}
			err = privateKeyFile.Close()
			if err != nil {
				return xerrors.Errorf("close temp gitsshkey file: %w", err)
			}

			// Append our key, giving precedence to user keys. Note that
			// OpenSSH server are typically configured with MaxAuthTries
			// set to the default value of 6. This means that only the 6
			// first keys can be tried. However, we will assume that if
			// a user has configured 6+ keys for a host, they know what
			// they're doing. This behavior is critical if a server has
			// been configured with MaxAuthTries set to 1.
			identityFiles = append(identityFiles, privateKeyFile.Name())

			var identityArgs []string
			for _, id := range identityFiles {
				identityArgs = append(identityArgs, "-i", id)
			}

			args := inv.Args
			args = append(identityArgs, args...)
			c := exec.CommandContext(ctx, "ssh", args...)
			c.Env = append(c.Env, env...)
			c.Stderr = inv.Stderr
			c.Stdout = inv.Stdout
			c.Stdin = inv.Stdin
			err = c.Run()
			if err != nil {
				exitErr := &exec.ExitError{}
				if xerrors.As(err, &exitErr) && exitErr.ExitCode() == 255 {
					_, _ = fmt.Fprintln(inv.Stderr,
						"\n"+cliui.DefaultStyles.Wrap.Render("Coder authenticates with "+cliui.DefaultStyles.Field.Render("git")+
							" using the public key below. All clones with SSH are authenticated automatically 🪄.")+"\n")
					_, _ = fmt.Fprintln(inv.Stderr, cliui.DefaultStyles.Code.Render(strings.TrimSpace(key.PublicKey))+"\n")
					_, _ = fmt.Fprintln(inv.Stderr, "Add to GitHub and GitLab:")
					_, _ = fmt.Fprintln(inv.Stderr, cliui.DefaultStyles.Prompt.String()+"https://github.com/settings/ssh/new")
					_, _ = fmt.Fprintln(inv.Stderr, cliui.DefaultStyles.Prompt.String()+"https://gitlab.com/-/profile/keys")
					_, _ = fmt.Fprintln(inv.Stderr)
					return err
				}
				return xerrors.Errorf("run ssh command: %w", err)
			}

			return nil
		},
	}

	return cmd
}

// fallbackIdentityFiles is the list of identity files SSH tries when
// none have been defined for a host.
var fallbackIdentityFiles = strings.Join([]string{
	"identityfile ~/.ssh/id_rsa",
	"identityfile ~/.ssh/id_dsa",
	"identityfile ~/.ssh/id_ecdsa",
	"identityfile ~/.ssh/id_ecdsa_sk",
	"identityfile ~/.ssh/id_ed25519",
	"identityfile ~/.ssh/id_ed25519_sk",
	"identityfile ~/.ssh/id_xmss",
}, "\n")

// parseIdentityFilesForHost uses ssh -G to discern what SSH keys have
// been enabled for the host (via the users SSH config) and returns a
// list of existing identity files.
//
// We do this because when no keys are defined for a host, SSH uses
// fallback keys (see above). However, by passing `-i` to attach our
// private key, we're effectively disabling the fallback keys.
//
// Example invocation:
//
//	ssh -G -o SendEnv=GIT_PROTOCOL git@github.com git-upload-pack 'coder/coder'
//
// The extra arguments work without issue and lets us run the command
// as-is without stripping out the excess (git-upload-pack 'coder/coder').
func parseIdentityFilesForHost(ctx context.Context, args, env []string) (identityFiles []string, error error) {
	home, err := os.UserHomeDir()
	if err != nil {
		return nil, xerrors.Errorf("get user home dir failed: %w", err)
	}

	var outBuf bytes.Buffer
	var r io.Reader = &outBuf

	args = append([]string{"-G"}, args...)
	cmd := exec.CommandContext(ctx, "ssh", args...)
	cmd.Env = append(cmd.Env, env...)
	cmd.Stdout = &outBuf
	cmd.Stderr = io.Discard
	err = cmd.Run()
	if err != nil {
		// If ssh -G failed, the SSH version is likely too old, fallback
		// to using the default identity files.
		r = strings.NewReader(fallbackIdentityFiles)
	}

	s := bufio.NewScanner(r)
	for s.Scan() {
		line := s.Text()
		if strings.HasPrefix(line, "identityfile ") {
			id := strings.TrimPrefix(line, "identityfile ")
			if strings.HasPrefix(id, "~/") {
				id = home + id[1:]
			}
			// OpenSSH on Windows is weird, it supports using (and does
			// use) mixed \ and / in paths.
			//
			// Example: C:\Users\ZeroCool/.ssh/known_hosts
			//
			// To check the file existence in Go, though, we want to use
			// proper Windows paths.
			// OpenSSH is amazing, this will work on Windows too:
			// C:\Users\ZeroCool/.ssh/id_rsa
			id = filepath.FromSlash(id)

			// Only include the identity file if it exists.
			if _, err := os.Stat(id); err == nil {
				identityFiles = append(identityFiles, id)
			}
		}
	}
	if err := s.Err(); err != nil {
		// This should never happen, the check is for completeness.
		return nil, xerrors.Errorf("scan ssh output: %w", err)
	}

	return identityFiles, nil
}
