// Copyright (C) 2022 Marius Schellenberger

package sftp

import (
	"cachefs/pkg/provider"
	"context"
	"errors"
	"fmt"
	"io/fs"
	"net"
	"net/url"
	"os"
	"path"
	"path/filepath"
	"time"

	"github.com/go-logr/logr"
	"github.com/pkg/sftp"
	"golang.org/x/crypto/ssh"
	"golang.org/x/crypto/ssh/knownhosts"
)

var (
	_ provider.FS = (*FS)(nil)
	_ SFTP        = (*sftp.Client)(nil)
	_ SFTP        = (*connError)(nil)
)

type SFTP interface {
	Remove(string) error
	Open(string) (*sftp.File, error)
	OpenFile(string, int) (*sftp.File, error)
	Stat(string) (fs.FileInfo, error)
	ReadDir(string) ([]fs.FileInfo, error)
	MkdirAll(string) error
	Wait() error
	Close() error
}

type FS struct {
	log       logr.Logger
	root      string
	addr      string
	ctx       context.Context
	conn      net.Conn
	cfg       *ssh.ClientConfig
	c         *ssh.Client
	client    SFTP
	cancel    func()
	keepalive bool
}

func NewFS(u *url.URL, block bool, log logr.Logger) (fs *FS, err error) {
	cfg, err := sshConfig(u)
	if err != nil {
		return
	}
	fs = &FS{
		log:    log.WithValues("host", u.Host),
		root:   u.Path,
		addr:   u.Host,
		cfg:    cfg,
		client: connError{},
	}
	ctx, cancel := context.WithCancel(context.Background())
	fs.cancel = cancel
	go fs.connect(ctx)
	if block {
		for {
			if _, e := fs.Stat("."); e != ErrNotConnected {
				return
			}
			time.Sleep(time.Millisecond * 100)
		}
	}
	return
}

func (fs *FS) dial() (err error) {
	fs.conn, err = net.DialTimeout("tcp", fs.addr, fs.cfg.Timeout)
	if err != nil {
		return
	}
	c, chans, reqs, err := ssh.NewClientConn(fs.conn, fs.addr, fs.cfg)
	if err != nil {
		return
	}
	fs.c = ssh.NewClient(c, chans, reqs)
	return nil
}

func (fs *FS) connect(ctx context.Context) {
	log := fs.log
	for {
		log.V(2).Info("connecting")
		err := fs.dial()
		if err == nil {
			log.V(2).Info("connected")
			if !fs.keepalive {
				go fs.keepAlive(ctx)
				fs.keepalive = true
			}
			client, err := sftp.NewClient(fs.c,
				sftp.UseFstat(true),
				sftp.UseConcurrentReads(true),
				sftp.UseConcurrentWrites(true),
				//sftp.MaxPacketChecked(32768*2),
				//sftp.MaxPacketUnchecked(32768*2),
				//sftp.MaxConcurrentRequestsPerFile(128),
			)
			if err == nil {
				fs.client = client
				select {
				case <-ctx.Done():
					return
				case e := <-fs.wait(ctx):
					log.V(2).Info("connection lost", "err", e)
					fs.conn.Close()
					fs.client = connError{}
					fs.c = nil
				}
			} else {
				log.Error(err, "error creating client")
			}
		} else {
			log.Error(err, "error connecting")
		}
		select {
		case <-ctx.Done():
			return
		case <-time.After(time.Second * 2):
		}
	}
}

func (fs *FS) wait(ctx context.Context) <-chan error {
	w := make(chan error)
	go func() {
		select {
		case <-ctx.Done():
		case w <- fs.client.Wait():
		}
	}()
	return w
}

func (fs *FS) keepAlive(ctx context.Context) {
	t := time.NewTicker(time.Second * 15)
	defer t.Stop()
	for {
		if fs.c != nil {
			_, _, err := fs.c.SendRequest("keepalive@cachefs", true, nil)
			if err != nil {
				fs.log.Error(err, "sending keep-alive request failed")
			} else {
				fs.conn.SetReadDeadline(time.Now().Add(time.Second * 20))
			}
		}
		select {
		case <-ctx.Done():
			return
		case <-t.C:
		}
	}
}

func (fs *FS) Remove(p string) error {
	return fs.client.Remove(fs.path(p))
}

func (fs *FS) Open(p string) (provider.File, error) {
	f, err := fs.client.Open(fs.path(p))
	return &file{f, fs}, err
}

func (fs *FS) OpenFile(p string, flags int, _ os.FileMode) (provider.File, error) {
	f, err := fs.client.OpenFile(fs.path(p), flags)
	return &file{f, fs}, err
}

func (fs *FS) Fstat(_ uintptr) (int64, error) {
	return 0, provider.ErrFstat
}

func (fs *FS) Stat(p string) (fs.FileInfo, error) {
	return fs.client.Stat(fs.path(p))
}

func (fs *FS) Root() string {
	return fs.root
}

func (fs *FS) MkdirAll(p string, _ os.FileMode) error {
	return fs.client.MkdirAll(fs.path(p))
}

func (fs *FS) Close() error {
	fs.cancel()
	fs.client.Close()
	return fs.c.Close()
}

func (fs *FS) path(p string) string {
	return filepath.Join(fs.root, filepath.FromSlash(path.Clean("/"+p)))
}

func sshConfig(u *url.URL) (c *ssh.ClientConfig, err error) {
	if u.User == nil {
		return nil, errors.New("missing username")
	}
	q, err := url.ParseQuery(u.RawQuery)
	if err != nil {
		return nil, err
	}
	c = &ssh.ClientConfig{
		Config: ssh.Config{
			//KeyExchanges: []string{"curve25519-sha256"},
			//Ciphers: []string{"aes128-ctr"},
			Ciphers: []string{"chacha20-poly1305@openssh.com"},
			//MACs:    []string{"hmac-sha2-256"},
		},
		User:    u.User.Username(),
		Timeout: time.Second * 30,
	}
	known := q.Get("known_hosts")
	if known == "" {
		home, err := os.UserHomeDir()
		if err != nil {
			return nil, fmt.Errorf("error reading user home: %w", err)
		}
		known = filepath.Join(home, ".ssh", "known_hosts")
	}
	c.HostKeyCallback, err = knownhosts.New(known)
	if err != nil {
		return nil, err
	}
	if pw, ok := u.User.Password(); ok {
		c.Auth = []ssh.AuthMethod{ssh.Password(pw)}
	} else {
		buf, err := os.ReadFile(q.Get("key"))
		if err != nil {
			return nil, err
		}
		signer, err := ssh.ParsePrivateKey(buf)
		c.Auth = []ssh.AuthMethod{ssh.PublicKeys(signer)}
	}
	return
}
