/*
Copyright 2021 IBM All Rights Reserved.

SPDX-License-Identifier: Apache-2.0
*/

package gateway

import (
	"context"
	"fmt"
	"testing"
	"time"

	pb "github.com/hyperledger/fabric-protos-go-apiv2/gateway"
	"github.com/hyperledger/fabric-protos-go-apiv2/peer"
	"github.com/hyperledger/fabric/gossip/common"
	gdiscovery "github.com/hyperledger/fabric/gossip/discovery"
	"github.com/hyperledger/fabric/internal/pkg/gateway/mocks"
	"github.com/stretchr/testify/require"
	"google.golang.org/grpc"
	"google.golang.org/grpc/codes"
	"google.golang.org/grpc/status"
)

func TestEvaluate(t *testing.T) {
	tests := []testDef{
		{
			name: "single endorser",
			members: []networkMember{
				{"id1", "localhost:7051", "msp1", 5},
			},
			localLedgerHeight: 5,
			expectedEndorsers: []string{"localhost:7051"},
		},
		{
			name:      "no endorsers",
			plan:      endorsementPlan{},
			members:   []networkMember{},
			errCode:   codes.FailedPrecondition,
			errString: "no peers available to evaluate chaincode test_chaincode in channel test_channel",
		},
		{
			name: "five endorsers, prefer local org",
			members: []networkMember{
				{"id1", "localhost:7051", "msp1", 5},
				{"id2", "peer1:8051", "msp1", 6},
				{"id3", "peer2:9051", "msp2", 6},
				{"id4", "peer3:10051", "msp2", 5},
				{"id5", "peer4:11051", "msp3", 6},
			},
			localLedgerHeight: 5,
			expectedEndorsers: []string{"peer1:8051"},
		},
		{
			name: "five endorsers, prefer host peer",
			members: []networkMember{
				{"id1", "localhost:7051", "msp1", 5},
				{"id2", "peer1:8051", "msp1", 5},
				{"id3", "peer2:9051", "msp2", 6},
				{"id4", "peer3:10051", "msp2", 5},
				{"id5", "peer4:11051", "msp3", 6},
			},
			localLedgerHeight: 5,
			expectedEndorsers: []string{"localhost:7051"},
		},
		{
			name: "five endorsers, prefer host peer despite no endpoint",
			members: []networkMember{
				{"id1", "", "msp1", 5},
				{"id2", "peer1:8051", "msp1", 5},
				{"id3", "peer2:9051", "msp2", 6},
				{"id4", "peer3:10051", "msp2", 5},
				{"id5", "peer4:11051", "msp3", 6},
			},
			localLedgerHeight: 5,
			expectedEndorsers: []string{"localhost:7051"},
		},
		{
			name: "evaluate with targetOrganizations, prefer local org despite block height",
			members: []networkMember{
				{"id1", "localhost:7051", "msp1", 5},
				{"id2", "peer1:8051", "msp1", 5},
				{"id3", "peer2:9051", "msp2", 6},
				{"id4", "peer3:10051", "msp2", 5},
				{"id5", "peer4:11051", "msp3", 6},
			},
			localLedgerHeight: 5,
			endorsingOrgs:     []string{"msp3", "msp1"},
			expectedEndorsers: []string{"localhost:7051"},
		},
		{
			name: "evaluate with targetOrganizations that doesn't include local org, prefer highest block height",
			members: []networkMember{
				{"id1", "localhost:7051", "msp1", 5},
				{"id2", "peer1:8051", "msp1", 5},
				{"id3", "peer2:9051", "msp2", 6},
				{"id4", "peer3:10051", "msp2", 5},
				{"id5", "peer4:11051", "msp3", 7},
			},
			localLedgerHeight: 5,
			endorsingOrgs:     []string{"msp2", "msp3"},
			expectedEndorsers: []string{"peer4:11051"},
		},
		{
			name: "evaluate with transient data should select local org, highest block height",
			members: []networkMember{
				{"id1", "localhost:7051", "msp1", 4},
				{"id2", "peer1:8051", "msp1", 5},
				{"id3", "peer2:9051", "msp2", 6},
				{"id4", "peer3:10051", "msp2", 5},
				{"id5", "peer4:11051", "msp3", 7},
			},
			localLedgerHeight: 4,
			transientData:     map[string][]byte{"transient-key": []byte("transient-value")},
			expectedEndorsers: []string{"peer1:8051"},
		},
		{
			name: "evaluate with transient data should fail if local org not available",
			members: []networkMember{
				{"id3", "peer2:9051", "msp2", 6},
				{"id4", "peer3:10051", "msp2", 5},
				{"id5", "peer4:11051", "msp3", 7},
			},
			transientData: map[string][]byte{"transient-key": []byte("transient-value")},
			errCode:       codes.FailedPrecondition,
			errString:     "no endorsers found in the gateway's organization; retry specifying target organization(s) to protect transient data: no peers available to evaluate chaincode test_chaincode in channel test_channel",
		},
		{
			name: "evaluate with transient data and target (non-local) orgs should select the highest block height peer",
			members: []networkMember{
				{"id1", "localhost:7051", "msp1", 11},
				{"id2", "peer1:8051", "msp1", 5},
				{"id3", "peer2:9051", "msp2", 6},
				{"id4", "peer3:10051", "msp2", 9},
				{"id5", "peer4:11051", "msp3", 7},
			},
			localLedgerHeight: 11,
			transientData:     map[string][]byte{"transient-key": []byte("transient-value")},
			endorsingOrgs:     []string{"msp2", "msp3"},
			expectedEndorsers: []string{"peer3:10051"},
		},
		{
			name: "process proposal fails",
			members: []networkMember{
				{"id1", "localhost:7051", "msp1", 5},
			},
			localLedgerHeight: 5,
			endpointDefinition: &endpointDef{
				proposalError: status.Error(codes.Aborted, "wibble"),
			},
			errCode:   codes.Aborted,
			errString: "failed to evaluate transaction, see attached details for more info",
			errDetails: []*pb.ErrorDetail{{
				Address: "localhost:7051",
				MspId:   "msp1",
				Message: "rpc error: code = Aborted desc = wibble",
			}},
		},
		{
			name: "process proposal chaincode error",
			members: []networkMember{
				{"id2", "peer1:8051", "msp1", 5},
			},
			endpointDefinition: &endpointDef{
				proposalResponseStatus:  400,
				proposalResponseMessage: "Mock chaincode error",
			},
			errCode:   codes.Unknown,
			errString: "evaluate call to endorser returned error: chaincode response 400, Mock chaincode error",
			errDetails: []*pb.ErrorDetail{{
				Address: "peer1:8051",
				MspId:   "msp1",
				Message: "chaincode response 400, Mock chaincode error",
			}},
		},
		{
			name: "evaluate on local org fails - retry in other org",
			members: []networkMember{
				{"id1", "localhost:7051", "msp1", 4},
				{"id2", "peer1:8051", "msp1", 4},
				{"id3", "peer2:9051", "msp2", 3},
				{"id4", "peer3:10051", "msp2", 4},
				{"id5", "peer4:11051", "msp3", 5},
			},
			localLedgerHeight: 4,
			plan: endorsementPlan{
				"g1": {{endorser: localhostMock, height: 4}, {endorser: peer1Mock, height: 4}}, // msp1
				"g2": {{endorser: peer2Mock, height: 3}, {endorser: peer3Mock, height: 4}},     // msp2
				"g3": {{endorser: peer4Mock, height: 5}},                                       // msp3
			},
			postSetup: func(t *testing.T, def *preparedTest) {
				def.localEndorser.ProcessProposalReturns(createErrorResponse(t, 500, "bad local endorser", nil), nil)
				peer1Mock.client.(*mocks.EndorserClient).ProcessProposalReturns(createErrorResponse(t, 500, "bad peer1 endorser", nil), nil)
			},
			expectedEndorsers: []string{"peer4:11051"},
		},
		{
			name: "restrict to local org peers - which all fail",
			members: []networkMember{
				{"id1", "localhost:7051", "msp1", 4},
				{"id2", "peer1:8051", "msp1", 4},
				{"id3", "peer2:9051", "msp2", 3},
				{"id4", "peer3:10051", "msp2", 4},
				{"id5", "peer4:11051", "msp3", 5},
			},
			localLedgerHeight: 4,
			plan: endorsementPlan{
				"g1": {{endorser: localhostMock, height: 4}, {endorser: peer1Mock, height: 4}}, // msp1
				"g2": {{endorser: peer2Mock, height: 3}, {endorser: peer3Mock, height: 4}},     // msp2
				"g3": {{endorser: peer4Mock, height: 5}},                                       // msp3
			},
			postSetup: func(t *testing.T, def *preparedTest) {
				def.localEndorser.ProcessProposalReturns(createErrorResponse(t, 500, "bad local endorser", nil), nil)
				peer1Mock.client.(*mocks.EndorserClient).ProcessProposalReturns(createErrorResponse(t, 500, "bad peer1 endorser", nil), nil)
			},
			endorsingOrgs: []string{"msp1"},
			errCode:       codes.Aborted,
			errString:     "failed to evaluate transaction, see attached details for more info",
			errDetails: []*pb.ErrorDetail{
				{
					Address: "localhost:7051",
					MspId:   "msp1",
					Message: "bad local endorser",
				},
				{
					Address: "peer1:8051",
					MspId:   "msp1",
					Message: "bad peer1 endorser",
				},
			},
		},
		{
			name: "fails due to invalid signature (pre-process check) - does not retry",
			members: []networkMember{
				{"id1", "localhost:7051", "msp1", 4},
				{"id2", "peer1:8051", "msp1", 4},
				{"id3", "peer2:9051", "msp2", 3},
				{"id4", "peer3:10051", "msp2", 4},
				{"id5", "peer4:11051", "msp3", 5},
			},
			localLedgerHeight: 4,
			plan: endorsementPlan{
				"g1": {{endorser: localhostMock, height: 4}, {endorser: peer1Mock, height: 4}}, // msp1
				"g2": {{endorser: peer2Mock, height: 3}, {endorser: peer3Mock, height: 4}},     // msp2
				"g3": {{endorser: peer4Mock, height: 5}},                                       // msp3
			},
			postSetup: func(t *testing.T, def *preparedTest) {
				def.localEndorser.ProcessProposalReturns(createErrorResponse(t, 500, "invalid signature", nil), fmt.Errorf("invalid signature"))
			},
			endorsingOrgs: []string{"msp1"},
			errCode:       codes.FailedPrecondition, // Code path could fail for reasons other than authentication
			errString:     "evaluate call to endorser returned error: invalid signature",
			errDetails: []*pb.ErrorDetail{
				{
					Address: "localhost:7051",
					MspId:   "msp1",
					Message: "invalid signature",
				},
			},
		},
		{
			name: "fails due to chaincode panic - retry on next peer",
			members: []networkMember{
				{"id1", "localhost:7051", "msp1", 4},
				{"id2", "peer1:8051", "msp1", 4},
				{"id3", "peer2:9051", "msp2", 3},
				{"id4", "peer3:10051", "msp2", 4},
				{"id5", "peer4:11051", "msp3", 5},
			},
			localLedgerHeight: 4,
			plan: endorsementPlan{
				"g1": {{endorser: localhostMock, height: 4}, {endorser: peer1Mock, height: 4}}, // msp1
				"g2": {{endorser: peer2Mock, height: 3}, {endorser: peer3Mock, height: 4}},     // msp2
				"g3": {{endorser: peer4Mock, height: 5}},                                       // msp3
			},
			postSetup: func(t *testing.T, def *preparedTest) {
				def.localEndorser.ProcessProposalReturns(createErrorResponse(t, 500, "error in simulation: chaincode stream terminated", nil), nil)
				peer1Mock.client.(*mocks.EndorserClient).ProcessProposalReturns(createErrorResponse(t, 500, "error in simulation: chaincode stream terminated", nil), nil)
			},
			endorsingOrgs: []string{"msp1"},
			errCode:       codes.Aborted,
			errString:     "failed to evaluate transaction, see attached details for more info",
			errDetails: []*pb.ErrorDetail{
				{
					Address: "localhost:7051",
					MspId:   "msp1",
					Message: "error in simulation: chaincode stream terminated",
				},
				{
					Address: "peer1:8051",
					MspId:   "msp1",
					Message: "error in simulation: chaincode stream terminated",
				},
			},
		},
		{
			name: "dialing endorser endpoint fails",
			members: []networkMember{
				{"id3", "peer2:9051", "msp2", 5},
			},
			postSetup: func(t *testing.T, def *preparedTest) {
				def.dialer.Calls(func(_ context.Context, target string, _ ...grpc.DialOption) (*grpc.ClientConn, error) {
					if target == "peer2:9051" {
						return nil, fmt.Errorf("endorser not answering")
					}
					return nil, nil
				})
			},
			errCode:   codes.Unavailable,
			errString: "failed to create new connection: endorser not answering",
		},
		{
			name: "discovery returns incomplete information - no Properties",
			postSetup: func(t *testing.T, def *preparedTest) {
				def.discovery.PeersOfChannelReturns([]gdiscovery.NetworkMember{{
					Endpoint: "localhost:7051",
					PKIid:    []byte("ill-defined"),
				}})
			},
			errCode:   codes.FailedPrecondition,
			errString: "no peers available to evaluate chaincode test_chaincode in channel test_channel",
		},
		{
			name: "context timeout during evaluate",
			plan: endorsementPlan{
				"g1": {{endorser: localhostMock, height: 3}}, // msp1
			},
			localLedgerHeight: 3,
			postSetup: func(t *testing.T, def *preparedTest) {
				def.ctx, def.cancel = context.WithTimeout(def.ctx, 100*time.Millisecond)

				def.localEndorser.ProcessProposalStub = func(ctx context.Context, proposal *peer.SignedProposal, option ...grpc.CallOption) (*peer.ProposalResponse, error) {
					time.Sleep(200 * time.Millisecond)
					return createProposalResponse(t, peer1Mock.address, "mock_response", 200, ""), nil
				}
			},
			postTest: func(t *testing.T, def *preparedTest) {
				def.cancel()
			},
			errCode:   codes.DeadlineExceeded,
			errString: "evaluate timeout expired",
		},
		{
			name: "uses local host ledger height",
			members: []networkMember{
				{"id2", "peer1:8051", "msp1", 6},
				{"id1", "localhost:7051", "msp1", 5},
			},
			localLedgerHeight: 7,
			expectedEndorsers: []string{"localhost:7051"},
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			test := prepareTest(t, &tt)

			response, err := test.server.Evaluate(test.ctx, &pb.EvaluateRequest{ProposedTransaction: test.signedProposal, TargetOrganizations: tt.endorsingOrgs})

			if checkError(t, &tt, err) {
				require.Nil(t, response, "response on error")
				return
			}

			// test the assertions

			require.NoError(t, err)
			// assert the result is the payload from the proposal response returned by the local endorser
			require.Equal(t, []byte("mock_response"), response.Result.Payload, "Incorrect result")

			// check the correct endorsers (mock) were called with the right parameters
			checkEndorsers(t, tt.expectedEndorsers, test)

			// check the discovery service (mock) was invoked as expected
			expectedChannel := common.ChannelID(testChannel)
			require.Equal(t, 2, test.discovery.PeersOfChannelCallCount())
			channel := test.discovery.PeersOfChannelArgsForCall(0)
			require.Equal(t, expectedChannel, channel)
			channel = test.discovery.PeersOfChannelArgsForCall(1)
			require.Equal(t, expectedChannel, channel)

			require.Equal(t, 1, test.discovery.IdentityInfoCallCount())
		})
	}
}
