package main

import (
	"errors"
	"fmt"
	"io"
	"os"
	"strconv"
	"strings"
	"sync"
	"time"

	easyssh "github.com/appleboy/easyssh-proxy"
)

var (
	errMissingHost          = errors.New("error: missing server host")
	errMissingPasswordOrKey = errors.New("error: can't connect without a private SSH key or password")
	errCommandTimeOut       = errors.New("error: command timeout")
	envsFormat              = "export {NAME}={VALUE}"
)

type (
	// Config for the plugin.
	Config struct {
		Key               string
		Passphrase        string
		KeyPath           string
		Username          string
		Password          string
		Host              []string
		Port              int
		Protocol          easyssh.Protocol
		Fingerprint       string
		Timeout           time.Duration
		CommandTimeout    time.Duration
		Script            []string
		ScriptStop        bool
		Envs              []string
		Proxy             easyssh.DefaultConfig
		Debug             bool
		Sync              bool
		Ciphers           []string
		UseInsecureCipher bool
		EnvsFormat        string
		AllEnvs           bool
		RequireTty        bool
	}

	// Plugin structure
	Plugin struct {
		Config Config
		Writer io.Writer
	}
)

func escapeArg(arg string) string {
	return "'" + strings.ReplaceAll(arg, "'", `'\''`) + "'"
}

func (p Plugin) hostPort(host string) (string, string) {
	hosts := strings.Split(host, ":")
	port := strconv.Itoa(p.Config.Port)
	if len(hosts) > 1 &&
		(p.Config.Protocol == easyssh.PROTOCOL_TCP ||
			p.Config.Protocol == easyssh.PROTOCOL_TCP4) {
		host = hosts[0]
		port = hosts[1]
	}

	return host, port
}

func (p Plugin) exec(host string, wg *sync.WaitGroup, errChannel chan error) {
	defer wg.Done()
	host, port := p.hostPort(host)
	// Create MakeConfig instance with remote username, server address and path to private key.
	ssh := &easyssh.MakeConfig{
		Server:            host,
		User:              p.Config.Username,
		Password:          p.Config.Password,
		Port:              port,
		Protocol:          p.Config.Protocol,
		Key:               p.Config.Key,
		KeyPath:           p.Config.KeyPath,
		Passphrase:        p.Config.Passphrase,
		Timeout:           p.Config.Timeout,
		Ciphers:           p.Config.Ciphers,
		Fingerprint:       p.Config.Fingerprint,
		UseInsecureCipher: p.Config.UseInsecureCipher,
		RequestPty:        p.Config.RequireTty,
		Proxy: easyssh.DefaultConfig{
			Server:            p.Config.Proxy.Server,
			User:              p.Config.Proxy.User,
			Password:          p.Config.Proxy.Password,
			Port:              p.Config.Proxy.Port,
			Protocol:          p.Config.Proxy.Protocol,
			Key:               p.Config.Proxy.Key,
			KeyPath:           p.Config.Proxy.KeyPath,
			Passphrase:        p.Config.Proxy.Passphrase,
			Timeout:           p.Config.Proxy.Timeout,
			Ciphers:           p.Config.Proxy.Ciphers,
			Fingerprint:       p.Config.Proxy.Fingerprint,
			UseInsecureCipher: p.Config.Proxy.UseInsecureCipher,
		},
	}

	if p.Config.Debug {
		p.log(host, "======CMD======")
		p.log(host, strings.Join(p.Config.Script, "\n"))
		p.log(host, "======END======")
	}

	env := []string{}
	if p.Config.AllEnvs {
		allenvs := findEnvs("DRONE_", "PLUGIN_", "INPUT_", "GITHUB_")
		p.Config.Envs = append(p.Config.Envs, allenvs...)
	}
	for _, key := range p.Config.Envs {
		key = strings.ToUpper(key)
		if val, found := os.LookupEnv(key); found {
			env = append(env, p.format(p.Config.EnvsFormat, "{NAME}", key, "{VALUE}", escapeArg(val)))
		}
	}

	p.Config.Script = append(env, p.scriptCommands()...)

	if p.Config.Debug && len(env) > 0 {
		p.log(host, "======ENV======")
		p.log(host, strings.Join(env, "\n"))
		p.log(host, "======END======")
	}

	stdoutChan, stderrChan, doneChan, errChan, err := ssh.Stream(strings.Join(p.Config.Script, "\n"), p.Config.CommandTimeout)
	if err != nil {
		errChannel <- err
		return
	}
	// read from the output channel until the done signal is passed
	var isTimeout bool
loop:
	for {
		select {
		case isTimeout = <-doneChan:
			break loop
		case outline := <-stdoutChan:
			if outline != "" {
				p.log(host, outline)
			}
		case errline := <-stderrChan:
			if errline != "" {
				p.log(host, errline)
			}
		case err = <-errChan:
		}
	}

	// get exit code or command error.
	if err != nil {
		errChannel <- err
	}

	// command time out
	if !isTimeout {
		errChannel <- errCommandTimeOut
	}
}

// format string
func (p Plugin) format(format string, args ...string) string {
	r := strings.NewReplacer(args...)
	return r.Replace(format)
}

// log output to console
func (p Plugin) log(host string, message ...interface{}) {
	if p.Writer == nil {
		p.Writer = os.Stdout
	}
	if count := len(p.Config.Host); count == 1 {
		fmt.Fprintf(p.Writer, "%s", fmt.Sprintln(message...))
		return
	}

	fmt.Fprintf(p.Writer, "%s: %s", host, fmt.Sprintln(message...))
}

// Exec executes the plugin.
func (p Plugin) Exec() error {
	p.Config.Host = trimValues(p.Config.Host)

	if len(p.Config.Host) == 0 {
		return errMissingHost
	}

	if len(p.Config.Key) == 0 && len(p.Config.Password) == 0 && len(p.Config.KeyPath) == 0 {
		return errMissingPasswordOrKey
	}

	if p.Config.EnvsFormat == "" {
		p.Config.EnvsFormat = envsFormat
	}

	wg := sync.WaitGroup{}
	wg.Add(len(p.Config.Host))
	errChannel := make(chan error)
	finished := make(chan struct{})
	if p.Config.Sync {
		go func() {
			for _, host := range p.Config.Host {
				p.exec(host, &wg, errChannel)
			}
		}()
	} else {
		for _, host := range p.Config.Host {
			go p.exec(host, &wg, errChannel)
		}
	}

	go func() {
		wg.Wait()
		close(finished)
	}()

	select {
	case <-finished:
	case err := <-errChannel:
		if err != nil {
			return err
		}
	}

	fmt.Println("===============================================")
	fmt.Println("✅ Successfully executed commands to all hosts.")
	fmt.Println("===============================================")

	return nil
}

func (p Plugin) scriptCommands() []string {
	scripts := []string{}

	for _, cmd := range p.Config.Script {
		if p.Config.ScriptStop {
			scripts = append(scripts, strings.Split(cmd, "\n")...)
		} else {
			scripts = append(scripts, cmd)
		}
	}

	commands := make([]string, 0)

	for _, cmd := range scripts {
		cmd = strings.TrimSpace(cmd)
		if strings.TrimSpace(cmd) == "" {
			continue
		}
		commands = append(commands, cmd)
		if p.Config.ScriptStop && cmd[(len(cmd)-1):] != "\\" {
			commands = append(commands, "DRONE_SSH_PREV_COMMAND_EXIT_CODE=$? ; if [ $DRONE_SSH_PREV_COMMAND_EXIT_CODE -ne 0 ]; then exit $DRONE_SSH_PREV_COMMAND_EXIT_CODE; fi;")
		}
	}

	return commands
}

func trimValues(keys []string) []string {
	var newKeys []string

	for _, value := range keys {
		value = strings.TrimSpace(value)
		if len(value) == 0 {
			continue
		}

		newKeys = append(newKeys, value)
	}

	return newKeys
}

// Find all envs from specified prefix
func findEnvs(prefix ...string) []string {
	envs := []string{}
	for _, e := range os.Environ() {
		for _, p := range prefix {
			if strings.HasPrefix(e, p) {
				e = strings.Split(e, "=")[0]
				envs = append(envs, e)
				break
			}
		}
	}
	return envs
}
