/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov <fox.cpp@disroot.org>, Maddy Mail Server 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/>.
*/

// Package tests provides the framework for integration testing of maddy.
//
// The packages core object is tests.T object that encapsulates all test
// state. It runs the server using test-provided configuration file and acts as
// a proxy for all interactions with the server.
package tests

import (
	"bufio"
	"bytes"
	"flag"
	"fmt"
	"math/rand"
	"net"
	"os"
	"os/exec"
	"path/filepath"
	"strconv"
	"strings"
	"sync"
	"testing"
	"time"

	"github.com/foxcpp/go-mockdns"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

var (
	TestBinary  = "./maddy"
	CoverageOut string
	DebugLog    bool
)

type T struct {
	*testing.T

	testDir string
	cfg     string

	dnsServ  *mockdns.Server
	env      []string
	ports    map[string]uint16
	portsRev map[uint16]string

	servProc *exec.Cmd

	reloadedChan chan struct{}
}

func NewT(t *testing.T) *T {
	return &T{
		T:            t,
		ports:        map[string]uint16{},
		portsRev:     map[uint16]string{},
		reloadedChan: make(chan struct{}, 1),
	}
}

// Config sets the configuration to use for the server. It must be called
// before Run.
func (t *T) Config(cfg string) {
	t.Helper()

	t.cfg = cfg

	if t.servProc != nil {
		t.Log("Reloading configuration for running server...")

		configPreable := "state_dir " + filepath.Join(t.testDir, "statedir") + "\n" +
			"runtime_dir " + filepath.Join(t.testDir, "runtimedir") + "\n\n"

		err := os.WriteFile(filepath.Join(t.testDir, "maddy.conf"), []byte(configPreable+t.cfg), os.ModePerm)
		if err != nil {
			t.Fatal("Test configuration failed:", err)
		}

		t.reloadConfig()
	}
}

// DNS sets the DNS zones to emulate for the tested server instance.
//
// If it is not called before Run, DNS(nil) call is assumed which makes the
// mockdns server respond with NXDOMAIN to all queries.
func (t *T) DNS(zones map[string]mockdns.Zone) {
	t.Helper()

	if zones == nil {
		zones = map[string]mockdns.Zone{}
	}
	if _, ok := zones["100.97.109.127.in-addr.arpa."]; !ok {
		zones["100.97.109.127.in-addr.arpa."] = mockdns.Zone{PTR: []string{"client.maddy.test."}}
	}

	if t.dnsServ != nil {
		t.Log("NOTE: Multiple DNS calls, replacing the server instance...")
		require.NoError(t, t.dnsServ.Close())
	}

	dnsServ, err := mockdns.NewServerWithLogger(zones, t, false)
	if err != nil {
		t.Fatal("Test configuration failed:", err)
	}
	dnsServ.Log = t
	t.dnsServ = dnsServ

	t.Cleanup(func() {
		if t.dnsServ == nil {
			return
		}

		// Shutdown the DNS server after maddy to make sure it will not spend time
		// timing out queries.
		if err := t.dnsServ.Close(); err != nil {
			t.Log("Unable to stop the DNS server:", err)
		}
		t.dnsServ = nil
	})
}

// Port allocates the random TCP port for use by test. It will made accessible
// in the configuration via environment variables with name in the form
// TEST_PORT_name.
//
// If there is a port with name remote_smtp, it will be passed as the value for
// the -debug.smtpport parameter.
func (t *T) Port(name string) uint16 {
	if port := t.ports[name]; port != 0 {
		return port
	}

	// TODO: Try to bind on port to test its usability.
	port := rand.Int31n(45536) + 20000
	t.ports[name] = uint16(port)
	t.portsRev[uint16(port)] = name
	return uint16(port)
}

func (t *T) Env(kv string) {
	t.env = append(t.env, kv)
}

func (t *T) ensureCanRun() {
	if t.cfg == "" {
		panic("tests: Run called without configuration set")
	}
	if t.dnsServ == nil {
		// If there is no DNS zones set in test - start a server that will
		// respond with NXDOMAIN to all queries to avoid accidentally leaking
		// any DNS queries to the real world.
		t.Log("NOTE: Explicit DNS(nil) is recommended.")
		t.DNS(nil)
	}

	// Setup file system, create statedir, runtimedir, write out config.
	if t.testDir == "" {
		testDir, err := os.MkdirTemp("", "maddy-tests-")
		if err != nil {
			t.Fatal("Test configuration failed:", err)
		}
		t.testDir = testDir
		t.Log("using", t.testDir)

		if err := os.MkdirAll(filepath.Join(t.testDir, "statedir"), os.ModePerm); err != nil {
			t.Fatal("Test configuration failed:", err)
		}
		if err := os.MkdirAll(filepath.Join(t.testDir, "runtimedir"), os.ModePerm); err != nil {
			t.Fatal("Test configuration failed:", err)
		}

		t.Cleanup(func() {
			if !t.Failed() {
				return
			}

			t.Log("removing", t.testDir)
			assert.NoError(t, os.RemoveAll(t.testDir))
			t.testDir = ""
		})
	}

	configPreable := "state_dir " + filepath.Join(t.testDir, "statedir") + "\n" +
		"runtime_dir " + filepath.Join(t.testDir, "runtimedir") + "\n\n"

	err := os.WriteFile(filepath.Join(t.testDir, "maddy.conf"), []byte(configPreable+t.cfg), os.ModePerm)
	if err != nil {
		t.Fatal("Test configuration failed:", err)
	}
}

func (t *T) buildCmd(additionalArgs ...string) *exec.Cmd {
	// Assigning 0 by default will make outbound SMTP unusable.
	remoteSmtp := "0"
	if port := t.ports["remote_smtp"]; port != 0 {
		remoteSmtp = strconv.Itoa(int(port))
	}

	args := []string{"-config", filepath.Join(t.testDir, "maddy.conf"),
		"-debug.smtpport", remoteSmtp,
		"-debug.dnsoverride", t.dnsServ.LocalAddr().String(),
	}

	if CoverageOut != "" {
		args = append(args, "-test.coverprofile", CoverageOut+"."+strconv.FormatInt(time.Now().UnixNano(), 16))
	}
	if DebugLog {
		args = append(args, "-debug")
	}

	args = append(args, additionalArgs...)

	cmd := exec.Command(TestBinary, args...)

	pwd, err := os.Getwd()
	if err != nil {
		t.Fatal("Test configuration failed:", err)
	}

	// Set environment variables.
	cmd.Env = os.Environ()
	cmd.Env = append(cmd.Env,
		"TEST_PWD="+pwd,
		"TEST_STATE_DIR="+filepath.Join(t.testDir, "statedir"),
		"TEST_RUNTIME_DIR="+filepath.Join(t.testDir, "runtimedir"),
	)
	for name, port := range t.ports {
		cmd.Env = append(cmd.Env, fmt.Sprintf("TEST_PORT_%s=%d", name, port))
	}
	cmd.Env = append(cmd.Env, t.env...)

	return cmd
}

func (t *T) MustRunCLIGroup(args ...[]string) {
	t.ensureCanRun()

	wg := sync.WaitGroup{}
	for _, arg := range args {
		wg.Add(1)
		go func() {
			defer wg.Done()

			_, err := t.RunCLI(arg...)
			if err != nil {
				t.Printf("maddy %v: %v", arg, err)
				t.Fail()
			}
		}()
	}
	wg.Wait()
}

func (t *T) MustRunCLI(args ...string) string {
	s, err := t.RunCLI(args...)
	if err != nil {
		t.Fatalf("maddy %v: %v", args, err)
	}
	return s
}

func (t *T) RunCLI(args ...string) (string, error) {
	t.ensureCanRun()
	cmd := t.buildCmd(args...)

	var stderr, stdout bytes.Buffer
	cmd.Stderr = &stderr
	cmd.Stdout = &stdout

	t.Log("launching maddy", cmd.Args)
	if err := cmd.Run(); err != nil {
		t.Log("Stderr:", stderr.String())
		t.Fatal("Test configuration failed:", err)
	}

	t.Log("Stderr:", stderr.String())

	return stdout.String(), nil
}

// Run completes the configuration of test environment and starts the test server.
//
// T.Close should be called by the end of test to release any resources and
// shutdown the server.
//
// The parameter waitListeners specifies the amount of listeners the server is
// supposed to configure. Run() will block before all of them are up.
func (t *T) Run(waitListeners int) {
	t.ensureCanRun()
	cmd := t.buildCmd("run")

	// Capture maddy log and redirect it.
	logOut, err := cmd.StderrPipe()
	if err != nil {
		t.Fatal("Test configuration failed:", err)
	}

	t.Log("launching maddy", cmd.Args)
	if err := cmd.Start(); err != nil {
		t.Fatal("Test configuration failed:", err)
	}

	serverStarted := make(chan bool)

	go func() {
		defer close(serverStarted)
		scnr := bufio.NewScanner(logOut)
		for scnr.Scan() {
			line := scnr.Text()

			t.Log("maddy:", line)

			if strings.HasPrefix(line, "server started") {
				serverStarted <- true
			}

			if strings.HasPrefix(line, "new server started") {
				select {
				case t.reloadedChan <- struct{}{}:
					t.Log("server reload confirmed, continuing test")
				default:
					t.Log("unexpected reloads detected")
					t.Fail()
				}
			}
		}
		if err := scnr.Err(); err != nil {
			t.Log("stderr I/O error:", err)
		}
	}()

	if !<-serverStarted {
		t.Fatal("Log ended before all expected listeners are up. Start-up error?")
	}

	t.servProc = cmd

	t.Cleanup(t.killServer)
}

func (t *T) StateDir() string {
	return filepath.Join(t.testDir, "statedir")
}

func (t *T) RuntimeDir() string {
	return filepath.Join(t.testDir, "statedir")
}

func (t *T) killServer() {
	if err := t.servProc.Process.Signal(os.Interrupt); err != nil {
		t.Log("Unable to kill the server process:", err)
		assert.NoError(t, os.RemoveAll(t.testDir))
		return // Return, as now it is pointless to wait for it.
	}

	go func() {
		time.Sleep(5 * time.Second)
		if t.servProc != nil {
			t.Log("Killing possibly hung server process")
			t.servProc.Process.Kill() //nolint:errcheck
		}
	}()

	if err := t.servProc.Wait(); err != nil {
		t.Error("The server did not stop cleanly, deadlock?")
	}

	t.servProc = nil

	if err := os.RemoveAll(t.testDir); err != nil {
		t.Log("Failed to remove test directory:", err)
	}
	t.testDir = ""
}

func (t *T) Close() {
	t.Log("close is no-op")
}

// Printf implements Logger interfaces used by some libraries.
func (t *T) Printf(f string, a ...interface{}) {
	t.Logf(f, a...)
}

// Conn6 connects to the server listener at the specified named port using IPv6 loopback.
func (t *T) Conn6(portName string) Conn {
	port := t.ports[portName]
	if port == 0 {
		panic("tests: connection for the unused port name is requested")
	}

	conn, err := net.Dial("tcp6", "[::1]:"+strconv.Itoa(int(port)))
	if err != nil {
		t.Fatal("Could not connect, is server listening?", err)
	}

	return Conn{
		T:            t,
		WriteTimeout: 1 * time.Second,
		ReadTimeout:  15 * time.Second,
		Conn:         conn,
		Scanner:      bufio.NewScanner(conn),
	}
}

// Conn4 connects to the server listener at the specified named port using one
// of 127.0.0.0/8 addresses as a source.
func (t *T) Conn4(sourceIP, portName string) Conn {
	port := t.ports[portName]
	if port == 0 {
		panic("tests: connection for the unused port name is requested")
	}

	localIP := net.ParseIP(sourceIP)
	if localIP == nil {
		panic("tests: invalid localIP argument")
	}
	if localIP.To4() == nil {
		panic("tests: only IPv4 addresses are allowed")
	}

	conn, err := net.DialTCP("tcp4", &net.TCPAddr{
		IP:   localIP,
		Port: 0,
	}, &net.TCPAddr{
		IP:   net.IPv4(127, 0, 0, 1),
		Port: int(port),
	})
	if err != nil {
		t.Fatal("Could not connect, is server listening?", err)
	}

	return Conn{
		T:            t,
		WriteTimeout: 1 * time.Second,
		ReadTimeout:  15 * time.Second,
		Conn:         conn,
		Scanner:      bufio.NewScanner(conn),
	}
}

var (
	DefaultSourceIP    = net.IPv4(127, 109, 97, 100)
	DefaultSourceIPRev = "100.97.109.127"
)

func (t *T) ConnUnnamed(port uint16) Conn {
	conn, err := net.DialTCP("tcp4", &net.TCPAddr{
		IP:   DefaultSourceIP,
		Port: 0,
	}, &net.TCPAddr{
		IP:   net.IPv4(127, 0, 0, 1),
		Port: int(port),
	})
	if err != nil {
		t.Fatal("Could not connect, is server listening?", err)
	}

	return Conn{
		T:            t,
		WriteTimeout: 1 * time.Second,
		ReadTimeout:  15 * time.Second,
		Conn:         conn,
		Scanner:      bufio.NewScanner(conn),
	}
}

func (t *T) Conn(portName string) Conn {
	port := t.ports[portName]
	if port == 0 {
		panic("tests: connection for the unused port name is requested")
	}

	return t.ConnUnnamed(port)
}

func (t *T) Subtest(name string, f func(t *T)) {
	t.T.Run(name, func(subTT *testing.T) {
		subT := *t
		subT.T = subTT
		f(&subT)
	})
}

func init() {
	flag.StringVar(&TestBinary, "integration.executable", "./maddy", "executable to test")
	flag.StringVar(&CoverageOut, "integration.coverprofile", "", "write coverage stats to file (requires special maddy executable)")
	flag.BoolVar(&DebugLog, "integration.debug", false, "pass -debug to maddy executable")
}
