// Copyright 2023 The Sigstore Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package witness

import (
	"context"
	"crypto"
	"crypto/ecdsa"
	"crypto/elliptic"
	"crypto/rand"
	"errors"
	"fmt"
	"testing"
	"time"

	"github.com/go-redis/redismock/v9"
	"github.com/golang/mock/gomock"
	"github.com/google/trillian"
	"github.com/google/trillian/types"
	"github.com/prometheus/client_golang/prometheus"
	"github.com/prometheus/client_golang/prometheus/testutil"
	"github.com/sigstore/rekor/pkg/witness/mockclient"
	"github.com/sigstore/sigstore/pkg/signature"
	"go.uber.org/goleak"
)

func TestPublishCheckpoint(t *testing.T) {
	treeID := 1234
	hostname := "rekor-test"
	freq := 1
	counter := prometheus.NewCounterVec(prometheus.CounterOpts{
		Name: "rekor_checkpoint_publish",
		Help: "Checkpoint publishing by shard and code",
	}, []string{"shard", "code"})
	priv, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
	signer, _ := signature.LoadSigner(priv, crypto.SHA256)

	ctrl := gomock.NewController(t)
	defer ctrl.Finish()

	root := &types.LogRootV1{TreeSize: 10, RootHash: []byte{1}, TimestampNanos: 123, Revision: 0}
	mRoot, err := root.MarshalBinary()
	if err != nil {
		t.Fatalf("error marshalling log root: %v", err)
	}

	mockTrillianLogClient := mockclient.NewMockTrillianLogClient(ctrl)
	mockTrillianLogClient.EXPECT().GetLatestSignedLogRoot(gomock.Any(), &trillian.GetLatestSignedLogRootRequest{
		LogId:         int64(treeID),
		FirstTreeSize: 0,
	}).Return(&trillian.GetLatestSignedLogRootResponse{SignedLogRoot: &trillian.SignedLogRoot{LogRoot: mRoot}}, nil)

	redisClient, mock := redismock.NewClientMock()
	ts := time.Now().Truncate(time.Duration(freq) * time.Minute).UnixNano()
	mock.Regexp().ExpectSetNX(fmt.Sprintf("%d/%d", treeID, ts), true, 0).SetVal(true)
	mock.Regexp().ExpectSet(fmt.Sprintf("%d/latest", treeID), "[0-9a-fA-F]+", 0).SetVal("OK")

	publisher := NewCheckpointPublisher(context.Background(), mockTrillianLogClient, int64(treeID), hostname, signer, redisClient, uint(freq), counter)

	ctx, cancel := context.WithCancel(context.Background())
	publisher.StartPublisher(ctx)
	defer cancel()

	// wait for initial publish
	time.Sleep(1 * time.Second)

	if err := mock.ExpectationsWereMet(); err != nil {
		t.Error(err)
	}

	if res := testutil.CollectAndCount(counter); res != 2 {
		t.Fatalf("unexpected number of metrics: %d", res)
	}
	if res := testutil.ToFloat64(counter.WithLabelValues(fmt.Sprint(treeID), fmt.Sprint(Success))); res != 1.0 {
		t.Fatalf("unexpected number of metrics: %2f", res)
	}
	if res := testutil.ToFloat64(counter.WithLabelValues(fmt.Sprint(treeID), fmt.Sprint(SuccessObtainLock))); res != 1.0 {
		t.Fatalf("unexpected number of metrics: %2f", res)
	}
}

func TestPublishCheckpointMultiple(t *testing.T) {
	treeID := 1234
	hostname := "rekor-test"
	freq := 1
	counter := prometheus.NewCounterVec(prometheus.CounterOpts{
		Name: "rekor_checkpoint_publish",
		Help: "Checkpoint publishing by shard and code",
	}, []string{"shard", "code"})
	priv, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
	signer, _ := signature.LoadSigner(priv, crypto.SHA256)

	ctrl := gomock.NewController(t)
	defer ctrl.Finish()

	root := &types.LogRootV1{TreeSize: 10, RootHash: []byte{1}, TimestampNanos: 123, Revision: 0}
	mRoot, err := root.MarshalBinary()
	if err != nil {
		t.Fatalf("error marshalling log root: %v", err)
	}

	mockTrillianLogClient := mockclient.NewMockTrillianLogClient(ctrl)
	mockTrillianLogClient.EXPECT().GetLatestSignedLogRoot(gomock.Any(), &trillian.GetLatestSignedLogRootRequest{
		LogId:         int64(treeID),
		FirstTreeSize: 0,
	}).Return(&trillian.GetLatestSignedLogRootResponse{SignedLogRoot: &trillian.SignedLogRoot{LogRoot: mRoot}}, nil).MaxTimes(2)

	redisClient, mock := redismock.NewClientMock()
	ts := time.Now().Truncate(time.Duration(freq) * time.Minute).UnixNano()
	mock.Regexp().ExpectSetNX(fmt.Sprintf("%d/%d", treeID, ts), true, 0).SetVal(true)
	mock.Regexp().ExpectSet(fmt.Sprintf("%d/latest", treeID), "[0-9a-fA-F]+", 0).SetVal("OK")

	publisher := NewCheckpointPublisher(context.Background(), mockTrillianLogClient, int64(treeID), hostname, signer, redisClient, uint(freq), counter)
	ctx, cancel := context.WithCancel(context.Background())
	publisher.StartPublisher(ctx)
	defer cancel()

	redisClientEx, mockEx := redismock.NewClientMock()
	mockEx.Regexp().ExpectSetNX(fmt.Sprintf("%d/%d", treeID, ts), true, 0).SetVal(false)
	publisherEx := NewCheckpointPublisher(context.Background(), mockTrillianLogClient, int64(treeID), hostname, signer, redisClientEx, uint(freq), counter)
	ctxEx, cancelEx := context.WithCancel(context.Background())
	publisherEx.StartPublisher(ctxEx)
	defer cancelEx()

	// wait for initial publish
	time.Sleep(1 * time.Second)

	if err := mock.ExpectationsWereMet(); err != nil {
		t.Error(err)
	}
	if err := mockEx.ExpectationsWereMet(); err != nil {
		t.Error(err)
	}

	// only publishes once
	if res := testutil.CollectAndCount(counter); res != 2 {
		t.Fatalf("unexpected number of metrics: %d", res)
	}
	if res := testutil.ToFloat64(counter.WithLabelValues(fmt.Sprint(treeID), fmt.Sprint(Success))); res != 1.0 {
		t.Fatalf("unexpected number of metrics: %2f", res)
	}
	if res := testutil.ToFloat64(counter.WithLabelValues(fmt.Sprint(treeID), fmt.Sprint(SuccessObtainLock))); res != 1.0 {
		t.Fatalf("unexpected number of metrics: %2f", res)
	}
}

func TestPublishCheckpointTrillianError(t *testing.T) {
	treeID := 1234
	hostname := "rekor-test"
	freq := 1
	counter := prometheus.NewCounterVec(prometheus.CounterOpts{
		Name: "rekor_checkpoint_publish",
		Help: "Checkpoint publishing by shard and code",
	}, []string{"shard", "code"})
	priv, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
	signer, _ := signature.LoadSigner(priv, crypto.SHA256)

	ctrl := gomock.NewController(t)
	defer ctrl.Finish()

	// return error
	mockTrillianLogClient := mockclient.NewMockTrillianLogClient(ctrl)
	mockTrillianLogClient.EXPECT().GetLatestSignedLogRoot(gomock.Any(), gomock.Any()).Return(nil, errors.New("error: LatestSLR"))

	redisClient, _ := redismock.NewClientMock()

	publisher := NewCheckpointPublisher(context.Background(), mockTrillianLogClient, int64(treeID), hostname, signer, redisClient, uint(freq), counter)
	ctx, cancel := context.WithCancel(context.Background())
	publisher.StartPublisher(ctx)
	defer cancel()

	// wait for initial publish
	time.Sleep(1 * time.Second)

	if res := testutil.CollectAndCount(counter); res != 1 {
		t.Fatalf("unexpected number of metrics: %d", res)
	}
	if res := testutil.ToFloat64(counter.WithLabelValues(fmt.Sprint(treeID), fmt.Sprint(GetCheckpoint))); res != 1.0 {
		t.Fatalf("unexpected number of metrics: %2f", res)
	}
}

func TestPublishCheckpointInvalidTrillianResponse(t *testing.T) {
	treeID := 1234
	hostname := "rekor-test"
	freq := 1
	counter := prometheus.NewCounterVec(prometheus.CounterOpts{
		Name: "rekor_checkpoint_publish",
		Help: "Checkpoint publishing by shard and code",
	}, []string{"shard", "code"})
	priv, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
	signer, _ := signature.LoadSigner(priv, crypto.SHA256)

	ctrl := gomock.NewController(t)
	defer ctrl.Finish()

	// set no log root in response
	mockTrillianLogClient := mockclient.NewMockTrillianLogClient(ctrl)
	mockTrillianLogClient.EXPECT().GetLatestSignedLogRoot(gomock.Any(), gomock.Any()).
		Return(&trillian.GetLatestSignedLogRootResponse{SignedLogRoot: &trillian.SignedLogRoot{LogRoot: []byte{}}}, nil)

	redisClient, _ := redismock.NewClientMock()

	publisher := NewCheckpointPublisher(context.Background(), mockTrillianLogClient, int64(treeID), hostname, signer, redisClient, uint(freq), counter)
	ctx, cancel := context.WithCancel(context.Background())
	publisher.StartPublisher(ctx)
	defer cancel()

	// wait for initial publish
	time.Sleep(1 * time.Second)

	if res := testutil.CollectAndCount(counter); res != 1 {
		t.Fatalf("unexpected number of metrics: %d", res)
	}
	if res := testutil.ToFloat64(counter.WithLabelValues(fmt.Sprint(treeID), fmt.Sprint(UnmarshalCheckpoint))); res != 1.0 {
		t.Fatalf("unexpected number of metrics: %2f", res)
	}
}

func TestPublishCheckpointRedisFailure(t *testing.T) {
	treeID := 1234
	hostname := "rekor-test"
	freq := 1
	counter := prometheus.NewCounterVec(prometheus.CounterOpts{
		Name: "rekor_checkpoint_publish",
		Help: "Checkpoint publishing by shard and code",
	}, []string{"shard", "code"})
	priv, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
	signer, _ := signature.LoadSigner(priv, crypto.SHA256)

	ctrl := gomock.NewController(t)
	defer ctrl.Finish()

	root := &types.LogRootV1{TreeSize: 10, RootHash: []byte{1}, TimestampNanos: 123, Revision: 0}
	mRoot, err := root.MarshalBinary()
	if err != nil {
		t.Fatalf("error marshalling log root: %v", err)
	}

	mockTrillianLogClient := mockclient.NewMockTrillianLogClient(ctrl)
	mockTrillianLogClient.EXPECT().GetLatestSignedLogRoot(gomock.Any(), gomock.Any()).
		Return(&trillian.GetLatestSignedLogRootResponse{SignedLogRoot: &trillian.SignedLogRoot{LogRoot: mRoot}}, nil)

	redisClient, mock := redismock.NewClientMock()
	// error on first redis call
	mock.Regexp().ExpectSetNX(".+", true, 0).SetErr(errors.New("redis error"))

	publisher := NewCheckpointPublisher(context.Background(), mockTrillianLogClient, int64(treeID), hostname, signer, redisClient, uint(freq), counter)
	ctx, cancel := context.WithCancel(context.Background())
	publisher.StartPublisher(ctx)
	defer cancel()

	// wait for initial publish
	time.Sleep(1 * time.Second)

	if res := testutil.CollectAndCount(counter); res != 1 {
		t.Fatalf("unexpected number of metrics: %d", res)
	}
	if res := testutil.ToFloat64(counter.WithLabelValues(fmt.Sprint(treeID), fmt.Sprint(RedisFailure))); res != 1.0 {
		t.Fatalf("unexpected number of metrics: %2f", res)
	}
}

func TestPublishCheckpointRedisLatestFailure(t *testing.T) {
	treeID := 1234
	hostname := "rekor-test"
	freq := 1
	counter := prometheus.NewCounterVec(prometheus.CounterOpts{
		Name: "rekor_checkpoint_publish",
		Help: "Checkpoint publishing by shard and code",
	}, []string{"shard", "code"})
	priv, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
	signer, _ := signature.LoadSigner(priv, crypto.SHA256)

	ctrl := gomock.NewController(t)
	defer ctrl.Finish()

	root := &types.LogRootV1{TreeSize: 10, RootHash: []byte{1}, TimestampNanos: 123, Revision: 0}
	mRoot, err := root.MarshalBinary()
	if err != nil {
		t.Fatalf("error marshalling log root: %v", err)
	}

	mockTrillianLogClient := mockclient.NewMockTrillianLogClient(ctrl)
	mockTrillianLogClient.EXPECT().GetLatestSignedLogRoot(gomock.Any(), gomock.Any()).
		Return(&trillian.GetLatestSignedLogRootResponse{SignedLogRoot: &trillian.SignedLogRoot{LogRoot: mRoot}}, nil)

	redisClient, mock := redismock.NewClientMock()
	mock.Regexp().ExpectSetNX(".+", true, 0).SetVal(true)
	// error on second redis call
	mock.Regexp().ExpectSet(".*", "[0-9a-fA-F]+", 0).SetErr(errors.New("error"))

	publisher := NewCheckpointPublisher(context.Background(), mockTrillianLogClient, int64(treeID), hostname, signer, redisClient, uint(freq), counter)
	ctx, cancel := context.WithCancel(context.Background())
	publisher.StartPublisher(ctx)
	defer cancel()

	// wait for initial publish
	time.Sleep(1 * time.Second)

	// two metrics, one success for initial redis and one failure for latest
	if res := testutil.CollectAndCount(counter); res != 2 {
		t.Fatalf("unexpected number of metrics: %d", res)
	}
	if res := testutil.ToFloat64(counter.WithLabelValues(fmt.Sprint(treeID), fmt.Sprint(RedisLatestFailure))); res != 1.0 {
		t.Fatalf("unexpected number of metrics: %2f", res)
	}
}

func TestMain(m *testing.M) {
	goleak.VerifyTestMain(m)
}
