package csv

import (
	"fmt"
	"io"
	"math/rand"
	"os"
	"path/filepath"
	"reflect"
	"strings"
	"sync"
	"testing"
	"time"

	"github.com/featurebasedb/featurebase/v3/idk/idktest"
)

const TestName = "csvtest"

func configureTestFlags(main *Main) {
	if pilosaHost, ok := os.LookupEnv("IDK_TEST_PILOSA_HOST"); ok {
		main.PilosaHosts = []string{pilosaHost}
	} else {
		main.PilosaHosts = []string{"pilosa:10101"}
	}
	if grpcHost, ok := os.LookupEnv("IDK_TEST_GRPC_HOST"); ok {
		main.PilosaGRPCHosts = []string{grpcHost}
	} else {
		main.PilosaGRPCHosts = []string{"pilosa:20101"}
	}
	main.Stats = ""
}

// helper method to run "Extract(All(), Rows())" queries and compare results
func testExtractRowsQuery(t *testing.T, index string, field string, check []interface{}) {
	pql := fmt.Sprintf("Extract(All(), Rows(%s))", field)

	eResp, err := idktest.DoExtractQuery(pql, index)
	if err != nil {
		t.Fatal("doing extract: ", err)
	}
	if eResp.Results[0].Columns == nil {
		t.Fatal("no results: ", err)
	}

	for i, item := range eResp.Results[0].Columns {
		switch exp := check[i].(type) {
		case nil:
			if item.Rows[0] != nil {
				t.Errorf("expected nil, got %+v", item.Rows[0])
			}
		case string:
			if item.Rows[0] != exp {
				t.Errorf("expected %s, got %+v", exp, item.Rows[0])
			}
		case int:
			vAsInt := int(item.Rows[0].(float64))
			if vAsInt != exp {
				t.Errorf("expected %d, got %+v", exp, item.Rows[0])
			} else {
				expAsFloat := float64(exp)
				if item.Rows[0] != expAsFloat {
					t.Errorf("expected %f, got %+v", expAsFloat, item.Rows[0])
				}
			}
		case float64:
			if item.Rows[0] != exp {
				t.Errorf("expected %f, got %+v", exp, item.Rows[0])
			}
		default:
			t.Errorf("unknown type: %T", exp)
		}
	}
}

func testDeleteIndex(t *testing.T, m *Main) {
	if err := m.PilosaClient().DeleteIndexByName(m.Index); err != nil {
		t.Logf(idktest.ErrDeletingIndex+"%v", err)
	}
}

func TestCSVCommand(t *testing.T) {
	file := `
id__ID,s__String_F_YMDH,__RecordTime_2006-01-02T15
0,a,2019-01-09T04
1,a,2019-01-09T05
2,a,2019-02-09T04
3,b,2019-01-09T04
4,a,2018-01-09T04
5,a,2019-01-10T04
`[1:]
	name := writeTempFile(t, file)

	m := NewMain()
	m.AutoGenerate = true
	configureTestFlags(m)
	m.Files = []string{name}
	rand.Seed(time.Now().UnixNano())
	m.Index = fmt.Sprintf(TestName+"%d", rand.Intn(100000))
	defer testDeleteIndex(t, m)

	err := m.Run()
	if err != nil {
		t.Fatalf("%s: %v", idktest.ErrRunningIngest, err)
	}

	client := m.PilosaClient()

	schema, err := client.Schema()
	if err != nil {
		t.Fatalf("%s: %v", idktest.ErrGettingSchema, err)
	}
	index := schema.Index(m.Index)

	s := index.Field("s")

	resp, err := client.Query(s.Range("a", tim(t, "2019-01-07T03"), tim(t, "2019-01-10T05")))
	if err != nil {
		t.Fatalf("%s: %v", idktest.ErrGettingQuery, err)
	}

	if !reflect.DeepEqual(resp.Results()[0].Row().Columns, []uint64{0, 1, 5}) {
		t.Errorf("got/exp\n%v\n%v", resp.Results()[0].Row().Columns, []uint64{0, 1, 5})
	}
}

func TestCSVCommandCustomHeader(t *testing.T) {
	file := `
ABCD,2019-01-02,70%
ABCD,2019-01-03,20%
ABCD,2019-01-04,30%
BEDF,2019-01-02,70%
BEDF,2019-01-05,90%
BEDF,2019-01-08,10%
BEDF,2019-01-08,20%
ABCD,2019-01-30,40%
`[1:]
	name := writeTempFile(t, file)

	m := NewMain()
	m.AutoGenerate = true
	m.Header = []string{"asset_tag__String", "fan_time__RecordTime_2006-01-02"}
	configureTestFlags(m)
	m.Files = []string{name}
	rand.Seed(time.Now().UnixNano())
	m.Index = fmt.Sprintf(TestName+"%d", rand.Intn(100000))
	defer testDeleteIndex(t, m)

	err := m.Run()
	if err != nil {
		t.Fatalf("%s: %v", idktest.ErrRunningIngest, err)
	}

	client := m.PilosaClient()

	schema, err := client.Schema()
	if err != nil {
		t.Fatalf("%s: %v", idktest.ErrGettingSchema, err)
	}
	index := schema.Index(m.Index)

	s := index.Field("asset_tag")

	resp, err := client.Query(s.Row("ABCD"))
	if err != nil {
		t.Fatalf("%s: %v", idktest.ErrGettingQuery, err)
	}

	if !reflect.DeepEqual(resp.Results()[0].Row().Columns, []uint64{0, 1, 2, 7}) {
		t.Errorf("got/exp\n%v\n%v", resp.Results()[0].Row().Columns, []uint64{0, 1, 2, 7})
	}
}

type recordTimeTestCase struct {
	name   string
	layout string
	unit   string
	scale  int // how many unit it takes to move an hour
}

func TestCSVRecordTime(t *testing.T) {
	testCases := []recordTimeTestCase{
		{name: "plusHours", layout: "", unit: "h", scale: 1},
		{name: "plusMinutes", layout: "", unit: "m", scale: 60},
		{name: "plusSeconds", layout: "", unit: "s", scale: 60 * 60},
	}
	// We set our epoch at 4AM, 2019-01-09, because that's the epoch used in
	// the previous example.
	arbitraryEpoch, err := time.Parse("2006-01-02T15", "2019-01-09T04")
	if err != nil {
		t.Fatalf("parsing time: %v", err)
	}

	for _, tc := range testCases {
		lines := []string{}
		layout := tc.layout
		if layout == "" {
			layout = time.RFC3339
		}
		header := fmt.Sprintf(`id__ID,s__String_F_YMDH,__RecordTime_%s_%s_%s`,
			layout, arbitraryEpoch.Format(layout), tc.unit)
		lines = append(lines, header)
		// We expect -1..1 to show up in our query, and -2 and +2 not to.
		for i, offset := range []int{-2, -1, 0, 1, 2} {
			record := fmt.Sprintf("%d,a,%d", i, offset*tc.scale)
			lines = append(lines, record)
		}
		lines = append(lines, "")
		file := strings.Join(lines, "\n")

		name := writeTempFile(t, file)

		m := NewMain()
		m.AutoGenerate = true
		configureTestFlags(m)
		m.Files = []string{name}
		rand.Seed(time.Now().UnixNano())
		m.Index = fmt.Sprintf(TestName+"%d", rand.Intn(100000))
		defer testDeleteIndex(t, m)

		err := m.Run()
		if err != nil {
			t.Fatalf("%s: %v", idktest.ErrRunningIngest, err)
		}

		client := m.PilosaClient()

		schema, err := client.Schema()
		if err != nil {
			t.Fatalf("%s: %v", idktest.ErrGettingSchema, err)
		}
		index := schema.Index(m.Index)

		s := index.Field("s")

		// We specify a range of one minute after the hour because otherwise
		// we hit the end of the range and don't count the +1 case. I am
		// not convinced that this is correct.
		resp, err := client.Query(s.Range("a", tim(t, "2019-01-09T03"), tim(t, "2019-01-09T05").Add(1*time.Minute)))
		if err != nil {
			t.Fatalf("%s: %v", idktest.ErrGettingQuery, err)
		}

		if !reflect.DeepEqual(resp.Results()[0].Row().Columns, []uint64{1, 2, 3}) {
			t.Errorf("record time (%s): got/exp\n%v\n%v", tc.name, resp.Results()[0].Row().Columns, []uint64{1, 2, 3})
		}
	}
}

func tim(t *testing.T, tstr string) time.Time {
	ti, err := time.Parse("2006-01-02T15", tstr)
	if err != nil {
		t.Fatalf("parsing time: %v", err)
	}
	return ti
}

func writeTempFile(t *testing.T, data string) string {
	f, err := os.CreateTemp("", "")
	if err != nil {
		t.Fatalf("getting temp file: %v", err)
	}
	defer f.Close()

	_, err = io.WriteString(f, data)
	if err != nil {
		t.Fatalf("writing string: %v", err)
	}

	return f.Name()
}

func TestStreamFileNames(t *testing.T) {
	tmp, err := testDirTree("d1/d2/d3/d4/d5")
	if err != nil {
		t.Fatal(err)
	}
	defer os.RemoveAll(tmp)

	mtx := &sync.Mutex{}
	visited := make(map[string]int)

	m := NewMain()
	configureTestFlags(m)
	// duplicate all files from the same directory
	m.Files = []string{tmp, tmp, tmp, tmp, tmp, tmp}
	_, err = m.NewSource()
	if err != nil {
		t.Fatal(err)
	}

	wg := &sync.WaitGroup{}
	visitor := func(files chan string) {
		defer wg.Done()

		for f := range files {
			// count all visited files
			mtx.Lock()
			visited[f]++
			mtx.Unlock()
		}
	}

	wg.Add(3)
	go visitor(m.files)
	go visitor(m.files)
	go visitor(m.files)
	wg.Wait()

	for p, n := range visited {
		fi, err := os.Lstat(p)
		if err != nil {
			t.Fatal(err)
		}

		if fi.Mode().IsRegular() && n != 1 {
			t.Fatalf("file %s was visited %d times", p, n)
		}
	}
}

func testDirTree(tree string) (string, error) {
	tmp, err := os.MkdirTemp("", "")
	if err != nil {
		return "", err
	}

	err = os.MkdirAll(filepath.Join(tmp, tree), 0o755)
	if err != nil {
		os.RemoveAll(tmp)
		return "", err
	}

	return tmp, filepath.Walk(tmp, func(path string, _ os.FileInfo, _ error) error {
		f, err := os.Create(filepath.Join(path, "csv"))
		if err != nil {
			return err
		}
		return f.Close()
	})
}

// Tests various out of range values are ingested as nil when CLI flags are set to true.
func TestVariousOORValues(t *testing.T) {
	file := `
id__ID,s__String_F_YMDH,ts__Timestamp_s_2006-01-02 15:04:05.999,price__Decimal_2,age__Int_1_120
0,a,0000-01-03 08:00:00.000,0.0,1
1,b,9999-12-31 23:59:60.999,5.44,35
2,b,2019-50-03 08:00:00.000,5.44,120
3,b,2019-01-50 08:00:00.000,5.44,120
4,b,2019-01-03 50:00:00.000,5.44,120
5,a,2019-04-03 00:90:00.000,5.44,129
6,a,2019-04-03 00:00:90.000,5.44,120
5,a,2019-04-03 00:00:00.000,123.123,1
6,a,2019-04-03 00:00:00.000,-1,1
7,a,2019-04-03 00:00:00.000,994492233720368547758.0892233720368547758,100
5,a,2019-04-03 00:00:00.000,2.34,121
6,a,2019-04-03 00:00:00.000,3.44,0
7,a,1500-04-03 00:00:00.000,994492233720368547758.0892233720368547758,2342342
8,a,2019-04-03 00:00:00.000,3.44,100
`[1:]
	name := writeTempFile(t, file)

	checker := make(map[string]interface{})
	ts := "2019-04-03T00:00:00Z"
	checker["ts"] = []interface{}{nil, nil, nil, nil, nil, nil, nil, ts, ts, ts, ts, ts, "1500-04-03T00:00:00Z", ts}
	checker["age"] = []interface{}{1, 35, 120, 120, 120, nil, 120, 1, 1, 100, nil, nil, nil, 100}
	checker["price"] = []interface{}{0.0, 5.44, 5.44, 5.44, 5.44, 5.44, 5.44, 123.12, -1.0, nil, 2.34, 3.44, nil, 3.44}

	m := NewMain()
	m.AutoGenerate = true
	m.BatchSize = 1
	configureTestFlags(m)
	m.Files = []string{name}
	rand.Seed(time.Now().UnixNano())
	m.Index = fmt.Sprintf(TestName+"%d", rand.Intn(100000))
	m.AllowIntOutOfRange = true
	m.AllowDecimalOutOfRange = true
	m.AllowTimestampOutOfRange = true
	defer testDeleteIndex(t, m)

	err := m.Run()
	if err != nil {
		t.Fatalf("%s: %v", idktest.ErrRunningIngest, err)
	}

	client := m.PilosaClient()

	schema, err := client.Schema()
	if err != nil {
		t.Fatalf("%s: %v", idktest.ErrGettingSchema, err)
	}
	index := schema.Index(m.Index)

	resp, err := client.Query(index.Count(index.All()))
	if err != nil {
		t.Fatalf("%s: %v", idktest.ErrGettingQuery, err)
	}
	if cnt := resp.Result().Count(); cnt != 14 {
		t.Fatalf("expected 14, got %+v", cnt)
	}

	for _, field := range []string{"ts", "age", "price"} {
		check := checker[field].([]interface{})
		testExtractRowsQuery(t, m.Index, field, check)
	}
}

type TimestampTestCase struct {
	fieldName string
	file      string
	expect    interface{}
}

// Test Time Layouts
func TestTimeLayouts(t *testing.T) {
	testCases := []TimestampTestCase{
		{
			fieldName: "ts1",
			file: `
id__ID,s__String_F_YMDH,ts1__Timestamp_s_2006-01-02 15:04:05.999_2030-01-02 15:04:05.999_s
0,a,99221100

`[1:],
			expect: []interface{}{"2033-02-24T00:29:05Z"},
		},
		{
			fieldName: "ts-nano-min",
			file: `
id__ID,s__String_F_YMDH,ts-nano-min__Timestamp_ns_2006-01-02T15:04:05.999999999Z_1833-11-24T17:31:44.01Z_s
0,a,1
1,b,-1
`[1:],
			expect: []interface{}{"1833-11-24T17:31:45.01Z", nil},
		},
		{
			fieldName: "ts-nano-max",
			file: `
id__ID,s__String_F_YMDH,ts-nano-max__Timestamp_ns_2006-01-02T15:04:05.999999999Z_2106-02-07T06:28:16Z_ns
0,a,1
1,b,-1000001
`[1:],
			expect: []interface{}{nil, "2106-02-07T06:28:15.998999999Z"},
		},
		{
			fieldName: "ts-sec-min",
			file: `
id__ID,s__String_F_YMDH,ts-sec-min__Timestamp_s_2006-01-02T15:04:05.999999999Z_0001-01-01T00:00:01Z_ms
0,a,1001
1,b,-1001
`[1:],
			expect: []interface{}{"0001-01-01T00:00:02Z", nil},
		},
		{
			fieldName: "ts-ms-max",
			file: `
id__ID,s__String_F_YMDH,ts-ms-max__Timestamp_ms_2006-01-02T15:04:05.999999999Z_9999-12-31T23:59:59Z_us
0,a,1001
1,b,-1001

`[1:],
			expect: []interface{}{nil, "9999-12-31T23:59:58.999Z"},
		},
		{
			fieldName: "gran-conversion",
			file: `
id__ID,s__String_F_YMDH,gran-conversion__Timestamp_ns_2006-01-02T15:04:05.999999999Z_2000-02-07T06:28:16Z_s
0,a,10000000000
1,b,-1001

`[1:],
			expect: []interface{}{nil, "2000-02-07T06:11:35Z"},
		},
	}

	for _, tc := range testCases {
		m := newMainOORFactory(t, tc.file, false, false, true)
		testTimestampRunner(t, m, tc)
	}
}

func testTimestampRunner(t *testing.T, m *Main, testCase TimestampTestCase) {
	defer testDeleteIndex(t, m)

	err := m.Run()
	if err != nil {
		t.Fatalf("%s: %v", idktest.ErrRunningIngest, err)
	}

	check := testCase.expect.([]interface{})
	testExtractRowsQuery(t, m.Index, testCase.fieldName, check)
}

// Test that out of range int values are ingested as nil when AllowIntOutOfRange is true.
func TestIntOpts(t *testing.T) {
	file := `
id__ID,negneg__Int_-10_-5,negpos__Int_-10_10,pospos__Int_5_10,negzero__Int_-10_0,zeropos__Int_0_10,zerozero__Int_0_0
1,-20,-20,-20,-20,-20,-20
2,-10,-10,-10,-10,-10,-10
3,-5,-5,-5,-5,-5,-5
4,0,0,0,0,0,0
5,5,5,5,5,5,5
6,10,10,10,10,10,10
7,20,20,20,20,20,20
`[1:]

	checker := make(map[string]interface{})
	checker["negneg"] = []interface{}{nil, -10, -5, nil, nil, nil, nil}
	checker["negzero"] = []interface{}{nil, -10, -5, 0, nil, nil, nil}
	checker["negpos"] = []interface{}{nil, -10, -5, 0, 5, 10, nil}
	checker["zeropos"] = []interface{}{nil, nil, nil, 0, 5, 10, nil}
	checker["zerozero"] = []interface{}{nil, nil, nil, 0, nil, nil, nil}
	checker["pospos"] = []interface{}{nil, nil, nil, nil, 5, 10, nil}

	batchSizes := []int{1, 3, 4, 10}
	for _, bsize := range batchSizes {
		t.Run(fmt.Sprintf("batchsize=%d", bsize), func(t *testing.T) {
			m := newMainOORFactory(t, file, true, false, false)
			m.BatchSize = bsize
			defer testDeleteIndex(t, m)

			err := m.Run()
			if err != nil {
				t.Fatalf("%s: %v", idktest.ErrRunningIngest, err)
			}

			for _, field := range []string{"negneg", "negzero", "negpos", "zeropos", "pospos"} {
				check := checker[field].([]interface{})
				testExtractRowsQuery(t, m.Index, field, check)
			}
		})
	}
}

// Test that out of range timestamp values are ingested as nil when AllowTimestampOutOfRange is true.
func TestTimestampOOR(t *testing.T) {
	file := `
id__ID,ts1__Timestamp_ns_2006-01-02 15:04:05.999,ts2__Timestamp_s_2006-01-02T15:04:05Z07:00_9998-12-31T15:04:05Z_h,ts3__Timestamp_s_2006-01-02T15:04:05Z07:00_0002-12-31T15:04:05Z_h,ts4__Timestamp_s_2006-01-02T15:04:05.999Z
0,1833-01-03 08:00:00.000,8500,8500,0001-01-01T00:00:00Z
1,1833-11-24 17:31:44.000,8769,-8500,0001-01-01T00:00:01Z
2,1833-11-25 17:31:44.000,-99991,0,0001-01-01T00:00:02Z
3,2106-02-06 06:28:16.000,0,-99995,9999-12-31T23:59:58Z
4,2106-02-07 06:28:16.000,9999,-99999,9999-12-31T23:59:59Z
5,2106-02-08 06:28:16.000,99999999999999999999999,-9999999999999999999999999,9999-12-31T23:59:60Z
`[1:]

	checker := make(map[string]interface{})
	// should import nil if timestamp val is out of range
	checker["ts1"] = []interface{}{nil, "1833-11-24T17:31:44Z", "1833-11-25T17:31:44Z", "2106-02-06T06:28:16Z", "2106-02-07T06:28:16Z", nil}
	// should import nil if custom epoch + value overflows
	checker["ts2"] = []interface{}{"9999-12-20T19:04:05Z", nil, "9987-08-05T08:04:05Z", "9998-12-31T15:04:05Z", nil, nil}
	checker["ts3"] = []interface{}{"0003-12-20T19:04:05Z", "0002-01-11T11:04:05Z", "0002-12-31T15:04:05Z", nil, nil, nil}
	checker["ts4"] = []interface{}{nil, "0001-01-01T00:00:01Z", "0001-01-01T00:00:02Z", "9999-12-31T23:59:58Z", "9999-12-31T23:59:59Z", nil}
	batchSizes := []int{3, 1, 4, 10}
	for _, bsize := range batchSizes {
		t.Run(fmt.Sprintf("batchsize=%d", bsize), func(t *testing.T) {
			m := newMainOORFactory(t, file, false, false, true)
			m.BatchSize = bsize
			defer testDeleteIndex(t, m)

			err := m.Run()
			if err != nil {
				t.Fatalf("%s: %v", idktest.ErrRunningIngest, err)
			}
			for _, field := range []string{"ts1", "ts2", "ts3", "ts4"} {
				check := checker[field].([]interface{})
				testExtractRowsQuery(t, m.Index, field, check)

			}
		})
	}
}

// Tests various conditions that should halt ingest
func TestFailureConditions(t *testing.T) {
	type testCase struct {
		name                string
		csv                 string
		fail                bool
		intOutOfRange       bool
		decimalOutOfRange   bool
		timestampOutOfRange bool
	}

	testCases := []testCase{
		{name: "too small", csv: `id__ID,ts1__Timestamp_s_2006-01-02T15:04:05Z07:00_0000-01-01T00:00:00Z_h
0,0
`, fail: true, intOutOfRange: true, timestampOutOfRange: true, decimalOutOfRange: true},
		{name: "just right", csv: `id__ID,ts1__Timestamp_s_2006-01-02T15:04:05Z07:00_2200-12-31T15:04:05Z_h
0,0
`, fail: false, intOutOfRange: true, timestampOutOfRange: true, decimalOutOfRange: true},
		{name: "too big", csv: `id__ID,ts1__Timestamp_s_2006-01-02T15:04:05Z07:00_9999-12-31T23:59:60Z_h
0,0
`, fail: true, intOutOfRange: true, timestampOutOfRange: true, decimalOutOfRange: true},
		{name: "intOutOfRange not allowed", csv: `id__ID,pospos__Int_5_10
0,4
`, fail: true, intOutOfRange: false, timestampOutOfRange: true, decimalOutOfRange: true},
		{name: "intOutOfRange not allowed-2", csv: `id__ID,pospos__Int_5_10
0,11
`, fail: true, intOutOfRange: false, timestampOutOfRange: true, decimalOutOfRange: true},
		{name: "timestampOutOfRange not allowed", csv: `id__ID,ts1__Timestamp_s_2006-01-02 15:04:05.999
0,-0001-01-03 08:00:00.000
`, fail: true, intOutOfRange: true, timestampOutOfRange: false, decimalOutOfRange: true},
		{name: "timestampOutOfRange not allowed-2", csv: `id__ID,ts2__Timestamp_s_2006-01-02T15:04:05Z07:00_9999-12-31T23:59:59Z_h
0,2433
`, fail: true, intOutOfRange: true, timestampOutOfRange: false, decimalOutOfRange: true},
		{name: "decimalOutOfRange not allowed", csv: `id__ID,price__Decimal_2
0,994492233720368547758.0892233720368547758
`, fail: true, intOutOfRange: true, timestampOutOfRange: true, decimalOutOfRange: false},
		{name: "intOverflow", csv: `id__ID,pospos__Int
0,89273948723984729387492387492987
		`, fail: true, intOutOfRange: false, timestampOutOfRange: false, decimalOutOfRange: false},

		{name: "int string overflow", csv: `id__ID,pospos__Int
0,"89273948723984729387492387492987"
`, fail: true, intOutOfRange: false, timestampOutOfRange: false, decimalOutOfRange: false},
	}

	for _, test := range testCases {
		t.Run(test.name, func(t *testing.T) {
			m := newMainOORFactory(t, test.csv, test.intOutOfRange, test.decimalOutOfRange, test.timestampOutOfRange)
			m.BatchSize = 1
			defer testDeleteIndex(t, m)

			err := m.Run()
			if test.fail {
				if err == nil {
					t.Fatal("expected error, got nil")
				}
			} else if err != nil {
				t.Fatalf("%s: %v", idktest.ErrRunningIngest, err)
			}
		})
	}
}

func newMainOORFactory(t *testing.T, file string, allowIntOOR bool, allowDecOOR bool, allowTSOOR bool) *Main {
	name := writeTempFile(t, file)
	m := NewMain()
	m.AutoGenerate = true
	configureTestFlags(m)
	m.Files = []string{name}
	rand.Seed(time.Now().UnixNano())
	m.Index = fmt.Sprintf("oortest%d", rand.Intn(100000))
	m.AllowIntOutOfRange = allowIntOOR
	m.AllowDecimalOutOfRange = allowDecOOR
	m.AllowTimestampOutOfRange = allowTSOOR
	return m
}
