// Copyright (C) 2020-2023  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"
	"lab.nexedi.com/kirr/neo/go/neo/xneo"
	bsqlite "lab.nexedi.com/kirr/neo/go/neo/storage/sqlite"
)

// NEOSrv represents running NEO server.
type NEOSrv interface {
	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)

	LogTail() (string, error) // tail of log files
}

// 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 int    // how many masters our cluster has
	// 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

	masterAddrSlice []string // addresses 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{})}

	// TODO set $PYTHONPATH to top, so that `import neo` works without `pip install -e .`
	n.pysrv = xexec.Command("./py/runneo.py", workdir, opt.name)
	n.pysrv.Args = append(n.pysrv.Args, "master_count="+fmt.Sprintf("%v", opt.nmaster))
	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.masterAddrSlice = strings.Split(string(masterAddr), " ")

	return n, nil
}

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

func (n *NEOPySrv) URL() string {
	return fmt.Sprintf("%s%s/%s", n.opt.URLPrefix(), strings.Join(n.masterAddrSlice, ","), n.clusterName())
}

func (n *NEOPySrv) LogTail() (string, error) {
	stail, err := n.logFileTail("storage_0.log")
	if err != nil {
		return "", err
	}
	mtail, err := n.logFileTail("master_0.log")
	if err != nil {
		return "", err
	}
	// FIXME _ admin_0.log and generally automatically detect all log files
	return stail + "\n\n" + mtail, nil
}

func (n *NEOPySrv) logFileTail(logfilename string) (string, error) {
	c := xexec.Command("python", "-m", "neo.scripts.neolog", fmt.Sprintf("%s/%s", n.opt.workdir, logfilename))
	o, err := c.Output()
	if err != nil {
		return "", err
	}
	return fmt.Sprintf("log file '%s' tail:\n\n", logfilename) + tail(string(o)), nil
}

func tail(s string) (string) {
	sslice := strings.Split(s, "\n")
	icount := len(sslice)
	tailsize := 20
	if icount < tailsize {
		tailsize = icount
	}
	return strings.Join(sslice[icount - tailsize:], "\n")
}

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)

	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.masterAddrSlice(), 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 !errors.Is(err, ErrStartNonOperational) {
			return nil, err
		}

		time.Sleep(10*time.Millisecond)

	}

	return n, nil
}

func (n *NEOGoSrv) LogTail() (string, error) {
	return "", nil // FIXME return data from glog
}

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()
		err = xerr.First(err, __)
	}

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

func (n *NEOGoSrv) masterAddrSlice() []string {
	return []string {n.Ml.Addr().String()}
}

func (n *NEOGoSrv) URL() string {
	return fmt.Sprintf("%s%s/%s", n.opt.URLPrefix(), strings.Join(n.masterAddrSlice(), ","), 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.
// TODO -> xtesting
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 func() {
			if t.Failed() {
				t.Logf("leaving NEO database and server log files in %s", work)
			} else {
				os.RemoveAll(work)
			}
		}()

		f(work)
	}

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


	for _, nmaster := range []int{1, 1} {

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

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

			// 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,
						// py internal representation of master_nodes is a
						// string where each addr is separated by 1 space
						strings.Join(npy.masterAddrSlice, " "),
					)
					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()
				// TODO t.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 implements 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) {
				defer func() {
					if t.Failed() {
						logTail, err := nsrv.LogTail()
						if err != nil {
							t.Logf("LogTail failed: %s", err)
						} else {
							t.Logf("NEO log tail:\n\n%s", logTail)
						}
					}
				}()

				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 exchange 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
				}
				err = ndrv.node.WithOperational(context.Background(), func(mlink *neonet.NodeLink, _ *xneo.ClusterState) error {
					enc := mlink.Encoding()
					if enc != encOK {
						t.Fatalf("connected with encoding %c  ; want %c", enc, encOK)
					}
					return nil
				}); X(err)

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


// TODO verify: 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
}
