// Copyright 2024 The Erigon Authors
// This file is part of Erigon.
//
// Erigon is free software: you can redistribute it and/or modify
// it under the terms of the GNU Lesser General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Erigon is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Lesser General Public License for more details.
//
// You should have received a copy of the GNU Lesser General Public License
// along with Erigon. If not, see <http://www.gnu.org/licenses/>.

package sentry_multi_client

import (
	"bytes"
	"context"
	"encoding/binary"
	"encoding/hex"
	"fmt"
	"math/rand"
	"sync"
	"time"

	"github.com/c2h5oh/datasize"
	"github.com/erigontech/erigon/db/datadir"
	"golang.org/x/sync/semaphore"

	"google.golang.org/grpc"
	"google.golang.org/grpc/backoff"
	"google.golang.org/grpc/credentials/insecure"
	"google.golang.org/grpc/keepalive"

	"github.com/erigontech/erigon/common"
	"github.com/erigontech/erigon/common/dbg"
	"github.com/erigontech/erigon/common/log/v3"
	"github.com/erigontech/erigon/db/kv"
	"github.com/erigontech/erigon/db/kv/dbutils"
	"github.com/erigontech/erigon/db/services"
	"github.com/erigontech/erigon/execution/chain"
	"github.com/erigontech/erigon/execution/protocol/rules"
	"github.com/erigontech/erigon/execution/rlp"
	"github.com/erigontech/erigon/execution/stagedsync"
	"github.com/erigontech/erigon/execution/types"
	"github.com/erigontech/erigon/node/direct"
	"github.com/erigontech/erigon/node/gointerfaces"
	"github.com/erigontech/erigon/node/gointerfaces/sentryproto"
	"github.com/erigontech/erigon/node/gointerfaces/typesproto"
	"github.com/erigontech/erigon/p2p/protocols/eth"
	"github.com/erigontech/erigon/p2p/protocols/wit"
	"github.com/erigontech/erigon/p2p/sentry"
	"github.com/erigontech/erigon/p2p/sentry/libsentry"
	"github.com/erigontech/erigon/rpc/jsonrpc/receipts"
)

// StartStreamLoops starts message processing loops for all sentries.
// The processing happens in several streams:
// RecvMessage - processing incoming headers/bodies
// RecvUploadMessage - sending bodies/receipts - may be heavy, it's ok to not process this messages enough fast, it's also ok to drop some of these messages if we can't process.
// RecvUploadHeadersMessage - sending headers - dedicated stream because headers propagation speed important for network health
// PeerEventsLoop - logging peer connect/disconnect events
// AnnounceBlockRangeLoop - announces available block range to all peers every epoch
func (cs *MultiClient) StartStreamLoops(ctx context.Context) {
	sentries := cs.Sentries()
	for i := range sentries {
		sentry := sentries[i]
		go cs.RecvMessageLoop(ctx, sentry, nil)
		go cs.RecvUploadMessageLoop(ctx, sentry, nil)
		go cs.RecvUploadHeadersMessageLoop(ctx, sentry, nil)
		go cs.PeerEventsLoop(ctx, sentry, nil)
	}
}

func (cs *MultiClient) RecvUploadMessageLoop(
	ctx context.Context,
	sentry sentryproto.SentryClient,
	wg *sync.WaitGroup,
) {
	ids := []sentryproto.MessageId{
		eth.ToProto[direct.ETH68][eth.GetBlockBodiesMsg],
		eth.ToProto[direct.ETH68][eth.GetReceiptsMsg],
		eth.ToProto[direct.ETH69][eth.GetReceiptsMsg],
		eth.ToProto[direct.ETH70][eth.GetReceiptsMsg],
		// eth/71 (EIP-8159) BAL exchange
		eth.ToProto[direct.ETH71][eth.GetBlockAccessListsMsg],
		wit.ToProto[direct.WIT0][wit.GetWitnessMsg],
	}
	streamFactory := func(streamCtx context.Context, sentry sentryproto.SentryClient) (grpc.ClientStream, error) {
		return sentry.Messages(streamCtx, &sentryproto.MessagesRequest{Ids: ids}, grpc.WaitForReady(true))
	}

	libsentry.ReconnectAndPumpStreamLoop(ctx, sentry, cs.makeStatusData, "RecvUploadMessage", streamFactory, MakeInboundMessage, cs.HandleInboundMessage, wg, cs.logger)
}

func (cs *MultiClient) RecvUploadHeadersMessageLoop(
	ctx context.Context,
	sentry sentryproto.SentryClient,
	wg *sync.WaitGroup,
) {
	ids := []sentryproto.MessageId{
		eth.ToProto[direct.ETH68][eth.GetBlockHeadersMsg],
	}
	streamFactory := func(streamCtx context.Context, sentry sentryproto.SentryClient) (grpc.ClientStream, error) {
		return sentry.Messages(streamCtx, &sentryproto.MessagesRequest{Ids: ids}, grpc.WaitForReady(true))
	}

	libsentry.ReconnectAndPumpStreamLoop(ctx, sentry, cs.makeStatusData, "RecvUploadHeadersMessage", streamFactory, MakeInboundMessage, cs.HandleInboundMessage, wg, cs.logger)
}

func (cs *MultiClient) RecvMessageLoop(
	ctx context.Context,
	sentry sentryproto.SentryClient,
	wg *sync.WaitGroup,
) {
	ids := []sentryproto.MessageId{
		eth.ToProto[direct.ETH68][eth.BlockHeadersMsg],
		eth.ToProto[direct.ETH68][eth.BlockBodiesMsg],
		eth.ToProto[direct.ETH68][eth.NewBlockHashesMsg],
		eth.ToProto[direct.ETH68][eth.NewBlockMsg],
		wit.ToProto[direct.WIT0][wit.NewWitnessMsg],
		wit.ToProto[direct.WIT0][wit.WitnessMsg],
		eth.ToProto[direct.ETH69][eth.BlockRangeUpdateMsg],
		// eth/71 (EIP-8159) BAL responses to outbound GetBlockAccessLists requests
		eth.ToProto[direct.ETH71][eth.BlockAccessListsMsg],
	}
	streamFactory := func(streamCtx context.Context, sentry sentryproto.SentryClient) (grpc.ClientStream, error) {
		return sentry.Messages(streamCtx, &sentryproto.MessagesRequest{Ids: ids}, grpc.WaitForReady(true))
	}

	libsentry.ReconnectAndPumpStreamLoop(ctx, sentry, cs.makeStatusData, "RecvMessage", streamFactory, MakeInboundMessage, cs.HandleInboundMessage, wg, cs.logger)
}

func (cs *MultiClient) PeerEventsLoop(
	ctx context.Context,
	sentry sentryproto.SentryClient,
	wg *sync.WaitGroup,
) {
	streamFactory := func(streamCtx context.Context, sentry sentryproto.SentryClient) (grpc.ClientStream, error) {
		return sentry.PeerEvents(streamCtx, &sentryproto.PeerEventsRequest{}, grpc.WaitForReady(true))
	}
	messageFactory := func() *sentryproto.PeerEvent {
		return new(sentryproto.PeerEvent)
	}

	libsentry.ReconnectAndPumpStreamLoop(ctx, sentry, cs.makeStatusData, "PeerEvents", streamFactory, messageFactory, cs.HandlePeerEvent, wg, cs.logger)
}

type StatusGetter interface {
	GetStatusData(ctx context.Context) (*sentryproto.StatusData, error)
}

// MultiClient - does handle request/response/subscriptions to multiple sentries
// each sentry may support same or different p2p protocol
type MultiClient struct {
	sentries           []sentryproto.SentryClient
	ChainConfig        *chain.Config
	db                 kv.TemporalRoDB
	WitnessBuffer      *stagedsync.WitnessBuffer
	Engine             rules.Engine
	blockReader        services.FullBlockReader
	statusDataProvider StatusGetter
	logPeerInfo        bool

	logger                           log.Logger
	getReceiptsActiveGoroutineNumber *semaphore.Weighted
	ethApiWrapper                    eth.ReceiptsGetter

	// balFetcher routes eth/71 (EIP-8159) BlockAccessLists responses to the
	// goroutine that issued the corresponding GetBlockAccessLists request.
	// Owned by MultiClient so it's shared across all sentries.
	balFetcher *BALFetcher
}

var _ eth.ReceiptsGetter = new(receipts.Generator) // compile-time interface-check

func NewMultiClient(
	dirs datadir.Dirs,
	db kv.TemporalRoDB,
	chainConfig *chain.Config,
	engine rules.Engine,
	sentries []sentryproto.SentryClient,
	blockReader services.FullBlockReader,
	statusDataProvider StatusGetter,
	logPeerInfo bool,
	enableWitProtocol bool,
	logger log.Logger,
) (*MultiClient, error) {
	// Initialize witness buffer for Polygon chains with witness protocol enabled
	var witnessBuffer *stagedsync.WitnessBuffer
	if chainConfig.Bor != nil && enableWitProtocol {
		witnessBuffer = stagedsync.NewWitnessBuffer()
	}

	cs := &MultiClient{
		sentries:                         sentries,
		ChainConfig:                      chainConfig,
		db:                               db,
		WitnessBuffer:                    witnessBuffer,
		Engine:                           engine,
		blockReader:                      blockReader,
		statusDataProvider:               statusDataProvider,
		logPeerInfo:                      logPeerInfo,
		logger:                           logger,
		getReceiptsActiveGoroutineNumber: semaphore.NewWeighted(1),
		ethApiWrapper:                    receipts.NewGenerator(dirs, blockReader, engine, nil, 5*time.Minute),
		balFetcher:                       NewBALFetcher(),
	}

	return cs, nil
}

func (cs *MultiClient) Sentries() []sentryproto.SentryClient { return cs.sentries }

func (cs *MultiClient) receipts66(_ context.Context, _ *sentryproto.InboundMessage, _ sentryproto.SentryClient) error {
	return nil
}

func (cs *MultiClient) getBlockHeaders66(ctx context.Context, inreq *sentryproto.InboundMessage, sentry sentryproto.SentryClient) error {
	var query eth.GetBlockHeadersPacket66
	if err := rlp.DecodeBytes(inreq.Data, &query); err != nil {
		return fmt.Errorf("decoding getBlockHeaders66: %w, data: %x", err, inreq.Data)
	}

	var headers []*types.Header
	if err := cs.db.View(ctx, func(tx kv.Tx) (err error) {
		headers, err = eth.AnswerGetBlockHeadersQuery(tx, query.GetBlockHeadersPacket, cs.blockReader)
		if err != nil {
			return err
		}
		return nil
	}); err != nil {
		return fmt.Errorf("querying BlockHeaders: %w", err)
	}

	// Even if we get empty headers list from db, we'll respond with that. Nodes
	// running on erigon 2.48 with --sentry.drop-useless-peers will kick us out
	// because of certain checks. But, nodes post that will not kick us out. This
	// is useful as currently with no response, we're anyways getting kicked due
	// to request timeout and EOF.

	b, err := rlp.EncodeToBytes(&eth.BlockHeadersPacket66{
		RequestId:          query.RequestId,
		BlockHeadersPacket: headers,
	})
	if err != nil {
		return fmt.Errorf("encode header response: %w", err)
	}
	outreq := sentryproto.SendMessageByIdRequest{
		PeerId: inreq.PeerId,
		Data: &sentryproto.OutboundMessageData{
			Id:   sentryproto.MessageId_BLOCK_HEADERS_66,
			Data: b,
		},
	}
	_, err = sentry.SendMessageById(ctx, &outreq, &grpc.EmptyCallOption{})
	if err != nil {
		if !libsentry.IsPeerNotFoundErr(err) {
			return fmt.Errorf("send header response 66: %w", err)
		}
		return fmt.Errorf("send header response 66: %w", err)
	}
	//cs.logger.Info(fmt.Sprintf("[%s] GetBlockHeaderMsg{hash=%x, number=%d, amount=%d, skip=%d, reverse=%t, responseLen=%d}", ConvertH512ToPeerID(inreq.PeerId), query.Origin.Hash, query.Origin.Number, query.Amount, query.Skip, query.Reverse, len(b)))
	return nil
}

// blockAccessLists71 handles an inbound eth/71 BlockAccessLists response
// (EIP-8159) by decoding it and delivering to the waiting fetcher via
// request id. Unknown / late / peer-mismatch arrivals are silently dropped —
// that's not a bad-peer signal; legitimate races exist (e.g. timeouts).
// Payload-hash validation lives in BALFetcher.FetchBlockAccessLists; peers
// that return garbage are penalised there.
func (cs *MultiClient) blockAccessLists71(_ context.Context, inreq *sentryproto.InboundMessage, _ sentryproto.SentryClient) error {
	if cs.balFetcher == nil {
		// MultiClient was constructed without a BAL fetcher (e.g. unit-test
		// builds that build &MultiClient{} directly). Drop the message.
		return nil
	}
	var packet eth.BlockAccessListsPacket66
	if err := rlp.DecodeBytes(inreq.Data, &packet); err != nil {
		return fmt.Errorf("decoding blockAccessLists71: %w, data: %x", err, inreq.Data)
	}
	peerID := sentry.ConvertH512ToPeerID(inreq.PeerId)
	cs.balFetcher.Deliver(peerID, &packet)
	return nil
}

// FetchBlockAccessLists issues a one-shot GetBlockAccessLists (eth/71,
// EIP-8159) request to peerID via the sentry at sentryIndex, returning the
// validated BALs aligned with blockHashes. expectedHashes must match
// blockHashes in length and carry the BlockAccessListHash from each block's
// header. See BALFetcher for validation and bad-peer semantics.
//
// sentryIndex MUST be the sentry where peerID is actually connected. In
// multi-sentry deployments, GrpcServer.SendMessageById currently has no
// peer-to-sentry routing (sentry_grpc_server.go: "TODO: enable after support
// peer to sentry mapping") and silently returns an empty Peers reply with
// nil error when the peer isn't local — so picking a random sentry would
// produce defaultFetchTimeout-bounded silent failures roughly (N-1)/N of
// the time on N sentries. Use BALDownloader.pickEth71Peer to obtain a
// matched (peer, sentryIndex) pair.
//
// Returns an error if sentryIndex is out of range or that sentry is not
// ready. Responses come back through the normal inbound-message path and
// are delivered by blockAccessLists71.
func (cs *MultiClient) FetchBlockAccessLists(
	ctx context.Context,
	sentryIndex int,
	peerID [64]byte,
	blockHashes []common.Hash,
	expectedHashes []common.Hash,
) ([]rlp.RawValue, error) {
	if sentryIndex < 0 || sentryIndex >= len(cs.sentries) {
		return nil, fmt.Errorf("bal: sentry index %d out of range [0,%d)", sentryIndex, len(cs.sentries))
	}
	sc := cs.sentries[sentryIndex]
	if ready, ok := sc.(interface{ Ready() bool }); ok && !ready.Ready() {
		return nil, fmt.Errorf("bal: sentry %d not ready", sentryIndex)
	}
	return cs.balFetcher.FetchBlockAccessLists(ctx, sc, peerID, blockHashes, expectedHashes)
}

// getBlockAccessLists71 answers an inbound eth/71 GetBlockAccessLists request
// (EIP-8159) by looking up stored BALs from rawdb and replying with a
// BlockAccessLists response positionally aligned to the request.
func (cs *MultiClient) getBlockAccessLists71(ctx context.Context, inreq *sentryproto.InboundMessage, sentry sentryproto.SentryClient) error {
	var query eth.GetBlockAccessListsPacket66
	if err := rlp.DecodeBytes(inreq.Data, &query); err != nil {
		return fmt.Errorf("decoding getBlockAccessLists71: %w, data: %x", err, inreq.Data)
	}
	tx, err := cs.db.BeginRo(ctx)
	if err != nil {
		return err
	}
	defer tx.Rollback()
	response := eth.AnswerGetBlockAccessListsQuery(tx, query.GetBlockAccessListsPacket, cs.blockReader)
	tx.Rollback()
	b, err := rlp.EncodeToBytes(&eth.BlockAccessListsPacket66{
		RequestId:              query.RequestId,
		BlockAccessListsPacket: response,
	})
	if err != nil {
		return fmt.Errorf("encode BlockAccessLists response: %w", err)
	}
	outreq := sentryproto.SendMessageByIdRequest{
		PeerId: inreq.PeerId,
		Data: &sentryproto.OutboundMessageData{
			Id:   sentryproto.MessageId_BLOCK_ACCESS_LISTS_71,
			Data: b,
		},
	}
	_, err = sentry.SendMessageById(ctx, &outreq, &grpc.EmptyCallOption{})
	if err != nil {
		if libsentry.IsPeerNotFoundErr(err) {
			return nil
		}
		return fmt.Errorf("send BlockAccessLists response: %w", err)
	}
	return nil
}

func (cs *MultiClient) getBlockBodies66(ctx context.Context, inreq *sentryproto.InboundMessage, sentry sentryproto.SentryClient) error {
	var query eth.GetBlockBodiesPacket66
	if err := rlp.DecodeBytes(inreq.Data, &query); err != nil {
		return fmt.Errorf("decoding getBlockBodies66: %w, data: %x", err, inreq.Data)
	}
	tx, err := cs.db.BeginRo(ctx)
	if err != nil {
		return err
	}
	defer tx.Rollback()
	response := eth.AnswerGetBlockBodiesQuery(tx, query.GetBlockBodiesPacket, cs.blockReader)
	tx.Rollback()
	b, err := rlp.EncodeToBytes(&eth.BlockBodiesRLPPacket66{
		RequestId:            query.RequestId,
		BlockBodiesRLPPacket: response,
	})
	if err != nil {
		return fmt.Errorf("encode header response: %w", err)
	}
	outreq := sentryproto.SendMessageByIdRequest{
		PeerId: inreq.PeerId,
		Data: &sentryproto.OutboundMessageData{
			Id:   sentryproto.MessageId_BLOCK_BODIES_66,
			Data: b,
		},
	}
	_, err = sentry.SendMessageById(ctx, &outreq, &grpc.EmptyCallOption{})
	if err != nil {
		if libsentry.IsPeerNotFoundErr(err) {
			return nil
		}
		return fmt.Errorf("send bodies response: %w", err)
	}
	//cs.logger.Info(fmt.Sprintf("[%s] GetBlockBodiesMsg responseLen %d", ConvertH512ToPeerID(inreq.PeerId), len(b)))
	return nil
}

func (cs *MultiClient) getReceipts66(ctx context.Context, inreq *sentryproto.InboundMessage, sentryClient sentryproto.SentryClient) error {
	var query eth.GetReceiptsPacket66
	if err := rlp.DecodeBytes(inreq.Data, &query); err != nil {
		return fmt.Errorf("decoding getReceipts66: %w, data: %x", err, inreq.Data)
	}
	return cs.getReceiptsInner(ctx, inreq.PeerId, sentryClient, requestParams{
		id:         query.RequestId,
		query:      query.GetReceiptsPacket,
		ethVersion: direct.ETH68,
	})
}

func (cs *MultiClient) getReceipts69(ctx context.Context, inreq *sentryproto.InboundMessage, sentryClient sentryproto.SentryClient) error {
	var query eth.GetReceiptsPacket66
	if err := rlp.DecodeBytes(inreq.Data, &query); err != nil {
		return fmt.Errorf("decoding getReceipts69: %w, data: %x", err, inreq.Data)
	}
	return cs.getReceiptsInner(ctx, inreq.PeerId, sentryClient, requestParams{
		id:         query.RequestId,
		query:      query.GetReceiptsPacket,
		ethVersion: direct.ETH69,
	})
}

func (cs *MultiClient) getReceipts70(ctx context.Context, inreq *sentryproto.InboundMessage, sentryClient sentryproto.SentryClient) error {
	var query eth.GetReceiptsPacket70
	if err := rlp.DecodeBytes(inreq.Data, &query); err != nil {
		return fmt.Errorf("decoding getReceipts70: %w, data: %x", err, inreq.Data)
	}
	return cs.getReceiptsInner(ctx, inreq.PeerId, sentryClient, requestParams{
		id:                     query.RequestId,
		query:                  query.GetReceiptsPacket,
		ethVersion:             direct.ETH70,
		firstBlockReceiptIndex: query.FirstBlockReceiptIndex,
	})
}

// requestParams groups the per-request parameters for getReceiptsInner.
type requestParams struct {
	id                     uint64
	query                  eth.GetReceiptsPacket
	ethVersion             uint
	firstBlockReceiptIndex uint64
}

// getReceiptsInner handles GetReceipts for all protocol versions.
func (cs *MultiClient) getReceiptsInner(ctx context.Context, peerId *typesproto.H512, sentryClient sentryproto.SentryClient, p requestParams) error {
	sizeLimit := eth.NoSizeLimit
	if p.ethVersion >= direct.ETH70 {
		sizeLimit = eth.Eth70ResponseSizeLimit
	}
	opts := eth.ReceiptQueryOpts{
		EthVersion:             p.ethVersion,
		FirstBlockReceiptIndex: p.firstBlockReceiptIndex,
		SizeLimit:              sizeLimit,
	}

	cached, needMore, err := eth.AnswerGetReceiptsQueryCacheOnly(ctx, cs.ethApiWrapper, p.query, opts)
	if err != nil {
		return err
	}
	var (
		receiptsList        []rlp.RawValue
		lastBlockIncomplete bool
	)
	if cached != nil {
		receiptsList = cached.EncodedReceipts
		lastBlockIncomplete = cached.LastBlockIncomplete
	}
	if needMore {
		err = cs.getReceiptsActiveGoroutineNumber.Acquire(ctx, 1)
		if err != nil {
			return err
		}
		defer cs.getReceiptsActiveGoroutineNumber.Release(1)

		tx, err := cs.db.BeginTemporalRo(ctx)
		if err != nil {
			return err
		}
		defer tx.Rollback()
		receiptsList, lastBlockIncomplete, err = eth.AnswerGetReceiptsQuery(ctx, cs.ChainConfig, cs.ethApiWrapper, cs.blockReader, tx, p.query, cached, opts)
		if err != nil {
			return err
		}
	}

	var b []byte
	if p.ethVersion >= direct.ETH70 {
		b, err = rlp.EncodeToBytes(&eth.ReceiptsRLPPacket70{
			RequestId:           p.id,
			LastBlockIncomplete: lastBlockIncomplete,
			ReceiptsRLPPacket:   receiptsList,
		})
	} else {
		b, err = rlp.EncodeToBytes(&eth.ReceiptsRLPPacket66{
			RequestId:         p.id,
			ReceiptsRLPPacket: receiptsList,
		})
	}
	if err != nil {
		return fmt.Errorf("encode receipts response: %w", err)
	}

	msgId := sentryproto.MessageId_RECEIPTS_66
	if p.ethVersion >= direct.ETH70 {
		msgId = sentryproto.MessageId_RECEIPTS_70
	}
	outreq := sentryproto.SendMessageByIdRequest{
		PeerId: peerId,
		Data: &sentryproto.OutboundMessageData{
			Id:   msgId,
			Data: b,
		},
	}
	_, err = sentryClient.SendMessageById(ctx, &outreq, &grpc.OnFinishCallOption{})
	if err != nil {
		if libsentry.IsPeerNotFoundErr(err) {
			return nil
		}
		return fmt.Errorf("send receipts response: %w", err)
	}
	return nil
}

func (cs *MultiClient) getBlockWitnesses(ctx context.Context, inreq *sentryproto.InboundMessage, sentryClient sentryproto.SentryClient) error {
	var req wit.GetWitnessPacket
	if err := rlp.DecodeBytes(inreq.Data, &req); err != nil {
		return fmt.Errorf("decoding GetWitnessPacket: %w, data: %x", err, inreq.Data)
	}

	tx, err := cs.db.BeginRo(ctx)
	if err != nil {
		return err
	}
	defer tx.Rollback()

	seen := make(map[common.Hash]struct{}, len(req.WitnessPages))
	for _, witnessPage := range req.WitnessPages {
		seen[witnessPage.Hash] = struct{}{}
	}

	witnessSize := make(map[common.Hash]uint64, len(seen))
	headers := make(map[common.Hash]*types.Header, len(seen))
	for witnessBlockHash := range seen {
		header, err := cs.blockReader.HeaderByHash(ctx, tx, witnessBlockHash)
		if err != nil {
			return fmt.Errorf("reading header for witness hash %x: %w", witnessBlockHash, err)
		}
		if header == nil {
			continue
		}
		headers[witnessBlockHash] = header
		key := dbutils.HeaderKey(header.Number.Uint64(), witnessBlockHash)
		sizeBytes, err := tx.GetOne(kv.BorWitnessSizes, key)
		if err != nil {
			return fmt.Errorf("reading witness size for hash %x: %w", witnessBlockHash, err)
		}
		if len(sizeBytes) > 0 {
			witnessSize[witnessBlockHash] = binary.BigEndian.Uint64(sizeBytes)
		} else {
			witnessSize[witnessBlockHash] = 0
		}
	}

	var response wit.WitnessPacketResponse
	witnessCache := make(map[common.Hash][]byte, len(seen))
	totalResponsePayloadDataAmount := 0
	totalCached := 0

	for _, witnessPage := range req.WitnessPages {
		size := witnessSize[witnessPage.Hash]
		totalPages := (size + wit.PageSize - 1) / wit.PageSize // Ceiling division

		var witnessPageResponse wit.WitnessPageResponse
		witnessPageResponse.Page = witnessPage.Page
		witnessPageResponse.Hash = witnessPage.Hash
		witnessPageResponse.TotalPages = totalPages

		if witnessPage.Page < totalPages {
			var witnessBytes []byte
			if cachedRLPBytes, exists := witnessCache[witnessPage.Hash]; exists {
				witnessBytes = cachedRLPBytes
			} else {
				header, ok := headers[witnessPage.Hash]
				if !ok || header == nil {
					continue
				}
				key := dbutils.HeaderKey(header.Number.Uint64(), witnessPage.Hash)
				queriedBytes, err := tx.GetOne(kv.BorWitnesses, key)
				if err != nil {
					return fmt.Errorf("reading witness for hash %x: %w", witnessPage.Hash, err)
				}
				witnessCache[witnessPage.Hash] = queriedBytes
				witnessBytes = queriedBytes
				totalCached += len(queriedBytes)
			}

			start := min(wit.PageSize*witnessPage.Page, uint64(len(witnessBytes)))
			end := min(start+wit.PageSize, uint64(len(witnessBytes)))
			witnessPageResponse.Data = witnessBytes[start:end]
			totalResponsePayloadDataAmount += len(witnessPageResponse.Data)
		}
		response = append(response, witnessPageResponse)

		// fast fail check
		if totalCached >= wit.MaximumCachedWitnessOnARequest {
			return fmt.Errorf("request demands too much memory: %d bytes", totalCached)
		}

		// memory protection check
		if totalResponsePayloadDataAmount >= wit.MaximumResponseSize {
			return fmt.Errorf("response exceeds maximum p2p payload size: %d bytes", totalResponsePayloadDataAmount)
		}
	}

	reply := wit.WitnessPacketRLPPacket{
		RequestId:             req.RequestId,
		WitnessPacketResponse: response,
	}
	b, err := rlp.EncodeToBytes(&reply)
	if err != nil {
		return fmt.Errorf("encoding witness response: %w", err)
	}

	outreq := sentryproto.SendMessageByIdRequest{
		PeerId: inreq.PeerId,
		Data: &sentryproto.OutboundMessageData{
			Id:   sentryproto.MessageId_BLOCK_WITNESS_W0,
			Data: b,
		},
	}
	_, err = sentryClient.SendMessageById(ctx, &outreq, &grpc.EmptyCallOption{})
	if err != nil && !libsentry.IsPeerNotFoundErr(err) {
		return fmt.Errorf("sending witness response: %w", err)
	}
	return nil
}

// addBlockWitnesses processes response to our getBlockWitnesses request
func (cs *MultiClient) addBlockWitnesses(ctx context.Context, inreq *sentryproto.InboundMessage, sentryClient sentryproto.SentryClient) error {
	if cs.WitnessBuffer == nil {
		return nil
	}

	var query wit.WitnessPacketRLPPacket
	if err := rlp.DecodeBytes(inreq.Data, &query); err != nil {
		return fmt.Errorf("decoding addBlockWitnesses: %w, data: %x", err, inreq.Data)
	}

	tx, err := cs.db.BeginRo(ctx)
	if err != nil {
		return err
	}
	defer tx.Rollback()

	// group witness pages by hash to reconstruct complete witnesses
	witnessPages := make(map[common.Hash]map[uint64][]byte)
	witnessTotalPages := make(map[common.Hash]uint64)

	for _, pageResponse := range query.WitnessPacketResponse {
		if pageResponse.TotalPages > wit.MaxWitnessPages {
			return fmt.Errorf("witness response advertises TotalPages %d > max %d for hash %x", pageResponse.TotalPages, wit.MaxWitnessPages, pageResponse.Hash)
		}
		// Page >= TotalPages is the empty-response sentinel documented on
		// wit.WitnessPageResponse.Page; skip without allocating.
		if pageResponse.Page >= pageResponse.TotalPages {
			continue
		}
		if prev, ok := witnessTotalPages[pageResponse.Hash]; ok && prev != pageResponse.TotalPages {
			return fmt.Errorf("witness response has inconsistent TotalPages for hash %x: %d vs %d", pageResponse.Hash, prev, pageResponse.TotalPages)
		}
		if witnessPages[pageResponse.Hash] == nil {
			witnessPages[pageResponse.Hash] = make(map[uint64][]byte)
		}
		witnessPages[pageResponse.Hash][pageResponse.Page] = pageResponse.Data
		witnessTotalPages[pageResponse.Hash] = pageResponse.TotalPages
	}

	// reconstruct witnesses
	for witnessHash, pages := range witnessPages {
		totalPages := witnessTotalPages[witnessHash]

		if uint64(len(pages)) != totalPages {
			// identify missing pages
			var missingPages []uint64
			for page := uint64(0); page < totalPages; page++ {
				if _, exists := pages[page]; !exists {
					missingPages = append(missingPages, page)
				}
			}

			// request missing pages
			if len(missingPages) > 0 {
				witnessPageRequests := make([]wit.WitnessPageRequest, len(missingPages))
				for i, page := range missingPages {
					witnessPageRequests[i] = wit.WitnessPageRequest{
						Hash: witnessHash,
						Page: page,
					}
				}

				getWitnessReq := wit.GetWitnessPacket{
					RequestId: rand.Uint64(),
					GetWitnessRequest: &wit.GetWitnessRequest{
						WitnessPages: witnessPageRequests,
					},
				}

				data, err := rlp.EncodeToBytes(getWitnessReq)
				if err != nil {
					cs.logger.Warn("failed to encode GetWitnessMsg for missing pages", "err", err, "hash", witnessHash)
					continue
				}

				// send request for missing pages to the same peer
				request := &sentryproto.SendMessageByIdRequest{
					PeerId: inreq.PeerId,
					Data: &sentryproto.OutboundMessageData{
						Id:   sentryproto.MessageId_GET_BLOCK_WITNESS_W0,
						Data: data,
					},
				}

				if _, err := sentryClient.SendMessageById(ctx, request); err != nil {
					// if sending to the specific peer fails, try random peers as fallback
					// TODO: instead of sending to random peers, add new function to send to peers known to have witness
					cs.logger.Info("failed to send GetWitnessMsg to original peer, trying random peers", "err", err, "hash", witnessHash)

					fallbackRequest := &sentryproto.SendMessageToRandomPeersRequest{
						Data: &sentryproto.OutboundMessageData{
							Id:   sentryproto.MessageId_GET_BLOCK_WITNESS_W0,
							Data: data,
						},
						MaxPeers: 1,
					}

					if _, err := sentryClient.SendMessageToRandomPeers(ctx, fallbackRequest); err != nil {
						cs.logger.Warn("failed to send GetWitnessMsg for missing pages to any peer", "err", err, "hash", witnessHash)
					} else {
						cs.logger.Info("requested missing witness pages via random peer", "hash", witnessHash, "missing_pages", missingPages)
					}
				} else {
					cs.logger.Info("requested missing witness pages from original peer", "hash", witnessHash, "missing_pages", missingPages, "peer", hex.EncodeToString(gointerfaces.ConvertH512ToBytes(inreq.PeerId)))
				}
			}
			continue
		}

		header, err := cs.blockReader.HeaderByHash(ctx, tx, witnessHash)
		if err != nil {
			return fmt.Errorf("reading header for witness hash %x: %w", witnessHash, err)
		}
		if header == nil {
			cs.logger.Debug("header not found for witness", "hash", witnessHash)
			continue
		}

		// reconstruct complete witness data by concatenating pages in order
		var completeWitness []byte
		for page := uint64(0); page < totalPages; page++ {
			pageData, exists := pages[page]
			if !exists {
				cs.logger.Debug("missing page in witness", "hash", witnessHash, "page", page)
				break
			}
			completeWitness = append(completeWitness, pageData...)
		}

		if uint64(len(pages)) == totalPages {
			cs.WitnessBuffer.AddWitness(header.Number.Uint64(), witnessHash, completeWitness)
		}
	}

	return nil
}

func (cs *MultiClient) newWitness(ctx context.Context, inreq *sentryproto.InboundMessage, sentryClient sentryproto.SentryClient) error {
	if cs.WitnessBuffer == nil {
		return nil
	}

	var query wit.NewWitnessPacket
	if err := rlp.DecodeBytes(inreq.Data, &query); err != nil {
		return fmt.Errorf("decoding newWitness: %w, data: %x", err, inreq.Data)
	}

	bHash := query.Witness.Header().Hash()

	var witBuf bytes.Buffer
	if err := query.Witness.EncodeRLP(&witBuf); err != nil {
		return fmt.Errorf("error in witness encoding: err: %w", err)
	}

	witBytes := witBuf.Bytes()
	blockNumber := query.Witness.Header().Number.Uint64()

	cs.WitnessBuffer.AddWitness(blockNumber, bHash, witBytes)

	return nil
}

// blockRange69 handles incoming BLOCK_RANGE_UPDATE messages
func (cs *MultiClient) blockRange69(ctx context.Context, inreq *sentryproto.InboundMessage, sentryClient sentryproto.SentryClient) error {
	var query eth.BlockRangeUpdatePacket
	if err := rlp.DecodeBytes(inreq.Data, &query); err != nil {
		return fmt.Errorf("decoding blockRange69: %w, data: %x", err, inreq.Data)
	}
	if err := query.Validate(); err != nil {
		cs.logger.Debug("Kick peer for invalid BlockRangeUpdate", "peer", inreq.PeerId.String(), "err", err)
		if _, err1 := sentryClient.PenalizePeer(ctx, &sentryproto.PenalizePeerRequest{
			PeerId:  inreq.PeerId,
			Penalty: sentryproto.PenaltyKind_Kick,
		}, &grpc.EmptyCallOption{}); err1 != nil {
			cs.logger.Error("Could not send penalty", "err", err1)
		}
		return err
	}

	go func() {
		ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
		defer cancel()

		for _, s := range cs.sentries {
			if _, err1 := s.SetPeerBlockRange(ctx, &sentryproto.SetPeerBlockRangeRequest{
				PeerId:            inreq.PeerId,
				LatestBlockHeight: query.Latest,
				MinBlockHeight:    query.Earliest,
			}, &grpc.EmptyCallOption{}); err1 != nil {
				cs.logger.Warn("Could not send latest block range for peer", "err", err1, "peer", inreq.PeerId.String())
			}
		}
	}()

	return nil
}

func MakeInboundMessage() *sentryproto.InboundMessage {
	return new(sentryproto.InboundMessage)
}

func (cs *MultiClient) HandleInboundMessage(ctx context.Context, message *sentryproto.InboundMessage, sentry sentryproto.SentryClient) (err error) {
	defer func() {
		if rec := recover(); rec != nil {
			err = fmt.Errorf("%+v, msgID=%s, trace: %s", rec, message.Id.String(), dbg.Stack())
		}
	}() // avoid crash because Erigon's core does many things
	err = cs.handleInboundMessage(ctx, message, sentry)

	if (err != nil) && rlp.IsInvalidRLPError(err) {
		cs.logger.Debug("Kick peer for invalid RLP", "err", err)
		penalizeRequest := sentryproto.PenalizePeerRequest{
			PeerId:  message.PeerId,
			Penalty: sentryproto.PenaltyKind_Kick, // TODO: Extend penalty kinds
		}
		if _, err1 := sentry.PenalizePeer(ctx, &penalizeRequest, &grpc.EmptyCallOption{}); err1 != nil {
			cs.logger.Error("Could not send penalty", "err", err1)
		}
	}

	return err
}

func (cs *MultiClient) handleInboundMessage(ctx context.Context, inreq *sentryproto.InboundMessage, sentry sentryproto.SentryClient) error {
	switch inreq.Id {
	case sentryproto.MessageId_NEW_BLOCK_HASHES_66,
		sentryproto.MessageId_BLOCK_HEADERS_66,
		sentryproto.MessageId_NEW_BLOCK_66,
		sentryproto.MessageId_BLOCK_BODIES_66:
		// Block download is handled by the execution layer (engine API), not over devp2p.
		return nil
	case sentryproto.MessageId_GET_BLOCK_HEADERS_66:
		return cs.getBlockHeaders66(ctx, inreq, sentry)
	case sentryproto.MessageId_GET_BLOCK_BODIES_66:
		return cs.getBlockBodies66(ctx, inreq, sentry)
	case sentryproto.MessageId_RECEIPTS_66:
		return cs.receipts66(ctx, inreq, sentry)
	case sentryproto.MessageId_GET_RECEIPTS_66:
		return cs.getReceipts66(ctx, inreq, sentry)
	case sentryproto.MessageId_NEW_WITNESS_W0:
		return cs.newWitness(ctx, inreq, sentry)
	case sentryproto.MessageId_BLOCK_WITNESS_W0:
		return cs.addBlockWitnesses(ctx, inreq, sentry)
	case sentryproto.MessageId_GET_BLOCK_WITNESS_W0:
		return cs.getBlockWitnesses(ctx, inreq, sentry)
	case sentryproto.MessageId_GET_RECEIPTS_69:
		return cs.getReceipts69(ctx, inreq, sentry)
	case sentryproto.MessageId_GET_RECEIPTS_70:
		return cs.getReceipts70(ctx, inreq, sentry)
	case sentryproto.MessageId_RECEIPTS_70:
		return cs.receipts66(ctx, inreq, sentry) // client-side receipt handling is a no-op
	case sentryproto.MessageId_GET_BLOCK_ACCESS_LISTS_71:
		return cs.getBlockAccessLists71(ctx, inreq, sentry)
	case sentryproto.MessageId_BLOCK_ACCESS_LISTS_71:
		return cs.blockAccessLists71(ctx, inreq, sentry)
	case sentryproto.MessageId_BLOCK_RANGE_UPDATE_69:
		return cs.blockRange69(ctx, inreq, sentry)
	default:
		return fmt.Errorf("not implemented for message Id: %s", inreq.Id)
	}
}

func (cs *MultiClient) HandlePeerEvent(ctx context.Context, event *sentryproto.PeerEvent, sentryClient sentryproto.SentryClient) error {
	eventID := event.EventId.String()
	peerID := sentry.ConvertH512ToPeerID(event.PeerId)
	peerIDStr := hex.EncodeToString(peerID[:])

	if !cs.logPeerInfo {
		cs.logger.Trace("[p2p] Sentry peer did", "eventID", eventID, "peer", peerIDStr)
		return nil
	}

	var nodeURL string
	var clientID string
	var capabilities []string
	if event.EventId == sentryproto.PeerEvent_Connect {
		reply, err := sentryClient.PeerById(ctx, &sentryproto.PeerByIdRequest{PeerId: event.PeerId})
		if err != nil {
			cs.logger.Debug("sentry.PeerById failed", "err", err)
		}
		if (reply != nil) && (reply.Peer != nil) {
			nodeURL = reply.Peer.Enode
			clientID = reply.Peer.Name
			capabilities = reply.Peer.Caps
		}
	}

	cs.logger.Trace("[p2p] Sentry peer did", "eventID", eventID, "peer", peerIDStr,
		"nodeURL", nodeURL, "clientID", clientID, "capabilities", capabilities)
	return nil
}

func (cs *MultiClient) makeStatusData(ctx context.Context) (*sentryproto.StatusData, error) {
	return cs.statusDataProvider.GetStatusData(ctx)
}

func GrpcClient(ctx context.Context, sentryAddr string) (*direct.SentryClientRemote, error) {
	// creating grpc client connection
	backoffCfg := backoff.DefaultConfig
	backoffCfg.BaseDelay = 500 * time.Millisecond
	backoffCfg.MaxDelay = 10 * time.Second
	dialOpts := make([]grpc.DialOption, 0, 4)
	dialOpts = append(dialOpts,
		grpc.WithConnectParams(grpc.ConnectParams{Backoff: backoffCfg, MinConnectTimeout: 10 * time.Minute}),
		grpc.WithDefaultCallOptions(grpc.MaxCallRecvMsgSize(int(16*datasize.MB))),
		grpc.WithKeepaliveParams(keepalive.ClientParameters{}),
		grpc.WithTransportCredentials(insecure.NewCredentials()),
	)

	conn, err := grpc.DialContext(ctx, sentryAddr, dialOpts...)
	if err != nil {
		return nil, fmt.Errorf("creating client connection to sentry P2P: %w", err)
	}
	return direct.NewSentryClientRemote(sentryproto.NewSentryClient(conn)), nil
}
