/*
Copyright 2021 IBM All Rights Reserved.

SPDX-License-Identifier: Apache-2.0
*/

package gateway

import (
	"io"
	"testing"

	cp "github.com/hyperledger/fabric-protos-go-apiv2/common"
	pb "github.com/hyperledger/fabric-protos-go-apiv2/gateway"
	ab "github.com/hyperledger/fabric-protos-go-apiv2/orderer"
	"github.com/hyperledger/fabric-protos-go-apiv2/peer"
	"github.com/hyperledger/fabric/protoutil"
	"github.com/pkg/errors"
	"github.com/stretchr/testify/require"
	"google.golang.org/grpc/codes"
	"google.golang.org/grpc/status"
	"google.golang.org/protobuf/proto"
	"google.golang.org/protobuf/types/known/timestamppb"
)

func TestChaincodeEvents(t *testing.T) {
	lastTransactionID := "LAST_TX_ID"

	newChaincodeEvent := func(chaincodeName string, transactionID string) *peer.ChaincodeEvent {
		return &peer.ChaincodeEvent{
			ChaincodeId: chaincodeName,
			TxId:        transactionID,
			EventName:   "EVENT_NAME",
			Payload:     []byte("PAYLOAD"),
		}
	}

	newTransactionHeader := func(transactionID string) *cp.Header {
		return &cp.Header{
			ChannelHeader: protoutil.MarshalOrPanic(&cp.ChannelHeader{
				Type:      int32(cp.HeaderType_ENDORSER_TRANSACTION),
				Timestamp: timestamppb.Now(),
				TxId:      transactionID,
			}),
		}
	}

	newTransactionEnvelope := func(event *peer.ChaincodeEvent) *cp.Envelope {
		return &cp.Envelope{
			Payload: protoutil.MarshalOrPanic(&cp.Payload{
				Header: newTransactionHeader(event.GetTxId()),
				Data: protoutil.MarshalOrPanic(&peer.Transaction{
					Actions: []*peer.TransactionAction{
						{
							Payload: protoutil.MarshalOrPanic(&peer.ChaincodeActionPayload{
								Action: &peer.ChaincodeEndorsedAction{
									ProposalResponsePayload: protoutil.MarshalOrPanic(&peer.ProposalResponsePayload{
										Extension: protoutil.MarshalOrPanic(&peer.ChaincodeAction{
											Events: protoutil.MarshalOrPanic(event),
										}),
									}),
								},
							}),
						},
					},
				}),
			}),
		}
	}

	newBlock := func(number uint64) *cp.Block {
		return &cp.Block{
			Header: &cp.BlockHeader{
				Number: number,
			},
			Metadata: &cp.BlockMetadata{
				Metadata: make([][]byte, 5),
			},
			Data: &cp.BlockData{
				Data: [][]byte{},
			},
		}
	}

	addTransaction := func(block *cp.Block, transaction *cp.Envelope, status peer.TxValidationCode) {
		metadata := block.GetMetadata().GetMetadata()
		metadata[cp.BlockMetadataIndex_TRANSACTIONS_FILTER] = append(metadata[cp.BlockMetadataIndex_TRANSACTIONS_FILTER], byte(status))

		blockData := block.GetData()
		blockData.Data = append(blockData.Data, protoutil.MarshalOrPanic(transaction))
	}

	matchEvent := newChaincodeEvent(testChaincode, "EXPECTED_TX_ID")
	wrongChaincodeEvent := newChaincodeEvent("WRONG_CHAINCODE", "WRONG__TX_ID")
	oldTransactionEvent := newChaincodeEvent(testChaincode, "OLD_TX_ID")
	lastTransactionEvent := newChaincodeEvent(testChaincode, lastTransactionID)
	lastTransactionWrongChaincodeEvent := newChaincodeEvent("WRONG_CHAINCODE", lastTransactionID)

	configTxEnvelope := &cp.Envelope{
		Payload: protoutil.MarshalOrPanic(&cp.Payload{
			Header: &cp.Header{
				ChannelHeader: protoutil.MarshalOrPanic(&cp.ChannelHeader{
					Type: int32(cp.HeaderType_CONFIG_UPDATE),
				}),
			},
		}),
	}

	noMatchingEventsBlock := newBlock(100)
	addTransaction(noMatchingEventsBlock, newTransactionEnvelope(wrongChaincodeEvent), peer.TxValidationCode_VALID)

	matchingEventBlock := newBlock(101)
	addTransaction(matchingEventBlock, configTxEnvelope, peer.TxValidationCode_VALID)
	addTransaction(matchingEventBlock, newTransactionEnvelope(wrongChaincodeEvent), peer.TxValidationCode_VALID)
	addTransaction(matchingEventBlock, newTransactionEnvelope(matchEvent), peer.TxValidationCode_VALID)

	partReadBlock := newBlock(200)
	addTransaction(partReadBlock, newTransactionEnvelope(oldTransactionEvent), peer.TxValidationCode_VALID)
	addTransaction(partReadBlock, newTransactionEnvelope(lastTransactionEvent), peer.TxValidationCode_VALID)
	addTransaction(partReadBlock, newTransactionEnvelope(matchEvent), peer.TxValidationCode_VALID)

	differentChaincodePartReadBlock := newBlock(300)
	addTransaction(differentChaincodePartReadBlock, newTransactionEnvelope(oldTransactionEvent), peer.TxValidationCode_VALID)
	addTransaction(differentChaincodePartReadBlock, newTransactionEnvelope(lastTransactionWrongChaincodeEvent), peer.TxValidationCode_VALID)
	addTransaction(differentChaincodePartReadBlock, newTransactionEnvelope(matchEvent), peer.TxValidationCode_VALID)

	tests := []testDef{
		{
			name:      "error reading events",
			eventErr:  errors.New("EVENT_ERROR"),
			errCode:   codes.Aborted,
			errString: "EVENT_ERROR",
		},
		{
			name: "returns chaincode events",
			blocks: []*cp.Block{
				matchingEventBlock,
			},
			expectedResponses: []proto.Message{
				&pb.ChaincodeEventsResponse{
					BlockNumber: matchingEventBlock.GetHeader().GetNumber(),
					Events: []*peer.ChaincodeEvent{
						{
							ChaincodeId: testChaincode,
							TxId:        matchEvent.GetTxId(),
							EventName:   matchEvent.GetEventName(),
							Payload:     matchEvent.GetPayload(),
						},
					},
				},
			},
		},
		{
			name: "skips blocks containing only non-matching chaincode events",
			blocks: []*cp.Block{
				noMatchingEventsBlock,
				matchingEventBlock,
			},
			expectedResponses: []proto.Message{
				&pb.ChaincodeEventsResponse{
					BlockNumber: matchingEventBlock.GetHeader().GetNumber(),
					Events: []*peer.ChaincodeEvent{
						{
							ChaincodeId: testChaincode,
							TxId:        matchEvent.GetTxId(),
							EventName:   matchEvent.GetEventName(),
							Payload:     matchEvent.GetPayload(),
						},
					},
				},
			},
		},
		{
			name: "skips previously seen transactions",
			blocks: []*cp.Block{
				partReadBlock,
			},
			afterTxID: lastTransactionID,
			expectedResponses: []proto.Message{
				&pb.ChaincodeEventsResponse{
					BlockNumber: partReadBlock.GetHeader().GetNumber(),
					Events: []*peer.ChaincodeEvent{
						{
							ChaincodeId: testChaincode,
							TxId:        matchEvent.GetTxId(),
							EventName:   matchEvent.GetEventName(),
							Payload:     matchEvent.GetPayload(),
						},
					},
				},
			},
		},
		{
			name: "identifies specified transaction if from different chaincode",
			blocks: []*cp.Block{
				differentChaincodePartReadBlock,
			},
			afterTxID: lastTransactionID,
			expectedResponses: []proto.Message{
				&pb.ChaincodeEventsResponse{
					BlockNumber: differentChaincodePartReadBlock.GetHeader().GetNumber(),
					Events: []*peer.ChaincodeEvent{
						{
							ChaincodeId: testChaincode,
							TxId:        matchEvent.GetTxId(),
							EventName:   matchEvent.GetEventName(),
							Payload:     matchEvent.GetPayload(),
						},
					},
				},
			},
		},
		{
			name: "identifies specified transaction if not in first read block",
			blocks: []*cp.Block{
				noMatchingEventsBlock,
				partReadBlock,
			},
			afterTxID: lastTransactionID,
			expectedResponses: []proto.Message{
				&pb.ChaincodeEventsResponse{
					BlockNumber: partReadBlock.GetHeader().GetNumber(),
					Events: []*peer.ChaincodeEvent{
						{
							ChaincodeId: testChaincode,
							TxId:        matchEvent.GetTxId(),
							EventName:   matchEvent.GetEventName(),
							Payload:     matchEvent.GetPayload(),
						},
					},
				},
			},
		},
		{
			name: "passes channel name to ledger provider",
			postTest: func(t *testing.T, test *preparedTest) {
				require.Equal(t, 1, test.ledgerProvider.LedgerCallCount())
				require.Equal(t, testChannel, test.ledgerProvider.LedgerArgsForCall(0))
			},
		},
		{
			name: "returns error obtaining ledger",
			blocks: []*cp.Block{
				matchingEventBlock,
			},
			errCode:   codes.NotFound,
			errString: "LEDGER_PROVIDER_ERROR",
			postSetup: func(t *testing.T, test *preparedTest) {
				test.ledgerProvider.LedgerReturns(nil, errors.New("LEDGER_PROVIDER_ERROR"))
			},
		},
		{
			name: "returns error obtaining ledger height",
			blocks: []*cp.Block{
				matchingEventBlock,
			},
			errCode:   codes.Aborted,
			errString: "LEDGER_INFO_ERROR",
			postSetup: func(t *testing.T, test *preparedTest) {
				test.ledger.GetBlockchainInfoReturns(nil, errors.New("LEDGER_INFO_ERROR"))
			},
		},
		{
			name: "uses block height as start block if next commit is specified as start position",
			blocks: []*cp.Block{
				matchingEventBlock,
			},
			localLedgerHeight: 101,
			startPosition: &ab.SeekPosition{
				Type: &ab.SeekPosition_NextCommit{
					NextCommit: &ab.SeekNextCommit{},
				},
			},
			postTest: func(t *testing.T, test *preparedTest) {
				require.Equal(t, 1, test.ledger.GetBlocksIteratorCallCount())
				require.EqualValues(t, 101, test.ledger.GetBlocksIteratorArgsForCall(0))
			},
		},
		{
			name: "uses specified start block",
			blocks: []*cp.Block{
				matchingEventBlock,
			},
			localLedgerHeight: 101,
			startPosition: &ab.SeekPosition{
				Type: &ab.SeekPosition_Specified{
					Specified: &ab.SeekSpecified{
						Number: 99,
					},
				},
			},
			postTest: func(t *testing.T, test *preparedTest) {
				require.Equal(t, 1, test.ledger.GetBlocksIteratorCallCount())
				require.EqualValues(t, 99, test.ledger.GetBlocksIteratorArgsForCall(0))
			},
		},
		{
			name: "defaults to next commit if start position not specified",
			blocks: []*cp.Block{
				matchingEventBlock,
			},
			localLedgerHeight: 101,
			postTest: func(t *testing.T, test *preparedTest) {
				require.Equal(t, 1, test.ledger.GetBlocksIteratorCallCount())
				require.EqualValues(t, 101, test.ledger.GetBlocksIteratorArgsForCall(0))
			},
		},
		{
			name: "uses block containing specified transaction instead of start block",
			blocks: []*cp.Block{
				matchingEventBlock,
			},
			localLedgerHeight: 101,
			postSetup: func(t *testing.T, test *preparedTest) {
				block := &cp.Block{
					Header: &cp.BlockHeader{
						Number: 99,
					},
				}
				test.ledger.GetBlockByTxIDReturns(block, nil)
			},
			afterTxID: "TX_ID",
			startPosition: &ab.SeekPosition{
				Type: &ab.SeekPosition_Specified{
					Specified: &ab.SeekSpecified{
						Number: 1,
					},
				},
			},
			postTest: func(t *testing.T, test *preparedTest) {
				require.Equal(t, 1, test.ledger.GetBlocksIteratorCallCount())
				require.EqualValues(t, 99, test.ledger.GetBlocksIteratorArgsForCall(0))
				require.Equal(t, 1, test.ledger.GetBlockByTxIDCallCount())
				require.Equal(t, "TX_ID", test.ledger.GetBlockByTxIDArgsForCall(0))
			},
		},
		{
			name: "uses start block if specified transaction not found",
			blocks: []*cp.Block{
				matchingEventBlock,
			},
			localLedgerHeight: 101,
			postSetup: func(t *testing.T, test *preparedTest) {
				test.ledger.GetBlockByTxIDReturns(nil, errors.New("NOT_FOUND"))
			},
			afterTxID: "TX_ID",
			startPosition: &ab.SeekPosition{
				Type: &ab.SeekPosition_Specified{
					Specified: &ab.SeekSpecified{
						Number: 1,
					},
				},
			},
			postTest: func(t *testing.T, test *preparedTest) {
				require.Equal(t, 1, test.ledger.GetBlocksIteratorCallCount())
				require.EqualValues(t, 1, test.ledger.GetBlocksIteratorArgsForCall(0))
			},
		},
		{
			name: "returns error for unsupported start position type",
			blocks: []*cp.Block{
				matchingEventBlock,
			},
			startPosition: &ab.SeekPosition{
				Type: &ab.SeekPosition_Oldest{
					Oldest: &ab.SeekOldest{},
				},
			},
			errCode:   codes.InvalidArgument,
			errString: "invalid start position type: *orderer.SeekPosition_Oldest",
		},
		{
			name: "returns error obtaining ledger iterator",
			blocks: []*cp.Block{
				matchingEventBlock,
			},
			errCode:   codes.Aborted,
			errString: "LEDGER_ITERATOR_ERROR",
			postSetup: func(t *testing.T, test *preparedTest) {
				test.ledger.GetBlocksIteratorReturns(nil, errors.New("LEDGER_ITERATOR_ERROR"))
			},
		},
		{
			name: "returns canceled status error when client closes stream",
			blocks: []*cp.Block{
				matchingEventBlock,
			},
			errCode: codes.Canceled,
			postSetup: func(t *testing.T, test *preparedTest) {
				test.eventsServer.SendReturns(io.EOF)
			},
		},
		{
			name: "returns status error from send to client",
			blocks: []*cp.Block{
				matchingEventBlock,
			},
			errCode:   codes.Aborted,
			errString: "SEND_ERROR",
			postSetup: func(t *testing.T, test *preparedTest) {
				test.eventsServer.SendReturns(status.Error(codes.Aborted, "SEND_ERROR"))
			},
		},
		{
			name:      "failed policy or signature check",
			policyErr: errors.New("POLICY_ERROR"),
			errCode:   codes.PermissionDenied,
			errString: "POLICY_ERROR",
		},
		{
			name: "passes channel name to policy checker",
			postTest: func(t *testing.T, test *preparedTest) {
				require.Equal(t, 1, test.policy.CheckACLCallCount())
				_, channelName, _ := test.policy.CheckACLArgsForCall(0)
				require.Equal(t, testChannel, channelName)
			},
		},
		{
			name:     "passes identity to policy checker",
			identity: []byte("IDENTITY"),
			postTest: func(t *testing.T, test *preparedTest) {
				require.Equal(t, 1, test.policy.CheckACLCallCount())
				_, _, data := test.policy.CheckACLArgsForCall(0)
				require.IsType(t, &protoutil.SignedData{}, data)
				signedData := data.(*protoutil.SignedData)
				require.Equal(t, []byte("IDENTITY"), signedData.Identity)
			},
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			test := prepareTest(t, &tt)

			request := &pb.ChaincodeEventsRequest{
				ChannelId:   testChannel,
				Identity:    tt.identity,
				ChaincodeId: testChaincode,
			}
			if tt.startPosition != nil {
				request.StartPosition = tt.startPosition
			}
			if len(tt.afterTxID) > 0 {
				request.AfterTransactionId = tt.afterTxID
			}
			requestBytes, err := proto.Marshal(request)
			require.NoError(t, err)

			signedRequest := &pb.SignedChaincodeEventsRequest{
				Request:   requestBytes,
				Signature: []byte{},
			}

			err = test.server.ChaincodeEvents(signedRequest, test.eventsServer)

			if checkError(t, &tt, err) {
				return
			}

			for i, expectedResponse := range tt.expectedResponses {
				actualResponse := test.eventsServer.SendArgsForCall(i)
				require.True(t, proto.Equal(expectedResponse, actualResponse), "response[%d] mismatch: %v", i, actualResponse)
			}

			if tt.postTest != nil {
				tt.postTest(t, test)
			}
		})
	}
}
