package cli_test

import (
	"bufio"
	"context"
	"fmt"
	"os"
	"strings"
	"testing"
	"time"

	"github.com/featurebasedb/featurebase/v3/cli"
	"github.com/featurebasedb/featurebase/v3/dax/server/test"
	"github.com/featurebasedb/featurebase/v3/logger"
	"github.com/stretchr/testify/require"
)

func TestCLIIntegration(t *testing.T) {
	if testing.Short() {
		t.Skip("skipping integration test")
	}

	ctx := context.Background()

	t.Run("Stubbed Framework", func(t *testing.T) {
		mc := test.MustRunManagedCommand(t)
		defer mc.Close()

		addr := mc.Address()

		capture := newCapture(t)

		comparer := newComparer(t)
		comparer.run()

		fbsql := cli.NewCommand(logger.StderrLogger)
		fbsql.SetStdin(capture)
		fbsql.SetStdout(comparer)
		fbsql.SetStderr(comparer)

		fbsql.Config = &cli.Config{
			Host: addr.Host(),
			Port: fmt.Sprintf("%d", addr.Port()),
		}

		// Run fbsql in a goroutine so we can continue to send it commands
		// below.
		didQuit := make(chan struct{})
		go func() {
			require.NoError(t, fbsql.Run(ctx))
			close(didQuit)
		}()

		// testFiles reference files located in the cli/testdata directory. All
		// tests should be placed there; other than adding another test file to
		// this list, you probably shouldn't be editing this file unless you are
		// trying to modify the way the test framework itself works.
		testFiles := []string{
			"setup",
			"database",
			"table",
			// the tests below may be dependent on the previous tests, which do
			// setup and some shared database and table creation.
			"query_buffer",
			// meta commands
			"meta_bang",
			"meta_cd",
			"meta_echo",
			"meta_describe",
			"meta_file",
			"meta_pset_border",
			"meta_pset_expanded",
			"meta_pset_format_csv",
			"meta_pset_tuples_only",
			"meta_include",
			"meta_output",
			"meta_set",
			"meta_timing",
			"meta_write",
		}

		for _, testFile := range testFiles {
			t.Run(testFile, func(t *testing.T) {
				f, err := os.Open("testdata/" + testFile)
				require.NoError(t, err)

				scanner := bufio.NewScanner(f)
				var lineNo int
				for scanner.Scan() {
					line := scanner.Text()
					lineNo++

					// Empty lines and comments (//) are ignored.
					if line == "" {
						continue
					} else if strings.HasPrefix(line, "//") {
						continue
					}

					parts := strings.SplitN(line, ":", 2)

					switch parts[0] {
					case "SEND":
						v := ""
						if len(parts) == 2 {
							v = parts[1]
						}
						capture.sendLine(v)
					case "EXPECT":
						v := ""
						if len(parts) == 2 {
							v = parts[1]
						}
						comparer.expectLine(v, testFile, lineNo)
					case "EXPECTCOMP":
						if len(parts) == 2 {
							comps := strings.SplitN(parts[1], ":", 2)
							v := ""
							if len(comps) == 2 {
								v = comps[1]
							}
							comparer.expectLineComp(comparator(comps[0]), v, testFile, lineNo)
						} else {
							t.Errorf("unexpected line: %s[%d]:%s", testFile, lineNo, line)
						}
					default:
						t.Errorf("unexpected line: %s[%d]:%s", testFile, lineNo, line)
					}
				}
				require.NoError(t, scanner.Err())
			})
		}

		// End with quit to ensure that fbsql closes without error.
		capture.sendLine(`\q`)

		// Ensure fbsql quits cleanly.
		select {
		case <-didQuit:
		case <-time.After(time.Second):
			t.Fatalf("expected fbsql to quit")
		}
	})
}

// compare is used to compare fbsql output written to its Stdout with expected
// lines.
type comparer struct {
	t       *testing.T
	out     chan byte
	outline chan []byte
	exp     chan []byte
}

func newComparer(t *testing.T) *comparer {
	return &comparer{
		t:       t,
		out:     make(chan byte, 1024),
		outline: make(chan []byte, 128),
		exp:     make(chan []byte, 1024),
	}
}

func (c *comparer) run() {
	// Read bytes off output, and for every line (designated by a line feed "\n"),
	// push the line onto the outline channel.
	go func() {
		var line []byte
		for {
			b := <-c.out
			if b == byte('\n') {
				c.outline <- line
				line = []byte{}
				continue
			}
			line = append(line, b)
		}
	}()
}

type comparator string

const (
	compEquals     = "Equals"
	compHasPrefix  = "HasPrefix"
	compWithFormat = "WithFormat"
)

// expectLine is a convenience method which calls expectLineComp with the compEq
// comparator and the given line.
func (c *comparer) expectLine(line string, fileName string, lineNo int) {
	c.expectLineComp(compEquals, line, fileName, lineNo)
}

// expectLineComp reads the next line from the outline channel and compares it
// with the given `line`. A comparator can be provided to inform how the lines
// should be compared (for example, the compHasPrefix comparator will just
// compare the beginning part of the outline).
func (c *comparer) expectLineComp(comp comparator, line string, fileName string, lineNo int) {
	var outline []byte
	select {
	case outline = <-c.outline:
	case <-time.After(10 * time.Second):
		// TODO(tlt): this is 10 seconds to account for the fb_views creation on
		// a local mac. This should really be something like 2 seconds. Put this
		// back to 2 once fb_views issue is addressed.
		c.t.Fatalf("expected output line %s[%d]: >%s<", fileName, lineNo, line)
	}

	// msg is included in any require which fails.
	msg := []interface{}{"exp: %s[%d], got: >%s<", fileName, lineNo, outline}

	switch comp {
	case compEquals:
		require.Equal(c.t, []byte(line), outline, msg...)
	case compHasPrefix:
		require.True(c.t, strings.HasPrefix(string(outline), line), msg...)
	case compWithFormat:
		require.True(c.t, compareByteSlices(outline, []byte(line)), msg...)
	default:
		c.t.Fatalf("invalid comparator: %s", comp)
	}
}

func (c *comparer) Write(b []byte) (n int, err error) {
	for i := range b {
		c.out <- b[i]
	}
	return len(b), err
}

// compareByteSlices compares a byte slice s with another byte slice format and
// returns true if they are the same. It will accept underscore as a
// single-character wildcard anywhere in slice format.
func compareByteSlices(s, format []byte) bool {
	// Replace some helpers in format before comparing.
	f := string(format)
	f = strings.ReplaceAll(f, `{uuid}`, `________-____-____-____-____________`)
	f = strings.ReplaceAll(f, `{timestamp}`, `____-__-__T__:__:__Z`)
	format = []byte(f)

	if len(s) != len(format) {
		return false
	}

	for i := range s {
		if format[i] == '_' {
			continue
		}
		if s[i] != format[i] {
			// log.Printf("DEBUG: characters differ: (%d): '%v' != '%v'", i, s[i], format[i])
			return false
		}
	}

	return true
}
