//go:build !kafka_sasl
// +build !kafka_sasl

package kafka

import (
	"context"
	"crypto/tls"
	"encoding/binary"
	"encoding/json"
	"fmt"
	"math/big"
	"math/rand"
	"os"
	"reflect"
	"strings"
	"testing"
	"time"

	"github.com/confluentinc/confluent-kafka-go/kafka"
	confluent "github.com/confluentinc/confluent-kafka-go/kafka"
	"github.com/featurebasedb/featurebase/v3/idk"
	"github.com/featurebasedb/featurebase/v3/idk/common"
	"github.com/featurebasedb/featurebase/v3/idk/kafka/csrc"
	"github.com/featurebasedb/featurebase/v3/logger"
	"github.com/glycerine/vprint"
	"github.com/go-avro/avro"
	liavro "github.com/linkedin/goavro/v2"
)

func configureSourceTestFlags(source *Source) {
	source.KafkaBootstrapServers = []string{kafkaHost}
	source.SchemaRegistryURL = registryHost
}

func TestAvroToPDKSchema(t *testing.T) {
	t.Parallel()

	tests := []struct {
		schemaFile string
		exp        []idk.Field
		expErr     string
	}{
		{
			schemaFile: "simple.json",
			exp:        expectedSchemas["simple.json"],
		},
		{
			schemaFile: "stringtypes.json",
			exp:        expectedSchemas["stringtypes.json"],
		},
		{
			schemaFile: "decimal.json",
			exp:        expectedSchemas["decimal.json"],
		},
		{
			schemaFile: "othertypes.json",
			exp:        expectedSchemas["othertypes.json"],
		},
		{
			schemaFile: "unions.json",
			exp:        expectedSchemas["unions.json"],
		},
		{
			schemaFile: "floatscale.json",
			exp:        expectedSchemas["floatscale.json"],
		},
		{
			schemaFile: "notarecord.json",
			expErr:     "unsupported Avro Schema type",
		},
		{
			schemaFile: "fieldisrecord.json",
			expErr:     "nested fields are not currently supported",
		},
		{
			schemaFile: "timestamp.json",
			exp:        expectedSchemas["timestamp.json"],
		},
	}

	// check that we've covered all the test schemas
	files, err := os.ReadDir("./testdata/schemas")
	if err != nil {
		t.Fatalf("reading directory: %v", err)
	}
	if len(files) != len(tests)+4 { // +4 because we aren't testing bigschema.json, the two delete ones, or the ID allocation one here.
		t.Errorf("have different number of schemas and tests: %d and %d\n%+v", len(files), len(tests), files)
	}

	for _, test := range tests {
		test := test
		t.Run(test.schemaFile, func(t *testing.T) {
			t.Parallel()

			codec := decodeTestSchema(t, test.schemaFile)
			schema, err := avroToPDKSchema(codec)
			if err != nil && test.expErr == "" {
				t.Fatalf("unexpected error: %v", err)
			}
			if test.expErr != "" && err == nil {
				t.Fatalf("expected error")
			}
			if test.expErr != "" && !strings.Contains(err.Error(), test.expErr) {
				t.Fatalf("error expected/got\n%s\n%v", test.expErr, err.Error())
			}
			if !reflect.DeepEqual(test.exp, schema) {
				t.Fatalf("schema exp/got\n%+v\n%+v", test.exp, schema)
			}
		})
	}
}

func TestAvroToPDKField(t *testing.T) {
	tests := []struct {
		name        string
		schemaField *avro.SchemaField
		expField    idk.Field
		expErr      string
	}{
		{
			name: "string ttl",
			schemaField: &avro.SchemaField{
				Name: "string-ttl",
				Type: &avro.StringSchema{},
				Properties: map[string]interface{}{
					"ttl":     "30s",
					"quantum": "YMD",
				},
			},
			expField: idk.StringField{NameVal: "string-ttl", Quantum: "YMD", TTL: "30s"},
			expErr:   "nil",
		},
		{
			name: "string ttl without quantum",
			schemaField: &avro.SchemaField{
				Name: "string-ttl",
				Type: &avro.StringSchema{},
				Properties: map[string]interface{}{
					"ttl": "30s",
				},
			},
			expField: idk.StringField{NameVal: "string-ttl"},
			expErr:   "nil",
		},
		{
			name: "bytes ttl",
			schemaField: &avro.SchemaField{
				Name: "bytes-ttl",
				Type: &avro.BytesSchema{},
				Properties: map[string]interface{}{
					"ttl":     "30s",
					"quantum": "YMD",
				},
			},
			expField: idk.StringField{NameVal: "bytes-ttl", Quantum: "YMD", TTL: "30s"},
			expErr:   "nil",
		},
		{
			name: "bytes ttl without quantum",
			schemaField: &avro.SchemaField{
				Name: "bytes-ttl",
				Type: &avro.BytesSchema{},
				Properties: map[string]interface{}{
					"ttl": "30s",
				},
			},
			expField: idk.StringField{NameVal: "bytes-ttl"},
			expErr:   "nil",
		},
		{
			name: "string array ttl",
			schemaField: &avro.SchemaField{
				Name: "array-ttl",
				Type: &avro.ArraySchema{
					Items: &avro.StringSchema{
						Properties: map[string]interface{}{
							"quantum": "YMD",
							"ttl":     "60s",
						},
					},
				},
			},
			expField: idk.StringArrayField{NameVal: "array-ttl", Quantum: "YMD", TTL: "60s"},
			expErr:   "nil",
		},
		{
			name: "string array ttl without quantum",
			schemaField: &avro.SchemaField{
				Name: "array-ttl",
				Type: &avro.ArraySchema{
					Items: &avro.StringSchema{
						Properties: map[string]interface{}{
							"ttl": "60s",
						},
					},
				},
			},
			expField: idk.StringArrayField{NameVal: "array-ttl"},
			expErr:   "nil",
		},
		{
			name: "Int ttl",
			schemaField: &avro.SchemaField{
				Name: "int-ttl",
				Type: &avro.IntSchema{},
				Properties: map[string]interface{}{
					"fieldType": "id",
					"ttl":       "30s",
					"quantum":   "YMD",
				},
			},
			expField: idk.IDField{NameVal: "int-ttl", Quantum: "YMD", TTL: "30s"},
			expErr:   "nil",
		},
		{
			name: "Int ttl without quantum",
			schemaField: &avro.SchemaField{
				Name: "int-ttl",
				Type: &avro.IntSchema{},
				Properties: map[string]interface{}{
					"fieldType": "id",
					"ttl":       "30s",
				},
			},
			expField: idk.IDField{NameVal: "int-ttl"},
			expErr:   "nil",
		},
	}

	for _, test := range tests {
		t.Run(test.name, func(t *testing.T) {
			field, err := avroToPDKField(test.schemaField)
			if !reflect.DeepEqual(field, test.expField) {
				t.Errorf("expected field: '%v', got: '%v'", test.expField, field)
			}
			if err != nil && !strings.Contains(err.Error(), test.expErr) {
				t.Errorf("expected error: '%s', got: '%s'", test.expErr, err.Error())
			}
		})
	}
}

func decodeTestSchema(t *testing.T, filename string) avro.Schema {
	codec, err := avro.ParseSchema(readTestSchema(t, filename))
	if err != nil {
		t.Fatalf("parsing schema: %v", err)
	}
	return codec
}

func readTestSchema(t *testing.T, filename string) string {
	bytes, err := os.ReadFile("./testdata/schemas/" + filename)
	if err != nil {
		t.Fatalf("reading schema file: %v", err)
	}
	return string(bytes)
}

func liDecodeTestSchema(t *testing.T, filename string) *liavro.Codec {
	codec, err := liavro.NewCodec(readTestSchema(t, filename))
	if err != nil {
		t.Fatalf("li parsing schema: %v", err)
	}
	return codec
}

var tests = []struct {
	data       []map[string]interface{}
	schemaFile string
	exp        [][]interface{}
}{
	{
		schemaFile: "simple.json",
		data:       []map[string]interface{}{{"first": "hello", "last": "goodbye"}, {"first": "one", "last": "two"}},
		exp:        [][]interface{}{{"hello", "goodbye"}, {"one", "two"}},
	},
	{
		schemaFile: "stringtypes.json",
		data:       []map[string]interface{}{{"first": "blah", "last": "goodbye", "middle": "123456789"}},
		exp:        [][]interface{}{{"blah", []byte("goodbye"), []byte("123456789")}},
	},
	{
		schemaFile: "decimal.json",
		data:       []map[string]interface{}{{"somenum": &big.Rat{}}, {"somenum": big.NewRat(10, 1)}, {"somenum": big.NewRat(1, 1)}, {"somenum": big.NewRat(5, 2)}, {"somenum": big.NewRat(1234567890, 1)}},
		exp:        [][]interface{}{{[]byte{0}}, {[]byte{0x3, 0xE8}}, {[]byte{100}}, {[]byte{0, 250}}, {[]byte{0x1C, 0xBE, 0x99, 0x1A, 0x08}}},
	},
	{
		schemaFile: "othertypes.json",
		data:       []map[string]interface{}{{"first": "a", "second": []string{"b", "c"}, "third": -8, "fourth": 99, "fifth": 99.9, "sixth": 101.1, "seventh": true, "eighth": map[string]interface{}{"string": "a"}, "ninth": map[string]interface{}{"string": "b"}}},
		exp:        [][]interface{}{{"a", []interface{}{"b", "c"}, int32(-8), int64(99), float32(99.9), float64(101.1), true, "a", "b"}},
	},
	{
		schemaFile: "unions.json",
		data: []map[string]interface{}{
			{"first": map[string]interface{}{"string": "a"}, "second": map[string]interface{}{"boolean": true}, "third": map[string]interface{}{"long": 101}, "fourth": map[string]interface{}{"bytes.decimal": big.NewRat(5, 2)}, "fifth": map[string]interface{}{"double": float64(9.4921)}},
			{"first": nil, "second": nil, "third": map[string]interface{}{"null": nil}, "fourth": nil, "fifth": nil},
		},
		exp: [][]interface{}{
			{"a", true, int64(101), []byte{9, 196}, float64(9.4921)},
			{nil, nil, nil, nil, nil},
		},
	},
	{
		schemaFile: "floatscale.json",
		data:       []map[string]interface{}{{"first": 23.12345}},
		exp:        [][]interface{}{{float32(23.12345)}},
	},
	{
		schemaFile: "timestamp.json",
		data:       []map[string]interface{}{{"time_samir": []byte("5000")}},
		exp:        [][]interface{}{{[]byte("5000")}},
	},
}

func TestKafkaSourceLocal(t *testing.T) {
	t.Parallel()

	// this is not an integration test, so we'll take steps to avoid
	// actually connecting to Kafka or Schema Registry.

	for i, test := range tests {
		i, test := i, test
		t.Run(test.schemaFile, func(t *testing.T) {
			t.Parallel()

			schema := liDecodeTestSchema(t, test.schemaFile)

			src := NewSource()
			defer src.Close()
			configureSourceTestFlags(src)
			cfg, err := common.SetupConfluent(&src.ConfluentCommand)
			if err != nil {
				t.Fatal(err)
			}
			src.ConfigMap = cfg
			// note: we will not call Open on the source which would connect
			// to Kafka. Instead, we'll set the reader manually so we
			// can inject messages.
			go func() {
				topic := string("test")
				for j, record := range test.data {
					buf := make([]byte, 5, 1000)
					buf[0] = 0
					binary.BigEndian.PutUint32(buf[1:], uint32(i))
					buf, err := schema.BinaryFromNative(buf, record)
					if err != nil {
						t.Errorf("encoding:\n%+v\nerr: %v", record, err)
					}
					e := &confluent.Message{
						TopicPartition: confluent.TopicPartition{
							Topic:     &topic,
							Partition: 0,
							Offset:    confluent.Offset(j),
						},
						Timestamp: time.Now(),
						Value:     buf,
					}

					src.recordChannel <- recordWithError{Record: e}
				}
				close(src.recordChannel)
			}()

			// prefill the schema cache so the registry isn't contacted.
			src.cache[int32(i)] = decodeTestSchema(t, test.schemaFile)
			for j, expect := range test.exp {
				pdkRec, err := src.Record()
				if j == 0 {
					if err != idk.ErrSchemaChange {
						t.Errorf("expected schema changed signal, got: %v", err)
					}
					gotSchema := src.Schema()
					if !reflect.DeepEqual(gotSchema, expectedSchemas[test.schemaFile]) {
						t.Errorf("unexpected schema exp/got:\n%+v\n%+v", expectedSchemas[test.schemaFile], gotSchema)
					}
				} else if err != nil {
					t.Fatalf("unexpected error getting record: %v", err)
				}
				if pdkRec == nil {
					t.Fatalf("should have a record")
				}
				data := pdkRec.Data()
				if !reflect.DeepEqual(data, expect) {
					t.Errorf("data mismatch exp/got:\n%+v\n%+v", expect, data)
					if len(data) != len(expect) {
						t.Fatalf("mismatched lengths exp/got %d/%d", len(expect), len(data))
					}
					for k, exp := range expect {
						got := data[k]
						if !reflect.DeepEqual(exp, got) {
							t.Errorf("Mismatch at %d, exp/got\n%v of %[2]T\n%v of %[3]T", k, exp, got)
						}
					}
				}
			}

			err = src.Close()
			if err != nil {
				t.Fatalf("failed to close source: %v", err)
			}
		})
	}
}

// TestKafkaSourceSchemaChangeCommitRegresion is a regression test for a bug where a failure during a schema change could cause a record to be lost.
func TestKafkaSourceSchemaChangeCommitRegression(t *testing.T) {
	t.Parallel()

	src := NewSource()
	defer src.Close()
	configureSourceTestFlags(src)
	cfg, err := common.SetupConfluent(&src.ConfluentCommand)
	if err != nil {
		t.Fatal(err)
	}
	src.ConfigMap = cfg

	// prefill the schema cache so the registry isn't contacted.
	t1, t2 := tests[0], tests[1]
	schema1 := liDecodeTestSchema(t, t1.schemaFile)
	schema2 := liDecodeTestSchema(t, t2.schemaFile)
	schemaID1, schemaID2 := int32(0), int32(1)
	src.cache[schemaID1] = decodeTestSchema(t, t1.schemaFile)
	src.cache[schemaID2] = decodeTestSchema(t, t2.schemaFile)

	// synthesize fake Kakfa messages
	data1, err := endcodeAvro(int(schemaID1), schema1, t1.data[0])
	if err != nil {
		t.Fatalf("encoding record: %v", err)
	}
	data2, err := endcodeAvro(int(schemaID2), schema2, t2.data[0])
	if err != nil {
		t.Fatalf("encoding record: %v", err)
	}

	go func() {
		topic := "xyzzy"
		src.recordChannel <- recordWithError{
			Record: &confluent.Message{
				TopicPartition: confluent.TopicPartition{
					Topic:     &topic,
					Partition: 0,
					Offset:    confluent.Offset(0),
				},
				Timestamp: time.Now(),
				Value:     data1,
			},
		}
		src.recordChannel <- recordWithError{
			Record: &confluent.Message{
				TopicPartition: confluent.TopicPartition{
					Topic:     &topic,
					Partition: 0,
					Offset:    confluent.Offset(1),
				},
				Timestamp: time.Now(),
				Value:     data2,
			},
		}
		close(src.recordChannel)
	}()

	rec1, err := src.Record()
	if err != idk.ErrSchemaChange {
		t.Fatalf("expected schema change but got: %v", err)
	}
	if rec1 == nil {
		t.Fatal("first record is missing")
	}
	rec2, err := src.Record()
	if err != idk.ErrSchemaChange {
		t.Fatalf("expected schema change but got: %v", err)
	}
	if rec2 == nil {
		t.Fatal("second record is missing")
	}
}

func TestKafkaSourceTimeout(t *testing.T) {
	t.Parallel()

	src := NewSource()
	defer src.Close()
	cfg, err := common.SetupConfluent(&src.ConfluentCommand)
	if err != nil {
		t.Fatal(err)
	}

	src.Timeout = time.Millisecond
	src.KafkaSocketTimeoutMs = 10 // lowest value you can set librdkafka

	src.ConfigMap = cfg

	schema := liDecodeTestSchema(t, "simple.json")

	// prefill the schema cache so the registry isn't contacted.
	schemaID := int32(0)
	src.cache[schemaID] = decodeTestSchema(t, "simple.json")

	// synthesize fake Kakfa message
	record := map[string]interface{}{"first": "hello", "last": "goodbye"}
	buf := make([]byte, 5, 1000)
	buf[0] = 0
	binary.BigEndian.PutUint32(buf[1:], uint32(schemaID))
	buf, err = schema.BinaryFromNative(buf, record)
	if err != nil {
		t.Errorf("encoding:\n%+v\nerr: %v", record, err)
	}

	go func() {
		topic := "test"
		src.recordChannel <- recordWithError{
			Record: &confluent.Message{
				TopicPartition: confluent.TopicPartition{
					Topic: &topic,
				},
				Value: buf,
			},
		}
	}()

	// ensure we can get a message if one is available
	if rec, err := src.Record(); err != nil && err != idk.ErrSchemaChange {
		t.Fatalf("expected record, but got error %v", err)
	} else if rec.Data()[0].(string) != "hello" || rec.Data()[1].(string) != "goodbye" {
		t.Fatalf("unexpected record: %v", rec.Data())
	}

	// ensure timeout works and we get ErrFlush when no message is available
	start := time.Now()
	if rec, err := src.Record(); err != idk.ErrFlush {
		t.Fatalf("expected Flush, but got %+v err: %v", rec, err)
	} else if dur := time.Since(start); dur < time.Millisecond {
		t.Fatalf("expected to wait at least 1ms before getting Flush, but only waited %v", dur)
	}
	close(src.recordChannel)
}

// TestKafkaSource uses a real Kafka and Schema Registry. I downloaded
// the tar archive of the Confluent Platform (self managed software)
// from confluent.io/download (I got version 5.3.1). I ran `tar xzf`
// on the file, changed into the directory, ran `curl -L
// https://cnfl.io/cli | sh -s -- -b /Users/jaffee/bin` (that
// directory is on my PATH), then ran `confluent local start
// schema-registry`.
//
// I find that this test runs much faster after a `confluent local
// destroy` followed by `confluent local start schema-registry`. The
// difference is stark—10s of seconds—and I don't know why this should
// be, but I think it has something to do with kafka rebalancing
// itself when a new client joins.
func TestKafkaSourceIntegration(t *testing.T) {
	if testing.Short() {
		t.Skip()
	}
	src := NewSource()
	defer src.Close()
	configureSourceTestFlags(src)
	cfg, err := common.SetupConfluent(&src.ConfluentCommand)
	if err != nil {
		t.Fatal(err)
	}
	src.ConfigMap = cfg

	src.Topics = []string{"testKafkaSourceIntegration"}
	src.Group = "group0"
	rnd := rand.New(rand.NewSource(time.Now().UnixNano()))

	// Create Producer instance
	p, err := confluent.NewProducer(&confluent.ConfigMap{
		"bootstrap.servers": kafkaHost,
	})
	if err != nil {
		t.Fatalf("Failed to create producer: %s", err)
	}
	defer p.Close()
	tCreateTopic(t, src.Topics[0], p)
	/*	"sasl.mechanisms":   conf["sasl.mechanisms"],
		"security.protocol": conf["security.protocol"],
		"sasl.username":     conf["sasl.username"],
		"sasl.password":     conf["sasl.password"]})*/
	err = src.Open()
	if err != nil {
		t.Fatalf("Failed to open src:%v", err)
	}

	key := fmt.Sprintf("%d", rnd.Int())
	for i, test := range tests {
		schemaID := postSchema(t, test.schemaFile, fmt.Sprintf("schema%d", i), registryHost, nil)
		schema := liDecodeTestSchema(t, test.schemaFile)
		t.Run(test.schemaFile, func(t *testing.T) {
			for j, record := range test.data {
				tPutRecordsKafka(t, p, src.Topics[0], schemaID, schema, key, record)
				pdkRec, err := src.Record()
				if j == 0 {
					if err != idk.ErrSchemaChange {
						t.Errorf("expected schema changed signal, got: %v", err)
					}
					gotSchema := src.Schema()
					if !reflect.DeepEqual(gotSchema, expectedSchemas[test.schemaFile]) {
						t.Errorf("unexpected schema got/exp:\n%+v\n%+v", gotSchema, expectedSchemas[test.schemaFile])
					}
				} else if err != nil {
					t.Fatalf("unexpected error getting record: %v", err)
				}
				if pdkRec == nil {
					t.Fatalf("should have a record")
				}
				data := pdkRec.Data()
				if !reflect.DeepEqual(data, test.exp[j]) {
					t.Errorf("data mismatch exp/got:\n%+v\n%+v", test.exp[j], data)
					if len(data) != len(test.exp[j]) {
						t.Fatalf("mismatched lengths exp/got %d/%d", len(test.exp[j]), len(data))
					}
					for k := range test.exp[j] {
						if !reflect.DeepEqual(test.exp[j][k], data[k]) {
							t.Errorf("Mismatch at %d, exp/got\n%v of %[2]T\n%v of %[3]T", k, test.exp[j][k], data[k])
						}
					}

				}
			}
		})
	}
}

func mustNewProducer(t *testing.T, kafkaHost string) *confluent.Producer {
	t.Helper()
	p, err := confluent.NewProducer(&confluent.ConfigMap{
		"bootstrap.servers": kafkaHost,
	})
	if err != nil {
		t.Fatalf("Failed to create producer: %s", err)
	}
	return p
}

func TestKafkaSourceSkipOld(t *testing.T) {
	if testing.Short() {
		t.Skip()
	}
	src := NewSource()
	defer src.Close()
	configureSourceTestFlags(src)
	src.Topics = []string{"testKafkaSourceSkipOld"}
	src.Group = "group1"
	src.SkipOld = true
	src.Timeout = 10 * time.Second
	src.Verbose = true
	src.KafkaSocketTimeoutMs = int(src.Timeout / 2 / time.Millisecond)
	src.Log = logger.NewVerboseLogger(os.Stdout)

	// Create Producer instance
	p := mustNewProducer(t, kafkaHost)
	defer p.Close()
	tCreateTopic(t, src.Topics[0], p)

	schemaID := postSchema(t, "simple.json", "skipOldSchema", registryHost, nil)
	schema := liDecodeTestSchema(t, "simple.json")

	rnd := rand.New(rand.NewSource(time.Now().UnixNano()))
	key := fmt.Sprintf("%d", rnd.Int())

	// 1. put a record into kafka
	tPutRecordsKafka(t, p, src.Topics[0], schemaID, schema, key, map[string]interface{}{"first": "hello", "last": "goodbye"})

	// src.Open... this starts actually reading from Kafka asynchronously. With skipOld, it should not see the record.
	if err := src.Open(); err != nil {
		t.Fatalf("opening source: %v", err)
	}

	// grab the record which has been read from Kafka by the goroutine started in src.Open.... or timeout (we're expecting timeout)
	if rec, err := src.Record(); err == nil {
		t.Fatalf("expected error, got record: %v", rec)
	} else if err != idk.ErrFlush {
		t.Fatalf("expected flush error, got %v", err)
	}

	// put another record into Kafka
	tPutRecordsKafka(t, p, src.Topics[0], schemaID, schema, key, map[string]interface{}{"first": "a", "last": "b"})

	// grab that record or timeout (we're expecting to get it this
	// time)... but we've seen weird issues where if src.Timeout is
	// too low (like 5s) we don't see this record (on confluent 1.4.2)
	rec, err := src.Record()
	switch err {
	case nil:
		t.Fatal("expected schema change")
	case idk.ErrSchemaChange:
	default:
		t.Fatalf("expected schema change, got %v", err)
	}
	expect := []interface{}{"a", "b"}
	if got := rec.Data(); !reflect.DeepEqual(got, []interface{}{"a", "b"}) {
		t.Fatalf("expected %v but got %v", expect, got)
	}
}

func TestKafkaSourceNotAutoCommitting(t *testing.T) {
	t.Parallel()
	if testing.Short() {
		t.Skip()
	}
	rand.Seed(time.Now().UnixNano())
	src := NewSource()
	defer src.Close()
	configureSourceTestFlags(src)
	src.Topics = []string{"testKafkaSourceNotAutoCommit"}
	src.Group = fmt.Sprintf("group%d", rand.Int())

	// Create Producer instance
	p := mustNewProducer(t, kafkaHost)
	defer p.Close()
	tCreateTopic(t, src.Topics[0], p)

	schemaID := postSchema(t, "simple.json", "skipOldSchema", registryHost, nil)
	schema := liDecodeTestSchema(t, "simple.json")

	rnd := rand.New(rand.NewSource(time.Now().UnixNano()))
	key := fmt.Sprintf("%d", rnd.Int())

	if err := src.Open(); err != nil {
		t.Fatalf("opening source: %v", err)
	}
	partition := int32(1)

	// 1. put records into Kafka
	numRecords := int64(7)
	for i := int64(0); i < numRecords; i++ {
		tPutRecordsKafkaPartition(t, p, src.Topics[0], schemaID, schema, key, partition, map[string]interface{}{"first": fmt.Sprintf("%d", i), "last": fmt.Sprintf("%d", i+1)})
	}

	// 2. read records from Kafka (but don't commit!)
	var rec idk.Record
	var err error
	for i := 0; i < 7; i++ {
		if rec, err = src.Record(); err != nil && err != idk.ErrSchemaChange {
			t.Fatalf("expected schema change or no error, got: %v", err)
		}
	}

	// 3. wait for it...
	time.Sleep(time.Second * 6) // default autocommit interval is 5s, so we have to wait if we're going to catch that

	// 4. check to see if any offsets got committed. If
	// enable.auto.commit somehow gets set to true (which is the
	// default), then this should fail.
	if offsets, err := src.client.Committed([]confluent.TopicPartition{{Topic: &src.Topics[0], Partition: partition}}, 100); err != nil {
		t.Fatalf("getting committed offsets: %v", err)
	} else if len(offsets) != 1 {
		t.Fatalf("unexpected number of offsets in response: %v", offsets)
	} else if off := offsets[0]; off.Offset > 0 {
		t.Fatalf("initial committed offset is greater than 0: %d", off.Offset)
	}

	// 5. now commit
	err = rec.Commit(context.Background())
	if err != nil {
		t.Fatalf("committing: %v", err)
	}

	// 6. now verify commit worked
	if offsets, err := src.client.Committed([]confluent.TopicPartition{{Topic: &src.Topics[0], Partition: partition}}, 100); err != nil {
		t.Fatalf("getting committed offsets: %v", err)
	} else if len(offsets) != 1 {
		t.Fatalf("unexpected number of offsets in response: %v", offsets)
	} else if off := offsets[0]; int64(off.Offset) != numRecords {
		t.Fatalf("after commit, offset is not %d: %d", numRecords-1, off.Offset)
	}
}

func TestRegistryURLParsing(t *testing.T) {
	t.Parallel()

	tests := []struct {
		name             string
		registryURL      string
		expectedCodecURL string
	}{
		{
			name:             "host:port",
			registryURL:      "localhost:8081",
			expectedCodecURL: "http://localhost:8081/schemas/ids/1",
		},
		{
			name:             "host",
			registryURL:      "localhost",
			expectedCodecURL: "http://localhost/schemas/ids/1",
		},
		{
			name:             "scheme:host:port",
			registryURL:      "http://localhost:8081",
			expectedCodecURL: "http://localhost:8081/schemas/ids/1",
		},
		{
			name:             "scheme:host",
			registryURL:      "http://localhost",
			expectedCodecURL: "http://localhost/schemas/ids/1",
		},
		{
			name:             "scheme:host",
			registryURL:      "http://localhost",
			expectedCodecURL: "http://localhost/schemas/ids/1",
		},
		{
			name:             "scheme:host/path",
			registryURL:      "http://subsub.subdomain.domain/kafka-schema-registry",
			expectedCodecURL: "http://subsub.subdomain.domain/kafka-schema-registry/schemas/ids/1",
		},
		{
			name:             "scheme:host:port/path",
			registryURL:      "http://subsub.subdomain.domain:8081/kafka-schema-registry",
			expectedCodecURL: "http://subsub.subdomain.domain:8081/kafka-schema-registry/schemas/ids/1",
		},
		{
			name:             "https:host:port/path",
			registryURL:      "https://subsub.subdomain.domain:8081/kafka-schema-registry",
			expectedCodecURL: "https://subsub.subdomain.domain:8081/kafka-schema-registry/schemas/ids/1",
		},
		{
			name:             "https:host:port",
			registryURL:      "https://localhost:8081",
			expectedCodecURL: "https://localhost:8081/schemas/ids/1",
		},
	}

	for i, test := range tests {
		test := test
		t.Run(fmt.Sprintf("%s-%d", test.name, i), func(t *testing.T) {
			t.Parallel()

			src := &Source{}
			src.SchemaRegistryURL = test.registryURL

			err := src.cleanRegistryURL()
			if err != nil {
				t.Fatalf("cleaning registry URL: %v", err)
			}

			codecURL, err := src.codecURL(1, "schemas/ids/%d")
			if err != nil {
				t.Fatalf("codec URL: %v", err)
			}
			if codecURL != test.expectedCodecURL {
				t.Errorf("codec URL exp:\n%s\ngot:\n%s", test.expectedCodecURL, codecURL)
			}
		})
	}
}

func postSchema(t *testing.T, schemaFile, subj, regURL string, tlsConfig *tls.Config) (schemaID int) {
	schemaClient := csrc.NewClient(regURL, tlsConfig, nil)
	schemaStr := readTestSchema(t, schemaFile)
	resp, err := schemaClient.PostSubjects(subj, schemaStr)
	if err != nil {
		t.Fatalf("posting schema: %v", err)
	}
	return resp.ID
}

func tCreateTopic(t *testing.T, topic string, p *confluent.Producer) {
	t.Helper()
	a, err := confluent.NewAdminClientFromProducer(p)
	if err != nil {
		t.Fatalf("Failed to create new admin client from producer: %s", err)
	}
	// Contexts are used to abort or limit the amount of time
	// the Admin call blocks waiting for a result.
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()
	// Create topics on cluster.
	// Set Admin options to wait up to 60s for the operation to finish on the remote cluster
	maxDur, err := time.ParseDuration("60s")
	if err != nil {
		t.Fatalf("ParseDuration(60s): %s", err)
	}
	results, err := a.CreateTopics(
		ctx,
		// Multiple topics can be created simultaneously
		// by providing more TopicSpecification structs here.
		[]confluent.TopicSpecification{{
			Topic:             topic,
			NumPartitions:     64,
			ReplicationFactor: 1,
		}},
		// Admin options
		confluent.SetAdminOperationTimeout(maxDur))
	if err != nil {
		t.Fatalf("Admin Client request error: %v\n", err)
	}
	for _, result := range results {
		if result.Error.Code() != confluent.ErrNoError && result.Error.Code() != confluent.ErrTopicAlreadyExists {
			t.Fatalf("Failed to create topic: %v\n", result.Error)
		}
	}
	a.Close()
}

func tPutRecordsKafka(t *testing.T, p *confluent.Producer, topic string, schemaID int, schema *liavro.Codec, key string, records ...map[string]interface{}) {
	tPutRecordsKafkaPartition(t, p, topic, schemaID, schema, key, confluent.PartitionAny, records...)
}

func tPutRecordsKafkaPartition(t *testing.T, p *confluent.Producer, topic string, schemaID int, schema *liavro.Codec, key string, partition int32, records ...map[string]interface{}) {
	t.Helper()
	delivery_chan := make(chan kafka.Event, 10000)
	for _, record := range records {
		data, err := endcodeAvro(schemaID, schema, record)
		if err != nil {
			t.Fatalf("encoding record: %v", err)
		}
		err = p.Produce(&kafka.Message{
			TopicPartition: kafka.TopicPartition{Topic: &topic, Partition: partition},
			Key:            []byte(key),
			Value:          data,
		}, delivery_chan)
		if err != nil {
			t.Fatalf("producing record: %v", err)
		}
		e := <-delivery_chan
		m := e.(*kafka.Message)

		if m.TopicPartition.Error != nil {
			t.Fatalf("Delivery failed: %v\n", m.TopicPartition.Error)
		} else {
			vprint.VV("Delivered message to topic %s [%d] at offset %v\n",
				*m.TopicPartition.Topic, m.TopicPartition.Partition, m.TopicPartition.Offset)
		}
	}
	close(delivery_chan)
}

var expectedSchemas = map[string][]idk.Field{
	"simple.json": {
		idk.StringField{NameVal: "first"},
		idk.StringField{NameVal: "last"},
	},
	"stringtypes.json": {
		idk.StringField{NameVal: "first", Mutex: true},
		idk.StringField{NameVal: "last"},
		idk.StringField{NameVal: "middle"},
	},
	"decimal.json": {
		idk.DecimalField{NameVal: "somenum", Scale: 2},
	},
	"unions.json": {
		idk.StringField{NameVal: "first"},
		idk.BoolField{NameVal: "second"},
		idk.IntField{NameVal: "third"},
		idk.DecimalField{NameVal: "fourth", Scale: 3},
		idk.DecimalField{NameVal: "fifth", Scale: 2},
	},
	"othertypes.json": {
		idk.StringField{NameVal: "first", Mutex: true},
		idk.StringArrayField{NameVal: "second"},
		idk.IntField{NameVal: "third"},
		idk.IntField{NameVal: "fourth"},
		idk.DecimalField{NameVal: "fifth"},
		idk.DecimalField{NameVal: "sixth"},
		idk.BoolField{NameVal: "seventh"},
		idk.StringField{NameVal: "eighth", Mutex: true},
		idk.StringField{NameVal: "ninth", Mutex: true},
	},
	"floatscale.json": {
		idk.DecimalField{NameVal: "first", Scale: 4},
	},
	"timestamp.json": {
		idk.TimestampField{NameVal: "time_samir", Granularity: "s", Unit: "ms"},
	},
}

// Ensure the avro schemas produce the exptected JSON.
func TestAvroSchemaFieldToJSON(t *testing.T) {
	t.Parallel()

	tests := []struct {
		schemaField *avro.SchemaField
		exp         string
	}{
		{
			schemaField: &avro.SchemaField{
				Type: &avro.BytesSchema{},
			},
			exp: `{"type":"bytes"}`,
		},
		{
			schemaField: &avro.SchemaField{
				Name: "union-null-bytes",
				Type: &avro.UnionSchema{
					Types: []avro.Schema{
						&avro.NullSchema{},
						&avro.BytesSchema{},
					},
				},
				Default: nil,
			},
			exp: `{"name":"union-null-bytes","default":null,"type":["null","bytes"]}`,
		},
		{
			schemaField: &avro.SchemaField{
				Name: "decimal",
				Type: &avro.BytesSchema{
					Properties: map[string]interface{}{
						"logicalType": "decimal",
						"scale":       2,
						"precision":   5,
					},
				},
			},
			exp: `{"name":"decimal","type":{"type":"bytes","logicalType":"decimal","scale":2,"precision":5}}`,
		},
		{
			schemaField: &avro.SchemaField{
				Name: "union-null-decimal",
				Type: &avro.UnionSchema{
					Types: []avro.Schema{
						&avro.NullSchema{},
						&avro.BytesSchema{
							Properties: map[string]interface{}{
								"logicalType": "decimal",
								"scale":       2,
								"precision":   5,
							},
						},
					},
				},
			},
			exp: `{"name":"union-null-decimal","type":["null",{"type":"bytes","logicalType":"decimal","scale":2,"precision":5}],"default":null}`,
		},
		{
			schemaField: &avro.SchemaField{
				Name: "float",
				Type: &avro.FloatSchema{},
			},
			exp: `{"name":"float","type":"float"}`,
		},
		{
			schemaField: &avro.SchemaField{
				Name: "float-props",
				Type: &avro.FloatSchema{
					Properties: map[string]interface{}{
						"scale": 2,
					},
				},
			},
			exp: `{"name":"float-props","type":{"type":"float","scale":2}}`,
		},
		{
			schemaField: &avro.SchemaField{
				Name: "union-null-float",
				Type: &avro.UnionSchema{
					Types: []avro.Schema{
						&avro.NullSchema{},
						&avro.FloatSchema{
							Properties: map[string]interface{}{
								"scale": 2,
							},
						},
					},
				},
			},
			exp: `{"name":"union-null-float","type":["null",{"type":"float","scale":2}],"default":null}`,
		},
		{
			schemaField: &avro.SchemaField{
				Name: "timestamp",
				Type: &avro.BytesSchema{
					Properties: map[string]interface{}{
						"logicalType": "timestamp",
						"granularity": "s",
						"unit":        "ms",
					},
				},
			},
			exp: `{"name":"timestamp","type":{"granularity":"s","logicalType":"timestamp","type":"bytes","unit":"ms"}}`,
		},
	}

	for i, test := range tests {
		j, err := json.Marshal(test.schemaField)
		if err != nil {
			t.Fatal(err)
		}
		if eq, err := equalJSON(string(j), test.exp); err != nil {
			t.Fatal(err)
		} else if !eq {
			t.Fatalf("test %d: \nexp: %s, \ngot: %s", i, test.exp, j)
		}
	}
}

func equalJSON(s1, s2 string) (bool, error) {
	var o1 interface{}
	var o2 interface{}

	var err error
	err = json.Unmarshal([]byte(s1), &o1)
	if err != nil {
		return false, fmt.Errorf("Error mashalling string 1 :: %s", err.Error())
	}
	err = json.Unmarshal([]byte(s2), &o2)
	if err != nil {
		return false, fmt.Errorf("Error mashalling string 2 :: %s", err.Error())
	}

	return reflect.DeepEqual(o1, o2), nil
}

func TestGetKeyFunc(t *testing.T) {
	tests := []struct {
		rec    []interface{}
		expKey uint64
	}{
		{
			rec:    []interface{}{"aaa"},
			expKey: uint64(162),
		},
		{
			rec:    []interface{}{"aaaaaaaaaaaaaaa"},
			expKey: uint64(86),
		},
	}

	p, err := NewPutSource()
	if err != nil {
		t.Fatalf("failed to return new putsource: %v", err)
	}

	p.FBPrimaryKeyFields = []string{"uuid"}
	p.FBIDField = "uuid"
	p.FBIndexName = "test"

	schema := []idk.Field{
		idk.StringField{NameVal: "uuid"},
	}

	getKeyFunc, err := p.getKeyFunc(schema)
	if err != nil {
		t.Fatalf("failed to getKeyFunc: %v", err)
	}

	for i, test := range tests {
		tc := test
		t.Run(fmt.Sprintf("test%d", i), func(t *testing.T) {
			key, err := getKeyFunc(tc.rec)
			if err != nil {
				t.Fatalf("failed to get key for record: %v", err)
			}
			keyInt := uint64(binary.BigEndian.Uint64(key))
			if tc.expKey != keyInt {
				t.Fatalf("expected %d, got %d", tc.expKey, keyInt)
			}
		})
	}
}
