package kinesis

import (
	"context"
	"fmt"
	"io"
	"os"
	"strconv"
	"strings"
	"testing"
	"time"

	"github.com/aws/aws-sdk-go/aws"
	"github.com/aws/aws-sdk-go/service/kinesis"
	"github.com/featurebasedb/featurebase/v3/idk/idktest/mocks"
	"github.com/featurebasedb/featurebase/v3/logger"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/mock"
)

var (
	s3mock      = &mocks.S3API{}
	kinesisMock = &mocks.KinesisAPI{}
)

func newMockedStreamReader(t *testing.T, offsetsPath string) *StreamReader {
	cfg := StreamReaderConfig{
		log:           logger.NopLogger,
		streamName:    "teststream",
		offsetsPath:   offsetsPath,
		kinesisClient: kinesisMock,
		s3client:      s3mock,
	}
	reader, err := NewStreamReader(cfg)
	if err != nil {
		t.Fatalf("Unexpected error creating stream reader: %v", err)
	}

	reader.getRecordsBatchSize = 2
	reader.getRecordsQueriesPerSecondBehindTip = 1
	reader.getRecordsQueriesPerSecondAtTip = 1
	return reader
}

func TestNewKinesisReaderWithMissingOffsetsFile(t *testing.T) {
	tmpDir := t.TempDir()
	cfg := StreamReaderConfig{
		log:           logger.NopLogger,
		streamName:    "teststream",
		offsetsPath:   fmt.Sprintf("%s/missing", tmpDir),
		kinesisClient: kinesisMock,
		s3client:      s3mock,
	}
	reader, err := NewStreamReader(cfg)
	assert.NoError(t, err)
	assert.NotNil(t, reader)
	assert.NotNil(t, reader.offsets)
	assert.Empty(t, reader.offsets.Shards)
}

func TestNewKinesisReaderWithInvalidOffsetsFile(t *testing.T) {
	cfg := StreamReaderConfig{
		log:           logger.NopLogger,
		streamName:    "teststream",
		offsetsPath:   "./testdata/invalid_offsets.json",
		kinesisClient: kinesisMock,
		s3client:      s3mock,
	}
	reader, err := NewStreamReader(cfg)
	assert.Error(t, err)
	assert.Nil(t, reader)
}

func TestNewKinesisReaderWithExistingOffsets(t *testing.T) {
	tmpDir := t.TempDir()
	offsets := fmt.Sprintf("%s/offsets.json", tmpDir)
	if err := copyFile(offsets, "./testdata/offsets.json"); err != nil {
		t.Fatal("failed to copy offsets to temp directory")
	}

	cfg := StreamReaderConfig{
		log:           logger.NopLogger,
		streamName:    "teststream",
		offsetsPath:   offsets,
		kinesisClient: kinesisMock,
		s3client:      s3mock,
	}
	reader, err := NewStreamReader(cfg)
	assert.NoError(t, err)
	assert.NotNil(t, reader)

	shardOffset, found := reader.offsets.Load("shardId-000000000000")
	assert.True(t, found)
	assert.NotNil(t, shardOffset)
	assert.Equal(t, "49626031806177636477557067721327867885693021124781146130", shardOffset.SequenceNumber)
	assert.EqualValues(t, shardOffset.Index, 1)

	shardOffset, found = reader.offsets.Load("shardId-000000000001")
	assert.True(t, found)
	assert.NotNil(t, shardOffset)
	assert.Equal(t, "49626031806199937222755598499219162069555906398413389858", shardOffset.SequenceNumber)
	assert.EqualValues(t, shardOffset.Index, 2)
}

func shardName(shardIDx int) string {
	return fmt.Sprintf("shardId-00000000000%d", shardIDx)
}

func shardIterator(shardIDx int) string {
	return fmt.Sprintf("%s-%d", shardName(shardIDx), time.Now().UnixNano())
}

func TestStreamReaderStartFetchCommitWithoutOffsets(t *testing.T) {
	tmpDir := t.TempDir()
	reader := newMockedStreamReader(t, fmt.Sprintf("%s/offsets.json", tmpDir))

	describeStreamOut := &kinesis.DescribeStreamOutput{
		StreamDescription: &kinesis.StreamDescription{
			StreamStatus: aws.String(kinesis.StreamStatusActive),
		},
	}

	const testShardCount = 2
	for i := 0; i < testShardCount; i++ {
		describeStreamOut.StreamDescription.Shards = append(describeStreamOut.StreamDescription.Shards,
			&kinesis.Shard{ShardId: aws.String(shardName(i))})
	}

	kinesisMock.On("DescribeStream",
		mock.MatchedBy(func(input *kinesis.DescribeStreamInput) bool {
			return *input.StreamName == reader.streamName
		}),
	).Return(describeStreamOut, nil)

	for i := 0; i < testShardCount; i++ {
		c := i
		getShardIteratorOutput := &kinesis.GetShardIteratorOutput{ShardIterator: aws.String(shardIterator(c))}
		kinesisMock.On("GetShardIterator",
			mock.MatchedBy(func(input *kinesis.GetShardIteratorInput) bool {
				return *input.StreamName == reader.streamName && *input.ShardId == shardName(c) &&
					*input.ShardIteratorType == kinesis.ShardIteratorTypeTrimHorizon
			}),
		).Return(getShardIteratorOutput, nil)

		getRecordsOutput := &kinesis.GetRecordsOutput{
			Records: []*kinesis.Record{
				{SequenceNumber: aws.String("1"), Data: []byte("deadbeef"), ApproximateArrivalTimestamp: aws.Time(time.Now().UTC())},
			},
			MillisBehindLatest: aws.Int64(100),
			NextShardIterator:  aws.String(shardIterator(c)),
		}
		kinesisMock.On("GetRecords",
			mock.MatchedBy(func(input *kinesis.GetRecordsInput) bool {
				return strings.HasPrefix(*input.ShardIterator, shardName(c))
			}),
		).Return(getRecordsOutput, nil)
	}

	err := reader.Start()
	assert.NoError(t, err)

	var records []ShardRecord
	for i := 0; i < testShardCount; i++ {
		rec, err := reader.FetchMessage(context.Background())
		assert.NoError(t, err)
		assert.Equal(t, rec.SequenceNumber, aws.String("1"))
		records = append(records, rec)
	}
	assert.Empty(t, reader.offsets.Shards)

	err = reader.CommitMessages(context.Background(), records...)
	assert.NoError(t, err)

	assert.Len(t, reader.offsets.Shards, 2)
	for i := 0; i < testShardCount; i++ {
		shardOffset, exists := reader.offsets.Load(shardName(i))
		assert.True(t, exists)
		assert.EqualValues(t, shardOffset.Index, 1)
	}
	defer reader.Close()
}

func copyFile(dst, src string) error {
	to, err := os.Create(dst)
	if err != nil {
		return err
	}
	defer to.Close()

	from, err := os.Open(src)
	if err != nil {
		return err
	}
	defer from.Close()

	_, err = io.Copy(to, from)
	if err != nil {
		return err
	}
	return nil
}

func TestStreamReaderStartFetchCommitFromExistingOffsets(t *testing.T) {
	tmpDir := t.TempDir()
	offsets := fmt.Sprintf("%s/offsets.json", tmpDir)
	if err := copyFile(offsets, "./testdata/offsets.json"); err != nil {
		t.Fatal("failed to copy offsets to temp directory")
	}

	reader := newMockedStreamReader(t, offsets)

	describeStreamOut := &kinesis.DescribeStreamOutput{
		StreamDescription: &kinesis.StreamDescription{
			StreamStatus: aws.String(kinesis.StreamStatusActive),
		},
	}

	const testShardCount = 2
	for i := 0; i < testShardCount; i++ {
		describeStreamOut.StreamDescription.Shards = append(describeStreamOut.StreamDescription.Shards,
			&kinesis.Shard{ShardId: aws.String(shardName(i))})
	}

	kinesisMock.On("DescribeStream",
		mock.MatchedBy(func(input *kinesis.DescribeStreamInput) bool {
			return *input.StreamName == reader.streamName
		}),
	).Return(describeStreamOut, nil)

	for i := 0; i < testShardCount; i++ {
		c := i
		getShardIteratorOutput := &kinesis.GetShardIteratorOutput{ShardIterator: aws.String(shardIterator(c))}
		kinesisMock.On("GetShardIterator",
			mock.MatchedBy(func(input *kinesis.GetShardIteratorInput) bool {
				return *input.StreamName == reader.streamName && *input.ShardId == shardName(c) &&
					*input.ShardIteratorType == kinesis.ShardIteratorTypeAfterSequenceNumber
			}),
		).Return(getShardIteratorOutput, nil)

		getRecordsOutput := &kinesis.GetRecordsOutput{
			Records: []*kinesis.Record{
				{SequenceNumber: aws.String("1"), Data: []byte("deadbeef"), ApproximateArrivalTimestamp: aws.Time(time.Now().UTC())},
			},
			MillisBehindLatest: aws.Int64(100),
			NextShardIterator:  aws.String(shardIterator(c)),
		}
		kinesisMock.On("GetRecords",
			mock.MatchedBy(func(input *kinesis.GetRecordsInput) bool {
				return strings.HasPrefix(*input.ShardIterator, shardName(c))
			}),
		).Return(getRecordsOutput, nil)
	}

	err := reader.Start()
	assert.NoError(t, err)

	var records []ShardRecord
	for i := 0; i < testShardCount; i++ {
		rec, err := reader.FetchMessage(context.Background())
		assert.NoError(t, err)
		assert.Equal(t, rec.SequenceNumber, aws.String("1"))
		records = append(records, rec)
	}
	assert.Len(t, reader.offsets.Shards, 2)
	for i := 0; i < testShardCount; i++ {
		shardOffset, exists := reader.offsets.Load(shardName(i))
		assert.True(t, exists)
		assert.EqualValues(t, shardOffset.Index, i+1)
	}

	err = reader.CommitMessages(context.Background(), records...)
	assert.NoError(t, err)

	assert.Len(t, reader.offsets.Shards, 2)
	for i := 0; i < testShardCount; i++ {
		shardOffset, exists := reader.offsets.Load(shardName(i))
		assert.True(t, exists)
		assert.EqualValues(t, shardOffset.Index, i+2)
	}
	defer reader.Close()
}

func TestStreamOrderlyReadsAfterResharding(t *testing.T) {
	s3mock = &mocks.S3API{}
	kinesisMock = &mocks.KinesisAPI{}

	tmpDir := t.TempDir()

	reader := newMockedStreamReader(t, fmt.Sprintf("%s/offsets.json", tmpDir))
	defer reader.Close()

	describeStreamOut := &kinesis.DescribeStreamOutput{
		StreamDescription: &kinesis.StreamDescription{
			StreamStatus: aws.String(kinesis.StreamStatusActive),
		},
	}
	describeStreamOut.StreamDescription.Shards = []*kinesis.Shard{{ShardId: aws.String(shardName(0))}}
	kinesisMock.On("DescribeStream",
		mock.MatchedBy(func(input *kinesis.DescribeStreamInput) bool {
			return *input.StreamName == reader.streamName
		}),
	).Return(describeStreamOut, nil).Once()

	getShardIteratorOutput := &kinesis.GetShardIteratorOutput{ShardIterator: aws.String(shardIterator(0))}
	kinesisMock.On("GetShardIterator",
		mock.MatchedBy(func(input *kinesis.GetShardIteratorInput) bool {
			return *input.StreamName == reader.streamName && *input.ShardId == shardName(0) &&
				*input.ShardIteratorType == kinesis.ShardIteratorTypeTrimHorizon
		}),
	).Return(getShardIteratorOutput, nil).Once()

	const recordsBeforeClose = 2
	for i := 1; i <= recordsBeforeClose; i++ {
		c := strconv.Itoa(i)
		getRecordsOutput := &kinesis.GetRecordsOutput{
			Records: []*kinesis.Record{
				{SequenceNumber: aws.String(c), Data: []byte(c), ApproximateArrivalTimestamp: aws.Time(time.Now().UTC())},
			},
			MillisBehindLatest: aws.Int64(100),
			NextShardIterator:  aws.String(shardIterator(0)),
		}
		// last record signals the shard is closed
		if i == recordsBeforeClose {
			getRecordsOutput.NextShardIterator = nil
		}
		kinesisMock.On("GetRecords",
			mock.MatchedBy(func(input *kinesis.GetRecordsInput) bool {
				return strings.HasPrefix(*input.ShardIterator, shardName(0))
			}),
		).After(time.Second).Return(getRecordsOutput, nil).Once()
	}

	getRecordsOutput := &kinesis.GetRecordsOutput{
		Records: []*kinesis.Record{
			{SequenceNumber: aws.String("1"), Data: []byte("1"), ApproximateArrivalTimestamp: aws.Time(time.Now().UTC())},
		},
		MillisBehindLatest: aws.Int64(100),
		NextShardIterator:  aws.String(shardIterator(0)),
	}
	kinesisMock.On("GetRecords",
		mock.MatchedBy(func(input *kinesis.GetRecordsInput) bool {
			return strings.HasPrefix(*input.ShardIterator, shardName(0))
		}),
	).After(time.Second).Return(getRecordsOutput, nil).Once()

	describeCalls := 2
	for i := 1; i <= describeCalls; i++ {
		// stream goes into Updating status
		describeStreamOut = &kinesis.DescribeStreamOutput{
			StreamDescription: &kinesis.StreamDescription{
				StreamStatus: aws.String(kinesis.StreamStatusUpdating),
				Shards:       []*kinesis.Shard{{ShardId: aws.String(shardName(0))}},
			},
		}
		// last call transitions stream to active and adds child shard
		if i == describeCalls {
			describeStreamOut.StreamDescription.StreamStatus = aws.String(kinesis.StreamStatusActive)
			describeStreamOut.StreamDescription.Shards = append(describeStreamOut.StreamDescription.Shards,
				&kinesis.Shard{ShardId: aws.String(shardName(1)), ParentShardId: aws.String(shardName(0))})
		}

		kinesisMock.On("DescribeStream",
			mock.MatchedBy(func(input *kinesis.DescribeStreamInput) bool {
				return *input.StreamName == reader.streamName
			}),
		).Return(describeStreamOut, nil).Once()
	}

	getShardIteratorOutput = &kinesis.GetShardIteratorOutput{ShardIterator: aws.String(shardIterator(1))}
	kinesisMock.On("GetShardIterator",
		mock.MatchedBy(func(input *kinesis.GetShardIteratorInput) bool {
			return *input.StreamName == reader.streamName && *input.ShardId == shardName(1) &&
				*input.ShardIteratorType == kinesis.ShardIteratorTypeTrimHorizon
		}),
	).Return(getShardIteratorOutput, nil).Once()

	getRecordsOutput = &kinesis.GetRecordsOutput{
		Records: []*kinesis.Record{
			{SequenceNumber: aws.String("1"), Data: []byte("1"), ApproximateArrivalTimestamp: aws.Time(time.Now().UTC())},
		},
		MillisBehindLatest: aws.Int64(0),
		NextShardIterator:  aws.String(shardIterator(1)),
	}
	kinesisMock.On("GetRecords",
		mock.MatchedBy(func(input *kinesis.GetRecordsInput) bool {
			return strings.HasPrefix(*input.ShardIterator, shardName(1))
		}),
	).Return(getRecordsOutput, nil).Once()

	err := reader.Start()
	assert.NoError(t, err)

	for i := 1; i <= recordsBeforeClose; i++ {
		rec, err := reader.FetchMessage(context.Background())
		assert.NoError(t, err)
		assert.Equal(t, strconv.Itoa(i), *rec.SequenceNumber)
		assert.Equal(t, rec.ShardID, shardName(0))
	}

	rec, err := reader.FetchMessage(context.Background())
	assert.NoError(t, err)
	assert.Equal(t, "1", *rec.SequenceNumber)
	assert.Equal(t, rec.ShardID, shardName(1))

}
