package proxy

import (
	"errors"
	"fmt"
	"log"
	"net"
	"strconv"
	"sync"
	"time"

	socks5 "github.com/cloudfoundry/go-socks5"

	"golang.org/x/crypto/ssh"
	"golang.org/x/net/context"
)

var netListen = net.Listen

type hostKey interface {
	Get(username, privateKey, serverURL string) (ssh.PublicKey, error)
}

type DialFunc func(network, address string) (net.Conn, error)

type Socks5Proxy struct {
	hostKey           hostKey
	port              int
	started           bool
	keepAliveInterval time.Duration
	logger            *log.Logger
	mtx               sync.Mutex
}

func NewSocks5Proxy(hostKey hostKey, logger *log.Logger, keepAliveInterval time.Duration) *Socks5Proxy {
	return &Socks5Proxy{
		hostKey:           hostKey,
		started:           false,
		logger:            logger,
		keepAliveInterval: keepAliveInterval,
	}
}

func (s *Socks5Proxy) SetListenPort(port int) {
	s.port = port
}

func (s *Socks5Proxy) Start(username, key, url string) error {
	if s.isStarted() {
		return nil
	}

	dialer, err := s.Dialer(username, key, url)
	if err != nil {
		return err
	}

	err = s.StartWithDialer(dialer)
	if err != nil {
		return err
	}

	return nil
}

// thread safety
func (s *Socks5Proxy) isStarted() bool {
	s.mtx.Lock()
	defer s.mtx.Unlock()
	return s.started
}

func (s *Socks5Proxy) Dialer(username, key, url string) (DialFunc, error) {
	if username == "" {
		username = "jumpbox"
	}

	signer, err := ssh.ParsePrivateKey([]byte(key))
	if err != nil {
		return nil, fmt.Errorf("parse private key: %s", err)
	}

	hostKey, err := s.hostKey.Get(username, key, url)
	if err != nil {
		return nil, fmt.Errorf("get host key: %s", err)
	}

	clientConfig := NewSSHClientConfig(username, ssh.FixedHostKey(hostKey), ssh.PublicKeys(signer))

	conn, err := ssh.Dial("tcp", url, clientConfig)
	if err != nil {
		return nil, fmt.Errorf("ssh dial: %s", err)
	}

	errChan := make(chan error)

	go func(cl *ssh.Client, errChan chan error) {
		t := time.NewTicker(s.keepAliveInterval)
		for { //nolint:staticcheck
			select {
			case <-t.C:
				_, _, err := cl.SendRequest("bosh-cli-keep-alive@bosh.io", true, nil)
				if err != nil {
					errChan <- err
				}
			}
		}
	}(conn, errChan)

	go func(errChan chan error) {
		for { //nolint:staticcheck
			select {
			case err := <-errChan:
				s.logger.Printf("error sending ssh keep-alive: %s", err)
			}
		}
	}(errChan)

	return conn.Dial, nil
}

func (s *Socks5Proxy) StartWithDialer(dialer DialFunc) error {
	conf := &socks5.Config{
		Dial: func(ctx context.Context, network, addr string) (net.Conn, error) {
			return dialer(network, addr)
		},
		Logger: s.logger,
	}

	server, err := socks5.New(conf)
	if err != nil {
		return fmt.Errorf("new socks5 server: %s", err) // not tested
	}

	s.mtx.Lock()
	defer s.mtx.Unlock()
	if s.port == 0 {
		s.port, err = openPort()
		if err != nil {
			return fmt.Errorf("open port: %s", err)
		}
	}

	listener, err := server.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", s.port))
	if err != nil {
		return err
	}
	go func() {
		server.Serve(listener) //nolint:errcheck
	}()

	s.started = true
	return nil
}

func (s *Socks5Proxy) Addr() (string, error) {
	s.mtx.Lock()
	defer s.mtx.Unlock()
	if s.port == 0 {
		return "", errors.New("socks5 proxy is not running")
	}
	return fmt.Sprintf("127.0.0.1:%d", s.port), nil
}

func openPort() (int, error) {
	l, err := netListen("tcp", "localhost:0")
	if err != nil {
		return 0, err
	}

	defer l.Close() //nolint:errcheck
	_, port, err := net.SplitHostPort(l.Addr().String())
	if err != nil {
		return 0, err
	}

	return strconv.Atoi(port)
}
