package main

import (
	"context"
	"encoding/json"
	"flag"
	"fmt"
	"io"
	"os"
	"strconv"
	"strings"
	"time"

	"github.com/aws/aws-sdk-go-v2/aws"
	"github.com/aws/aws-sdk-go-v2/config"
	"github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs"
	"github.com/aws/aws-sdk-go-v2/service/s3"
)

const (
	idCounterBase = 10000000
)

var (
	inputMap map[uint32]struct{}
)

type Message struct {
	Log string
}

func main() {
	// Define flags
	region := flag.String("region", "", "AWS Region")
	bucket := flag.String("bucket", "", "S3 Bucket Name")
	logGroup := flag.String("log-group", "", "CloudWatch Log Group Name")
	prefix := flag.String("prefix", "", "Log Prefix")
	destination := flag.String("destination", "", "Log Destination (s3 or cloudwatch)")
	inputRecord := flag.Int("input-record", 0, "Total input record number")
	logDelay := flag.String("log-delay", "", "Log delay")

	// Parse flags
	flag.Parse()

	// Validate required flags
	if *region == "" {
		exitErrorf("[TEST FAILURE] AWS Region required. Use the -region flag.")
	}
	if *bucket == "" {
		exitErrorf("[TEST FAILURE] Bucket name required. Use the -bucket flag.")
	}
	if *logGroup == "" {
		exitErrorf("[TEST FAILURE] Log group name required. Use the -log-group flag.")
	}
	if *prefix == "" {
		exitErrorf("[TEST FAILURE] Object prefix required. Use the -prefix flag.")
	}
	if *destination == "" {
		exitErrorf("[TEST FAILURE] Log destination for validation required. Use the -destination flag.")
	}
	if *inputRecord == 0 {
		exitErrorf("[TEST FAILURE] Total input record number required. Use the -input-record flag.")
	}
	if *logDelay == "" {
		exitErrorf("[TEST FAILURE] Log delay required. Use the -log-delay flag.")
	}

	// Map for counting unique records in corresponding destination
	inputMap = make(map[uint32]struct{}, *inputRecord)

	totalRecordFound := 0
	if *destination == "s3" || *destination == "kinesis" || *destination == "firehose" {
		s3Client, err := getS3Client(*region)
		if err != nil {
			exitErrorf("[TEST FAILURE] Unable to create new S3 client: %v", err)
		}

		totalRecordFound = validate_s3(s3Client, *bucket, *prefix)
	} else if *destination == "cloudwatch" {
		cwClient, err := getCWClient(*region)
		if err != nil {
			exitErrorf("[TEST FAILURE] Unable to create new CloudWatch client: %v", err)
		}

		totalRecordFound = validate_cloudwatch(cwClient, *logGroup, *prefix)
	}

	// Get benchmark results based on log loss, log delay and log duplication
	get_results(*inputRecord, totalRecordFound, *logDelay)
}

// Creates a new S3 Client
func getS3Client(region string) (*s3.Client, error) {
	cfg, err := config.LoadDefaultConfig(context.TODO(),
		config.WithRegion(region),
	)
	if err != nil {
		return nil, err
	}

	return s3.NewFromConfig(cfg), nil
}

// Validates the log messages. Our log producer is designed to write log records in a specific format.
// Log format generated by our producer: 8CharUniqueID_13CharTimestamp_RandomString (10029999_1639151827578_RandomString).
// Both of the Kinesis Streams and Kinesis Firehose try to send each log maintaining the "at least once" policy.
// To validate, we need to make sure all the log records from input file are stored at least once.
func validate_s3(s3Client *s3.Client, bucket string, prefix string) int {
	var continuationToken *string
	s3RecordCounter := 0
	s3ObjectCounter := 0
	ctx := context.TODO()

	for {
		input := &s3.ListObjectsV2Input{
			Bucket:            aws.String(bucket),
			ContinuationToken: continuationToken,
			Prefix:            aws.String(prefix),
		}

		response, err := s3Client.ListObjectsV2(ctx, input)
		if err != nil {
			exitErrorf("[TEST FAILURE] Error occurred to get the objects from bucket: %q., %v", bucket, err)
		}

		for _, content := range response.Contents {
			input := &s3.GetObjectInput{
				Bucket: aws.String(bucket),
				Key:    content.Key,
			}
			obj, err := s3Client.GetObject(ctx, input)
			if err != nil {
				exitErrorf("[TEST FAILURE] Error to get S3 object. %v", err)
			}
			s3ObjectCounter++

			// Directly unmarshal the JSON objects from the S3 object body
			decoder := json.NewDecoder(obj.Body)
			for {
				var message Message
				err := decoder.Decode(&message)
				if err == io.EOF {
					break
				}
				if err != nil {
					fmt.Println("[TEST ERROR] Malform log entry. Unmarshal Error:", err)
					continue
				}

				recordId := message.Log[:8]
				s3RecordCounter++
				value, err := strconv.ParseUint(recordId, 10, 32)
				if err != nil {
					fmt.Println("[TEST ERROR] Malform log entry. ParseUint Error:", err)
					continue
				}
				recordIdUint := uint32(value)
				inputMap[recordIdUint] = struct{}{}
			}

			// Close the S3 object body
			obj.Body.Close()
		}

		if !aws.ToBool(response.IsTruncated) {
			break
		}
		continuationToken = response.NextContinuationToken
	}

	fmt.Println("total_s3_obj, ", s3ObjectCounter)

	return s3RecordCounter
}

// Creates a new CloudWatch Client
func getCWClient(region string) (*cloudwatchlogs.Client, error) {
	cfg, err := config.LoadDefaultConfig(context.TODO(),
		config.WithRegion(region),
	)
	if err != nil {
		return nil, err
	}

	return cloudwatchlogs.NewFromConfig(cfg), nil
}

// Validate logs in CloudWatch.
// Similar logic as S3 validation.
func validate_cloudwatch(cwClient *cloudwatchlogs.Client, logGroup string, logStream string) int {
	var forwardToken *string
	cwRecoredCounter := 0
	ctx := context.TODO()

	// Returns all log events from a CloudWatch log group with the given log stream.
	// This approach utilizes NextForwardToken to pull all log events from the CloudWatch log group.
	for {
		var input *cloudwatchlogs.GetLogEventsInput
		if forwardToken == nil {
			input = &cloudwatchlogs.GetLogEventsInput{
				LogGroupName:  aws.String(logGroup),
				LogStreamName: aws.String(logStream),
				StartFromHead: aws.Bool(true),
				Limit:         aws.Int32(10000),
			}
		} else {
			input = &cloudwatchlogs.GetLogEventsInput{
				LogGroupName:  aws.String(logGroup),
				LogStreamName: aws.String(logStream),
				NextToken:     forwardToken,
				StartFromHead: aws.Bool(true),
				Limit:         aws.Int32(10000),
			}
			// Sleep between GetLogEvents calls to avoid throttling
			time.Sleep(100 * time.Millisecond)
		}

		response, err := cwClient.GetLogEvents(ctx, input)
		for err != nil {
			// retry for throttling exception
			if strings.Contains(err.Error(), "ThrottlingException: Rate exceeded") {
				time.Sleep(5 * time.Second)
				response, err = cwClient.GetLogEvents(ctx, input)
			} else {
				exitErrorf("[TEST FAILURE] Error occured to get the log events from log group: %q., %v", logGroup, err)
			}
		}

		for _, event := range response.Events {
			log := aws.ToString(event.Message)

			// First 8 char is the unique record ID
			recordId := log[:8]
			value, err := strconv.ParseUint(recordId, 10, 32)
			if err != nil {
				fmt.Println("Error:", err)
				continue
			}
			recordIdUint := uint32(value)
			cwRecoredCounter += 1
			inputMap[recordIdUint] = struct{}{}
		}

		// Same NextForwardToken will be returned if we reach the end of the log stream
		if aws.ToString(response.NextForwardToken) == aws.ToString(forwardToken) {
			break
		}

		forwardToken = response.NextForwardToken
	}

	return cwRecoredCounter
}

func get_results(totalInputRecord int, totalRecordFound int, logDelay string) {
	uniqueRecordFound := len(inputMap)

	fmt.Println("total_input, ", totalInputRecord)
	fmt.Println("total_destination, ", totalRecordFound)
	fmt.Println("unique, ", uniqueRecordFound)
	fmt.Println("duplicate, ", (totalRecordFound - uniqueRecordFound))
	fmt.Println("delay, ", logDelay)
	fmt.Println("percent_loss, ", (totalInputRecord-uniqueRecordFound)*100/totalInputRecord) // %

	if totalInputRecord != uniqueRecordFound {
		fmt.Println("missing, ", totalInputRecord-uniqueRecordFound)
	} else {
		fmt.Println("missing, ", 0)
	}
}

func exitErrorf(msg string, args ...interface{}) {
	fmt.Fprintf(os.Stderr, msg+"\n", args...)
	os.Exit(1)
}
