/*
Copyright 2021 IBM All Rights Reserved.

SPDX-License-Identifier: Apache-2.0
*/

package gateway

import (
	"context"
	"fmt"

	"github.com/hyperledger/fabric-lib-go/common/flogging"
	gp "github.com/hyperledger/fabric-protos-go-apiv2/gateway"
	"github.com/hyperledger/fabric-protos-go-apiv2/peer"
	"github.com/hyperledger/fabric/protoutil"
	"google.golang.org/grpc/codes"
	"google.golang.org/grpc/status"
	"google.golang.org/protobuf/proto"
)

// Endorse will collect endorsements by invoking the transaction function specified in the SignedProposal against
// sufficient Peers to satisfy the endorsement policy.
func (gs *Server) Endorse(ctx context.Context, request *gp.EndorseRequest) (*gp.EndorseResponse, error) {
	if request == nil {
		return nil, status.Error(codes.InvalidArgument, "an endorse request is required")
	}
	signedProposal := request.GetProposedTransaction()
	if len(signedProposal.GetProposalBytes()) == 0 {
		return nil, status.Error(codes.InvalidArgument, "the proposed transaction must contain a signed proposal")
	}
	proposal, err := protoutil.UnmarshalProposal(signedProposal.GetProposalBytes())
	if err != nil {
		return nil, status.Error(codes.InvalidArgument, err.Error())
	}
	header, err := protoutil.UnmarshalHeader(proposal.GetHeader())
	if err != nil {
		return nil, status.Error(codes.InvalidArgument, err.Error())
	}
	channelHeader, err := protoutil.UnmarshalChannelHeader(header.GetChannelHeader())
	if err != nil {
		return nil, status.Error(codes.InvalidArgument, err.Error())
	}
	payload, err := protoutil.UnmarshalChaincodeProposalPayload(proposal.GetPayload())
	if err != nil {
		return nil, status.Error(codes.InvalidArgument, err.Error())
	}
	spec, err := protoutil.UnmarshalChaincodeInvocationSpec(payload.GetInput())
	if err != nil {
		return nil, status.Error(codes.InvalidArgument, err.Error())
	}

	channel := channelHeader.GetChannelId()
	chaincodeID := spec.GetChaincodeSpec().GetChaincodeId().GetName()
	hasTransientData := len(payload.GetTransientMap()) > 0

	logger := gs.logger.With("channel", channel, "chaincode", chaincodeID, "txID", request.GetTransactionId())

	var plan *plan
	var action *peer.ChaincodeEndorsedAction
	if len(request.GetEndorsingOrganizations()) > 0 {
		// The client is specifying the endorsing orgs and taking responsibility for ensuring it meets the signature policy
		plan, err = gs.registry.planForOrgs(channel, chaincodeID, request.GetEndorsingOrganizations())
		if err != nil {
			return nil, status.Error(codes.Unavailable, err.Error())
		}
	} else {
		// The client is delegating choice of endorsers to the gateway.
		plan, err = gs.planFromFirstEndorser(ctx, channel, chaincodeID, hasTransientData, signedProposal, logger)
		if err != nil {
			return nil, err
		}
	}

	for plan.completedLayout == nil {
		// loop through the layouts until one gets satisfied
		endorsers := plan.endorsers()
		if endorsers == nil {
			// no more layouts
			break
		}
		// send to all the endorsers
		waitCh := make(chan bool, len(endorsers))
		for _, e := range endorsers {
			go func(e *endorser) {
				for e != nil {
					if gs.processProposal(ctx, plan, e, signedProposal, logger) {
						break
					}
					e = plan.nextPeerInGroup(e)
				}
				waitCh <- true
			}(e)
		}
		for i := 0; i < len(endorsers); i++ {
			select {
			case <-waitCh:
				// Endorser completedLayout normally
			case <-ctx.Done():
				logger.Warnw("Endorse call timed out while collecting endorsements", "numEndorsers", len(endorsers))
				return nil, newRpcError(codes.DeadlineExceeded, "endorsement timeout expired while collecting endorsements")
			}
		}

	}

	if plan.completedLayout == nil {
		return nil, newRpcError(codes.Aborted, "failed to collect enough transaction endorsements, see attached details for more info", plan.errorDetails...)
	}

	action = &peer.ChaincodeEndorsedAction{ProposalResponsePayload: plan.responsePayload, Endorsements: uniqueEndorsements(plan.completedLayout.endorsements)}

	preparedTransaction, err := prepareTransaction(header, payload, action)
	if err != nil {
		return nil, status.Errorf(codes.Aborted, "failed to assemble transaction: %s", err)
	}

	return &gp.EndorseResponse{PreparedTransaction: preparedTransaction}, nil
}

type ppResponse struct {
	response *peer.ProposalResponse
	err      error
}

// processProposal will invoke the given endorsing peer to process the signed proposal, and will update the plan accordingly.
// This function will timeout and return false if the given context timeout or the EndorsementTimeout option expires.
// Returns boolean true if the endorsement was successful.
func (gs *Server) processProposal(ctx context.Context, plan *plan, endorser *endorser, signedProposal *peer.SignedProposal, logger *flogging.FabricLogger) bool {
	var response *peer.ProposalResponse
	done := make(chan *ppResponse)
	go func() {
		defer close(done)
		logger.Debugw("Sending to endorser:", "MSPID", endorser.mspid, "endpoint", endorser.address)
		ctx, cancel := context.WithTimeout(ctx, gs.options.EndorsementTimeout) // timeout of individual endorsement
		defer cancel()
		response, err := endorser.client.ProcessProposal(ctx, signedProposal)
		done <- &ppResponse{response: response, err: err}
	}()
	select {
	case resp := <-done:
		// Endorser completedLayout normally
		code, message, _, remove := responseStatus(resp.response, resp.err)
		if code != codes.OK {
			logger.Warnw("Endorse call to endorser failed", "MSPID", endorser.mspid, "endpoint", endorser.address, "error", message)
			if remove {
				gs.registry.removeEndorser(endorser)
			}
			plan.addError(errorDetail(endorser.endpointConfig, message))
			return false
		}
		response = resp.response
		logger.Debugw("Endorse call to endorser returned success", "MSPID", endorser.mspid, "endpoint", endorser.address, "status", response.Response.Status, "message", response.Response.Message)

		responseMessage := response.GetResponse()
		if responseMessage != nil {
			responseMessage.Payload = nil // Remove any duplicate response payload
		}

		return plan.processEndorsement(endorser, response)
	case <-ctx.Done():
		// Overall endorsement timeout expired
		return false
	}
}

// planFromFirstEndorser implements the gateway's strategy of processing the proposal on a single (preferably local) peer
// and using the ChaincodeInterest from the response to invoke discovery and build an endorsement plan.
// Returns the endorsement plan which can be used to request further endorsements, if required.
func (gs *Server) planFromFirstEndorser(ctx context.Context, channel string, chaincodeID string, hasTransientData bool, signedProposal *peer.SignedProposal, logger *flogging.FabricLogger) (*plan, error) {
	defaultInterest := &peer.ChaincodeInterest{
		Chaincodes: []*peer.ChaincodeCall{{
			Name: chaincodeID,
		}},
	}

	// 1. Choose an endorser from the gateway's organization
	plan, err := gs.registry.planForOrgs(channel, chaincodeID, []string{gs.registry.localEndorser.mspid})
	if err != nil {
		// No local org endorsers for this channel/chaincode. If transient data is involved, return error
		if hasTransientData {
			return nil, status.Error(codes.FailedPrecondition, "no endorsers found in the gateway's organization; retry specifying endorsing organization(s) to protect transient data")
		}
		// Otherwise, just let discovery pick one.
		plan, err = gs.registry.endorsementPlan(channel, defaultInterest, nil)
		if err != nil {
			return nil, status.Error(codes.FailedPrecondition, err.Error())
		}
	}
	firstEndorser := plan.endorsers()[0]

	gs.logger.Debugw("Sending to first endorser:", "MSPID", firstEndorser.mspid, "endpoint", firstEndorser.address)

	// 2. Process the proposal on this endorser
	var firstResponse *peer.ProposalResponse
	var errDetails []proto.Message

	for firstResponse == nil && firstEndorser != nil {
		done := make(chan struct{})
		go func() {
			defer close(done)

			ctx, cancel := context.WithTimeout(ctx, gs.options.EndorsementTimeout)
			defer cancel()
			firstResponse, err = firstEndorser.client.ProcessProposal(ctx, signedProposal)
			code, message, _, remove := responseStatus(firstResponse, err)

			if code != codes.OK {
				logger.Warnw("Endorse call to endorser failed", "endorserAddress", firstEndorser.address, "endorserMspid", firstEndorser.mspid, "error", message)
				errDetails = append(errDetails, errorDetail(firstEndorser.endpointConfig, message))
				if remove {
					gs.registry.removeEndorser(firstEndorser)
				}
				firstEndorser = plan.nextPeerInGroup(firstEndorser)
				firstResponse = nil
			}
		}()
		select {
		case <-done:
			// Endorser completed normally
		case <-ctx.Done():
			// Overall endorsement timeout expired
			logger.Warn("Endorse call timed out while collecting first endorsement")
			return nil, newRpcError(codes.DeadlineExceeded, "endorsement timeout expired while collecting first endorsement")
		}
	}
	if firstEndorser == nil || firstResponse == nil {
		return nil, newRpcError(codes.Aborted, "failed to endorse transaction, see attached details for more info", errDetails...)
	}

	// 3. Extract ChaincodeInterest and SBE policies
	// The chaincode interest could be nil for legacy peers and for chaincode functions that don't produce a read-write set
	interest := firstResponse.Interest
	if len(interest.GetChaincodes()) == 0 {
		interest = defaultInterest
	}

	// 4. If transient data is involved, then we need to ensure that discovery only returns orgs which own the collections involved.
	// Do this by setting NoPrivateReads to false on each collection
	originalInterest := &peer.ChaincodeInterest{}
	var protectedCollections []string
	if hasTransientData {
		for _, call := range interest.GetChaincodes() {
			ccc := proto.Clone(call).(*peer.ChaincodeCall)
			originalInterest.Chaincodes = append(originalInterest.Chaincodes, ccc)
			if call.NoPrivateReads {
				call.NoPrivateReads = false
				protectedCollections = append(protectedCollections, call.CollectionNames...)
			}
		}
	}

	// 5. Get a set of endorsers from discovery via the registry
	// The preferred discovery layout will contain the firstEndorser's Org.
	plan, err = gs.registry.endorsementPlan(channel, interest, firstEndorser)
	if err != nil {
		if len(protectedCollections) > 0 {
			// may have failed because of the cautious approach we are taking with transient data - check
			_, err = gs.registry.endorsementPlan(channel, originalInterest, firstEndorser)
			if err == nil {
				return nil, status.Error(codes.FailedPrecondition, fmt.Sprintf("requires endorsement from organisation(s) that are not in the distribution policy of the private data collection(s): %v; retry specifying trusted endorsing organizations to protect transient data", protectedCollections))
			}
		}
		return nil, status.Error(codes.FailedPrecondition, err.Error())
	}

	// 6. Remove the gateway org's endorser, since we've already done that
	plan.processEndorsement(firstEndorser, firstResponse)

	return plan, nil
}
