// Mgmt
// Copyright (C) James Shubin and the project contributors
// Written by James Shubin <james@shubin.ca> and the project contributors
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program.  If not, see <https://www.gnu.org/licenses/>.
//
// Additional permission under GNU GPL version 3 section 7
//
// If you modify this program, or any covered work, by linking or combining it
// with embedded mcl code and modules (and that the embedded mcl code and
// modules which link with this program, contain a copy of their source code in
// the authoritative form) containing parts covered by the terms of any other
// license, the licensors of this program grant you additional permission to
// convey the resulting work. Furthermore, the licensors of this program grant
// the original author, James Shubin, additional permission to update this
// additional permission if he deems it necessary to achieve the goals of this
// additional permission.

// Package ssh transports etcd traffic over SSH to provide a special World API.
package ssh

import (
	"context"
	"fmt"
	"io"
	"net"
	"net/url"
	"os"
	"strconv"
	"strings"

	"github.com/purpleidea/mgmt/engine"
	"github.com/purpleidea/mgmt/etcd"
	"github.com/purpleidea/mgmt/etcd/client"
	"github.com/purpleidea/mgmt/util"
	"github.com/purpleidea/mgmt/util/errwrap"

	clientv3 "go.etcd.io/etcd/client/v3"
	"golang.org/x/crypto/ssh"
	"golang.org/x/crypto/ssh/knownhosts"
	"google.golang.org/grpc"
)

const (
	defaultUser                  = "root"
	defaultSSHPort        uint16 = 22
	defaultEtcdPort       uint16 = 2379 // TODO: get this from etcd pkg
	defaultIDRsaPath             = "~/.ssh/id_rsa"
	defaultIDEd25519Path         = "~/.ssh/id_ed25519"
	defaultKnownHostsPath        = "~/.ssh/known_hosts"
)

// World is an implementation of the world API for etcd over SSH.
type World struct {
	// URL is the ssh server to connect to. Use the format, james@server:22
	// or similar. From there, we connect to each of the etcd Seeds, so the
	// ip's should be relative to this server.
	URL string

	// SSHID is the path to the ~/.ssh/id_rsa or ~/.ssh/id_ed25519 to use
	// for auth. If you omit this then this will look for your private key
	// in both of those default paths. If you specific a specific path, then
	// that will only be used. This will expand the ~/ and ~user/ style path
	// expansions.
	SSHID string

	// Seeds are the list of etcd endpoints to connect to.
	Seeds []string

	// NS is the etcd namespace to use.
	NS string

	MetadataPrefix string    // expected metadata prefix
	StoragePrefix  string    // storage prefix for etcdfs storage
	StandaloneFs   engine.Fs // store an fs here for local usage
	GetURI         func() string

	*etcd.World

	init *engine.WorldInit

	sshClient *ssh.Client

	cleanups []func() error
}

// sshKeyAuth is a helper function to get the ssh key auth struct needed.
func (obj *World) sshKeyAuth(sshID string) (ssh.AuthMethod, error) {
	if sshID == "" {
		return nil, fmt.Errorf("empty path specified")
	}

	// expand strings of the form: ~james/.ssh/id_rsa
	p, err := util.ExpandHome(sshID)
	if err != nil {
		return nil, errwrap.Wrapf(err, "can't find home directory")
	}
	if p == "" {
		return nil, fmt.Errorf("empty path specified")
	}
	// A public key may be used to authenticate against the server by using
	// an unencrypted PEM-encoded private key file. If you have an encrypted
	// private key, the crypto/x509 package can be used to decrypt it.
	key, err := os.ReadFile(p)
	if err != nil {
		return nil, err
	}

	// create the Signer for this private key
	signer, err := ssh.ParsePrivateKey(key)
	if err != nil {
		return nil, err
	}

	return ssh.PublicKeys(signer), nil
}

// hostKeyCallback is a helper function to get the ssh callback function needed.
func (obj *World) hostKeyCallback() (ssh.HostKeyCallback, error) {
	// TODO: consider allowing a user-specified path in the future
	s := defaultKnownHostsPath // "~/.ssh/known_hosts"

	// expand strings of the form: ~james/.ssh/known_hosts
	p, err := util.ExpandHome(s)
	if err != nil {
		return nil, errwrap.Wrapf(err, "can't find home directory")
	}
	if p == "" {
		return nil, fmt.Errorf("empty path specified")
	}

	return knownhosts.New(p)
}

// Init runs first.
func (obj *World) Init(init *engine.WorldInit) error {
	obj.init = init
	obj.cleanups = []func() error{}

	if len(obj.Seeds) == 0 {
		return fmt.Errorf("at least one seed is required")
	}
	seedSSH := make(map[string]string)
	for _, seed := range obj.Seeds {
		u, err := url.Parse(seed)
		if err != nil {
			return err
		}
		hostname := u.Hostname()
		if hostname == "" {
			return fmt.Errorf("empty hostname")
		}
		port := strconv.Itoa(int(defaultSSHPort)) // default
		if s := u.Port(); s != "" {
			port = s
		}
		addr := fmt.Sprintf("%s:%s", hostname, port)
		if s := u.Scheme; s != "http" && s != "https" {
			return fmt.Errorf("invalid scheme: %s", s)
		}
		seedSSH[seed] = addr // remove the scheme!
	}
	if l := len(obj.Seeds) - len(seedSSH); l != 0 {
		return fmt.Errorf("found %d duplicate tunnels", l)
	}

	s := obj.URL
	scheme := "ssh://"
	// the url.Parse function parses incorrectly without a scheme prefix :/
	if !strings.HasPrefix(s, scheme) {
		s = scheme + s
	}
	u, err := url.Parse(s)
	if err != nil {
		return err
	}
	user := defaultUser
	if s := u.User.Username(); s != "" {
		user = s
	}
	hostname := u.Hostname()
	if hostname == "" {
		return fmt.Errorf("empty hostname")
	}
	port := strconv.Itoa(int(defaultSSHPort)) // default
	if s := u.Port(); s != "" {
		port = s
	}

	addr := fmt.Sprintf("%s:%s", hostname, port)

	auths := []ssh.AuthMethod{}
	//auths = append(auths, ssh.Password("password")) // testing
	choices := []string{
		//obj.SSHID,
		defaultIDEd25519Path,
		defaultIDRsaPath, // "~/.ssh/id_rsa"
	}
	if obj.SSHID != "" {
		choices = []string{
			obj.SSHID,
		}
	}
	for _, p := range choices {
		if p == "" {
			continue
		}
		auth, err := obj.sshKeyAuth(p)
		if err != nil {
			obj.init.Logf("can't get auth from: %s", p)
			continue
		}
		auths = append(auths, auth)
	}
	if len(auths) == 0 {
		return fmt.Errorf("no auth options available")
	}

	hostKeyCallback, err := obj.hostKeyCallback()
	if err != nil {
		return err
	}

	// SSH connection configuration
	sshConfig := &ssh.ClientConfig{
		User: user,
		Auth: auths,
		//HostKeyCallback: ssh.InsecureIgnoreHostKey(), // testing
		HostKeyCallback: hostKeyCallback,
	}

	obj.init.Logf("ssh: %s@%s", user, addr)
	obj.sshClient, err = ssh.Dial("tcp", addr, sshConfig)
	if err != nil {
		return err
	}
	obj.cleanups = append(obj.cleanups, func() error {
		e := obj.sshClient.Close()
		if obj.init.Debug && e != nil {
			obj.init.Logf("ssh client close error: %+v", e)
		}
		return e
	})

	tunnels := make(map[string]net.Conn)
	for _, seed := range obj.Seeds {
		addr := seedSSH[seed]
		obj.init.Logf("tunnel: %s", addr)
		tunnel, err := obj.sshClient.Dial("tcp", addr)
		if err != nil {
			return errwrap.Append(obj.cleanup(), err)
		}
		obj.cleanups = append(obj.cleanups, func() error {
			e := tunnel.Close()
			if e == io.EOF { // XXX: why does this happen?
				return nil // ignore
			}
			if obj.init.Debug && e != nil {
				obj.init.Logf("ssh client close error: %+v", e)
			}
			return e
		})
		tunnels[addr] = tunnel
	}

	etcdClient, err := clientv3.New(clientv3.Config{
		Endpoints: obj.Seeds,
		DialOptions: []grpc.DialOption{
			grpc.WithContextDialer(func(ctx context.Context, addr string) (net.Conn, error) {
				tunnel, exists := tunnels[addr]
				if !exists {
					obj.init.Logf("can't find tunnel: %s", addr) // tell user early...
					return nil, fmt.Errorf("can't find tunnel: %s", addr)
				}
				// TODO: print the scheme here on this log msg
				obj.init.Logf("etcd dial: %s", addr)
				return tunnel, nil
			}),
		},
	})
	if err != nil {
		return errwrap.Append(obj.cleanup(), err)
	}
	obj.cleanups = append(obj.cleanups, func() error {
		e := etcdClient.Close()
		if obj.init.Debug && e != nil {
			obj.init.Logf("etcd client close error: %+v", e)
		}
		return e
	})

	c := client.NewClientFromNamespaceStr(etcdClient, obj.NS)

	obj.World = &etcd.World{
		// TODO: Pass through more data if the struct for etcd changes.
		Client:         c,
		MetadataPrefix: obj.MetadataPrefix,
		StoragePrefix:  obj.StoragePrefix,
		StandaloneFs:   obj.StandaloneFs,
		GetURI:         obj.GetURI,
	}
	if err := obj.World.Init(init); err != nil {
		return errwrap.Append(obj.cleanup(), err)
	}
	obj.cleanups = append(obj.cleanups, func() error {
		e := obj.World.Close()
		if obj.init.Debug && e != nil {
			obj.init.Logf("world close error: %+v", e)
		}
		return e
	})

	return nil
}

// cleanup performs all the "close" actions either at the very end or as we go.
func (obj *World) cleanup() error {
	var errs error
	for i := len(obj.cleanups) - 1; i >= 0; i-- { // reverse
		f := obj.cleanups[i]
		if err := f(); err != nil {
			errs = errwrap.Append(errs, err)
		}
	}
	obj.cleanups = nil // clean
	return errs
}

// Close runs last.
func (obj *World) Close() error {
	return obj.cleanup()
}
