// Copyright (C) 2020-2021  Nexedi SA and Contributors.
//                          Kirill Smelkov <kirr@nexedi.com>
//
// This program is free software: you can Use, Study, Modify and Redistribute
// it under the terms of the GNU General Public License version 3, or (at your
// option) any later version, as published by the Free Software Foundation.
//
// You can also Link and Combine this program with other software covered by
// the terms of any of the Free Software licenses or any of the Open Source
// Initiative approved licenses and Convey the resulting work. Corresponding
// source of such a combination shall include the source code for all other
// software used.
//
// This program is distributed WITHOUT ANY WARRANTY; without even the implied
// warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
//
// See COPYING file for full licensing terms.
// See https://www.nexedi.com/licensing for rationale and options.

package neo

import (
	"context"
	"errors"
	"fmt"
	"io/ioutil"
	"net/url"
	"os"
	"os/exec"
	"strings"
	"testing"
	"time"

	"lab.nexedi.com/kirr/neo/go/internal/xexec"
	"lab.nexedi.com/kirr/neo/go/internal/xtesting"
	"lab.nexedi.com/kirr/neo/go/zodb"

	"lab.nexedi.com/kirr/go123/xerr"
	"lab.nexedi.com/kirr/go123/xnet"
	"lab.nexedi.com/kirr/go123/xsync"

	"lab.nexedi.com/kirr/neo/go/neo/internal/tneonet"
	"lab.nexedi.com/kirr/neo/go/neo/neonet"
	"lab.nexedi.com/kirr/neo/go/neo/proto"
	bsqlite "lab.nexedi.com/kirr/neo/go/neo/storage/sqlite"
)

// NEOSrv represents running NEO server.
type NEOSrv interface {
	// XXX kill or restore?
	//ClusterName() string // name of the cluster
	//MasterAddr()  string // address of the master
	URL() string // zurl to access this NEO server

	Bugs() []string // list of known server bugs

	// BugEncFixed reports whether server, instead of autodetection,
	// supports only fixed encoding, and, if yes, which one.
	BugEncFixed() (bool, proto.Encoding)
}

// NEOSrvOptions represents options for a NEO server.
type NEOSrvOptions struct {
	workdir string // location for database and log files
	name    string // name of the cluster
	// nmaster
	// npartition
	// nreplica

	SSL bool // whether to use SSL for node-node exchange
}

// ---- NEO/py ----

// NEOPySrv represents running NEO/py server.
//
// Create it with StartNEOPySrv.
type NEOPySrv struct {
	pysrv       *xexec.Cmd    // spawned `runneo.py`
	opt         NEOSrvOptions // options for spawned server
	cancel      func()        // to stop pysrv
	done        chan struct{} // ready after Wait completes
	errExit     error         // error from Wait

	masterAddr string // address of master in spawned cluster
}

func (_ *NEOPySrv) Bugs() []string {
	return []string{"encfixed"}
}

func (_ *NEOPySrv) BugEncFixed() (bool, proto.Encoding) {
	return true, 'N' // NEO/py 1.12
}

// StartNEOPySrv starts NEO/py server specified by options.
func StartNEOPySrv(opt NEOSrvOptions) (_ *NEOPySrv, err error) {
	workdir := opt.workdir
	defer xerr.Contextf(&err, "start neo/py %s/%s", workdir, opt.name)

	ctx, cancel := context.WithCancel(context.Background())

	readyf := workdir + "/ready"
	err = os.Remove(readyf)
	if os.IsNotExist(err) {
		err = nil
	}
	if err != nil {
		return nil, err
	}

	n := &NEOPySrv{opt: opt, cancel: cancel, done: make(chan struct{})}

	// XXX $PYTHONPATH to top, so that `import neo` works?
	n.pysrv = xexec.Command("./py/runneo.py", workdir, opt.name)
	if opt.SSL {
		n.pysrv.Args = append(n.pysrv.Args, "ca="  +opt.CA())
		n.pysrv.Args = append(n.pysrv.Args, "cert="+opt.Cert())
		n.pysrv.Args = append(n.pysrv.Args, "key=" +opt.Key())
	}
	// $TEMP -> workdir  (else NEO/py creates another one for e.g. coverage)
	n.pysrv.Env = append(os.Environ(), "TEMP="+workdir)
	n.pysrv.Stdin = nil
	n.pysrv.Stdout = os.Stdout
	n.pysrv.Stderr = os.Stderr
	err = n.pysrv.Start(ctx)
	if err != nil {
		return nil, err
	}
	go func() {
		n.errExit = n.pysrv.Wait()
		close(n.done)
	}()
	defer func() {
		if err != nil {
			n.Close()
		}
	}()

	// wait till spawned NEO is ready to serve clients
	for {
		select {
		default:
		case <-n.done:
			return nil, n.errExit
		}

		_, err := os.Stat(readyf)
		if err == nil {
			break // NEO cluster spawned by runneo.py is running
		}
		if os.IsNotExist(err) {
			err = nil // not yet
		}
		if err != nil {
			return nil, err
		}

		time.Sleep(10*time.Millisecond)
	}

	// retrieve master address
	masterAddr, err := ioutil.ReadFile(readyf)
	if err != nil {
		return nil, err
	}
	n.masterAddr = string(masterAddr)

	return n, nil
}

func (n *NEOPySrv) ClusterName() string {
	return n.opt.name
}

func (n *NEOPySrv) MasterAddr() string {
	return n.masterAddr
}

func (n *NEOPySrv) URL() string {
	return fmt.Sprintf("%s%s/%s", n.opt.URLPrefix(), n.MasterAddr(), n.ClusterName())
}

func (n *NEOPySrv) Close() (err error) {
	defer xerr.Contextf(&err, "stop neo/py %s", n.opt.workdir)

	n.cancel()
	<-n.done
	err = n.errExit
	if _, ok := err.(*exec.ExitError); ok {
		err = nil // ignore exit status - it is always !0 on kill
	}
	return err
}

// ---- NEO/go ----

// NEOGoSrv represents running NEO/go server.
//
// Create it with StartNEOGoSrv.
type NEOGoSrv struct {
	opt         NEOSrvOptions    // server options
	cancel      func()           // to stop spawned nodes
	serveWG     *xsync.WorkGroup // services are spawned under serveWG

	// network access-point for all nodes
	// (this is functional test, lonet is not injected)
	net xnet.Networker

	Ml    xnet.Listener    // M listens here
	Sl    xnet.Listener    // S listens here
	M     *Master          // M service
	S     *Storage         // S service
	Sback *bsqlite.Backend // S backend
}

func (_ *NEOGoSrv) Bugs() []string {
	return []string{"nocommit"}
}

func (_ *NEOGoSrv) BugEncFixed() (bool, proto.Encoding) {
	return false, 0 // NEO/go server autodetects client encoding
}

// StartNEOGoSrv starts NEO/go server specified by options.
func StartNEOGoSrv(opt NEOSrvOptions) (_ *NEOGoSrv, err error) {
	defer xerr.Contextf(&err, "start neo/go %s/%s", opt.workdir, opt.name)

	// XXX release resources on error

	ctx, cancel := context.WithCancel(context.Background())
	serveWG := xsync.NewWorkGroup(ctx)

	n := &NEOGoSrv{opt: opt, cancel: cancel, serveWG: serveWG}
	defer func() {
		if err != nil {
			n.Close() // ignore err
		}
	}()

	// FIXME tune glog to write logs into workdir

	net, err := neonet.Join(ctx, neonet.Config{
		CA:   opt.CA(),
		Cert: opt.Cert(),
		Key:  opt.Key(),
	})
	if err != nil {
		return nil, err
	}
	n.net = net

	n.Ml, err = net.Listen(ctx, "");  if err != nil { return nil, err }
	n.Sl, err = net.Listen(ctx, "");  if err != nil { return nil, err }

	n.M = NewMaster(opt.name, net)

	serveWG.Go(func(ctx context.Context) error {
		return n.M.Run(ctx, n.Ml)
	})

	n.Sback, err = bsqlite.Open(fmt.Sprintf("%s/%s.sqlite", opt.workdir, opt.name))
	if err != nil {
		return nil, err
	}
	n.S = NewStorage(opt.name, n.MasterAddr(), net, n.Sback)
	serveWG.Go(func(ctx context.Context) error {
		return n.S.Run(ctx, n.Sl)
	})

	// wait till spawned NEO cluster can become operational
	// XXX better use tracing and wait for traceMasterStartReady event
	for {
		if err := ctx.Err(); err != nil {
			return nil, err
		}

		err = n.M.Start()
		if err == nil {
			break
		}
		if !strings.HasSuffix(err.Error(), "start: cluster is non-operational") { // XXX
			return nil, err
		}

		time.Sleep(10*time.Millisecond)

	}

	return n, nil

}

func (n *NEOGoSrv) Close() (err error) {
	defer xerr.Contextf(&err, "stop neo/go %s", n.opt.workdir)

	n.cancel()
	err = n.serveWG.Wait()
	if errors.Is(err, context.Canceled) {
		err = nil // cancel is expected
	}

	// ownership of Ml is transferred to M when M starts; M closes its l on stop.
	if n.M == nil && n.Ml != nil {
		__ := n.Ml.Close()
		err = xerr.First(err, __)
	}
	// ----//---- for Sl and S
	if n.S == nil && n.Sl != nil {
		__ := n.Ml.Close()
		err = xerr.First(err, __)
	}

	if n.Sback != nil {
		__ := n.Sback.Close() // XXX should be in Storage?
		err = xerr.First(err, __)
	}

	if n.net != nil {
		__ := n.net.Close()
		err = xerr.First(err, __)
	}
	return err
}

func (n *NEOGoSrv) MasterAddr() string {
	return n.Ml.Addr().String()
}

func (n *NEOGoSrv) URL() string {
	return fmt.Sprintf("%s%s/%s", n.opt.URLPrefix(), n.MasterAddr(), n.opt.name)
}


// ----------------

const npytests = "../../neo/tests/"

// CA/Cert/Key files to use if opt.SSL=y; empty if SSL=n.
func (opt NEOSrvOptions) CA() string {
	if !opt.SSL { return "" }
	return npytests + "ca.crt"
}
func (opt NEOSrvOptions) Cert() string {
	if !opt.SSL { return "" }
	return npytests + "node.crt"
}
func (opt NEOSrvOptions) Key() string {
	if !opt.SSL { return "" }
	return npytests + "node.key"
}

// URLPrefix returns start of URL for a NEO server started with opt.
// e.g. neo:// or neos://ca=1;cert=2;key=3@
// To be come complete returned URL has to be appended with host and path parts.
func (opt NEOSrvOptions) URLPrefix() string {
	zurl := ""
	if !opt.SSL {
		zurl = "neo://"
	} else {
		zurl = "neos://"
		zurl += "ca="   + url.QueryEscape(opt.CA())   +";"
		zurl += "cert=" + url.QueryEscape(opt.Cert()) +";"
		zurl += "key="  + url.QueryEscape(opt.Key())
		zurl += "@"
	}
	return zurl
}



// ----------------

// tOptions represents options for testing.
// XXX dup in ZEO
type tOptions struct {
	Preload string // preload database with data from this location
}

// withNEOSrv tests f with all kind of NEO servers.
func withNEOSrv(t *testing.T, f func(t *testing.T, nsrv NEOSrv), optv ...tOptions) {
	t.Helper()

	opt := tOptions{}
	if len(optv) > 1 {
		panic("multiple tOptions not allowed")
	}
	if len(optv) == 1 {
		opt = optv[0]
	}

	// inWorkDir runs f under dedicated work directory.
	inWorkDir := func(t *testing.T, f func(workdir string)) {
		t.Helper()
		X := xtesting.FatalIf(t)
		work, err := ioutil.TempDir("", "neo"); X(err)
		//defer os.RemoveAll(work)

		f(work)
	}

	// TODO + all variants with nreplica=X, npartition=Y, nmaster=Z, ... ?

	for _, ssl := range []bool{false, true} {
		kind := ""
		if ssl { kind = "ssl" } else { kind = "!ssl" }

		neoOpt := NEOSrvOptions{
			name: "1",
			SSL:  ssl,
		}

		// startNEOpy starts NEO/py server with database in workdir/
		// and preloads it with data according to opt.Preload.
		startNEOpy := func(t *testing.T, workdir string, opt tOptions) *NEOPySrv {
			X := xtesting.FatalIf(t)

			neoOpt := neoOpt
			neoOpt.workdir = workdir

			npy, err := StartNEOPySrv(neoOpt); X(err)
			if opt.Preload != "" {
				cmd := exec.Command("python", "-c",
					"from neo.scripts.neomigrate import main; main()",
					"-q",
					"-c", npy.ClusterName(),
				)
				if ssl {
					cmd.Args = append(cmd.Args, "--ca",   neoOpt.CA())
					cmd.Args = append(cmd.Args, "--cert", neoOpt.Cert())
					cmd.Args = append(cmd.Args, "--key",  neoOpt.Key())
				}
				cmd.Args = append(cmd.Args,
					opt.Preload,
					npy.MasterAddr(),
				)
				cmd.Stdin  = nil
				cmd.Stdout = os.Stdout
				cmd.Stderr = os.Stderr
				err := cmd.Run(); X(err)
			}
			return npy
		}

		// NEO/py
		t.Run("py/"+kind, func(t *testing.T) {
			t.Helper()
			// XXX needpy
			inWorkDir(t, func(workdir string) {
				X := xtesting.FatalIf(t)

				npy := startNEOpy(t, workdir, opt)
				defer func() {
					err := npy.Close(); X(err)
				}()

				f(t, npy)
			})
		})

		// NEO/go
		t.Run("go/"+kind, func(t *testing.T) {
			t.Helper()
			inWorkDir(t, func(workdir string) {
				X := xtesting.FatalIf(t)

				neoOpt := neoOpt
				neoOpt.workdir = workdir

				// start NEO/py first. We need it to create the
				// database and to preload it, because NEO/go
				// does not currently support commit.
				npy := startNEOpy(t, workdir, opt)
				err := npy.Close(); X(err)
				// start NEO/py again to flush ttrans/tobj ->  trans/obj tables
				// if preload was requested. We need this because currently
				// NEO/go does not implement recovery (which is write operation),
				// and NEO/py leaves data after ZODB commit in ttrans/tobj (to
				// be moved to trans/obj on next ZODB commit). See
				// lab.nexedi.com/nexedi/neoppod/commit/7eb7cf1b
				if opt.Preload != "" {
					optRestart := opt
					optRestart.Preload = ""
					npy = startNEOpy(t, workdir, optRestart)
					err = npy.Close(); X(err)
				}

				// start NEO/go, as the database is created and preloaded
				ngo, err := StartNEOGoSrv(neoOpt); X(err)
				defer func() {
					err := ngo.Close(); X(err)
				}()

				f(t, ngo)
			})
		})
	}
}

// withNEO tests f on all kinds of NEO servers connected to by NEO client with
// all kind of default settings.
func withNEO(t *testing.T, f func(t *testing.T, nsrv NEOSrv, ndrv *Client), optv ...tOptions) {
	t.Helper()
	withNEOSrv(t, func(t *testing.T, nsrv NEOSrv) {
		t.Helper()

		// test client with N,M and M,N dial encoding try orders. This
		//
		// - makes sure that both encodings are working ok (when
		//   testing against NEO/go), and
		//
		// - verifies that encoding autodetection actually works when
		//   NEO/go client connects NEO/py server.
		//
		//   For example if NEO/py server implments only M encoding,
		//   testing with N,M verifies retry after handshaking with
		//   enc=N.
		encTryOrder0 := *tneonet.DialEncTryOrder
		defer func() {
			*tneonet.DialEncTryOrder = encTryOrder0
		}()

		for i := range encTryOrder0 {
			var encv []proto.Encoding
			encv = append(encv, encTryOrder0[i])
			encv = append(encv, encTryOrder0[:i]...)
			encv = append(encv, encTryOrder0[i+1:]...)

			encvs := ""
			for j, enc := range encv {
				if j > 0 {
					encvs += ","
				}
				encvs += fmt.Sprintf("%c", enc)
			}

			noautodetect, srvEnc := nsrv.BugEncFixed()
			name := "enc="
			if noautodetect {
				name += fmt.Sprintf("%c(dialTryOrder=%v)", srvEnc, encvs)
			} else {
				name += fmt.Sprintf("%c", encv[0])
			}

			t.Run(name, func(t *testing.T) {
				X := xtesting.FatalIf(t)

				if noautodetect && len(encv) > 1 && encv[0] == srvEnc {
					// skip - the other case when encv[0] != srvEnc will
					// both test autodetection and exchanve via srvEnc.
					t.Skip("skip: does not excercise client redial")
				}


				*tneonet.DialEncTryOrder = encv
				ndrv, _, err := neoOpen(nsrv.URL(),
							&zodb.DriverOptions{ReadOnly: true}); X(err)
				defer func() {
					err := ndrv.Close(); X(err)
				}()

				// verify that link was established with either:
				// - encv[0] encoding (NEO/go = server supports autodetection)
				// - server encoding  (NEO/py)
				encOK := (*tneonet.DialEncTryOrder)[0]
				if noautodetect {
					encOK = srvEnc
				}
				mlink, err := ndrv.masterLink(context.Background()); X(err)

				enc := mlink.Encoding()
				if enc != encOK {
					t.Fatalf("connected with encoding %c  ; want %c", enc, encOK)
				}

				f(t, nsrv, ndrv)
			})
		}
	}, optv...)
}


// XXX connect with wrong clusterName -> rejected

func TestEmptyDB(t *testing.T) {
	withNEO(t, func(t *testing.T, nsrv NEOSrv, n *Client) {
		xtesting.DrvTestEmptyDB(t, n)
	})
}

func TestLoad(t *testing.T) {
	X := xtesting.FatalIf(t)

	data := "../zodb/storage/fs1/testdata/1.fs"
	txnvOk, err := xtesting.LoadDBHistory(data); X(err)

	withNEO(t, func(t *testing.T, nsrv NEOSrv, n *Client) {
		xtesting.DrvTestLoad(t, n, txnvOk, nsrv.Bugs()...)
	}, tOptions{
		Preload: data,
	})
}

func TestWatch(t *testing.T) {
	withNEOSrv(t, func(t *testing.T, nsrv NEOSrv) {
		xtesting.DrvTestWatch(t, nsrv.URL(), openClientByURL, nsrv.Bugs()...)
	})
}


func neoOpen(zurl string, opt *zodb.DriverOptions) (_ *Client, at0 zodb.Tid, err error) {
	defer xerr.Contextf(&err, "openneo %s", zurl)
	u, err := url.Parse(zurl)
	if err != nil {
		return nil, 0, err
	}

	n, at0, err := openClientByURL(context.Background(), u, opt)
	if err != nil {
		return nil, 0, err
	}

	return n.(*Client), at0, nil
}
