package rpc

import (
	"context"
	"errors"
	"fmt"
	"math/big"
	"os"
	"reflect"
	"time"

	"github.com/cenkalti/backoff/v4"
	"github.com/ethereum/go-ethereum"
	"github.com/ethereum/go-ethereum/accounts/abi"
	"github.com/ethereum/go-ethereum/accounts/abi/bind"
	"github.com/ethereum/go-ethereum/common"
	"github.com/ethereum/go-ethereum/common/hexutil"
	"github.com/ethereum/go-ethereum/consensus/misc"
	"github.com/ethereum/go-ethereum/core"
	"github.com/ethereum/go-ethereum/core/rawdb"
	"github.com/ethereum/go-ethereum/core/types"
	"github.com/ethereum/go-ethereum/eth"
	"github.com/ethereum/go-ethereum/log"
	"github.com/ethereum/go-ethereum/miner"
	"github.com/ethereum/go-ethereum/params"
	"github.com/ethereum/go-ethereum/rpc"
	"golang.org/x/sync/errgroup"

	"github.com/taikoxyz/taiko-mono/packages/taiko-client/bindings/encoding"
	shastaBindings "github.com/taikoxyz/taiko-mono/packages/taiko-client/bindings/shasta"
	"github.com/taikoxyz/taiko-mono/packages/taiko-client/pkg/config"
)

var (
	// errSyncing is returned when the L2 execution engine is syncing.
	errSyncing         = errors.New("syncing")
	rpcPollingInterval = 3 * time.Second
	defaultWaitTimeout = 3 * time.Minute
)

// prepCallOpts ensures opts is non-nil and applies the default RPC timeout to its context.
// Callers must defer the returned cancel func.
func prepCallOpts(opts *bind.CallOpts) (*bind.CallOpts, context.CancelFunc) {
	if opts == nil {
		opts = &bind.CallOpts{Context: context.Background()}
	}
	var cancel context.CancelFunc
	opts.Context, cancel = CtxWithTimeoutOrDefault(opts.Context, DefaultRpcTimeout)
	return opts, cancel
}

// GetProtocolConfigs gets the protocol configs from the inbox contract.
func (c *Client) GetProtocolConfigs(opts *bind.CallOpts) (config.ProtocolConfigs, error) {
	opts, cancel := prepCallOpts(opts)
	defer cancel()

	configs, err := c.ShastaClients.Inbox.GetConfig(opts)
	if err != nil {
		return nil, err
	}

	return config.NewInboxProtocolConfigs(&configs), nil
}

// WaitTillL2ExecutionEngineSynced keeps waiting until the L2 execution engine is fully synced.
func (c *Client) WaitTillL2ExecutionEngineSynced(ctx context.Context) error {
	start := time.Now()

	return backoff.Retry(
		func() error {
			newCtx, cancel := context.WithTimeout(ctx, DefaultRpcTimeout)
			defer cancel()

			progress, err := c.L2ExecutionEngineSyncProgress(newCtx)
			if err != nil {
				log.Error("Fetch L2 execution engine sync progress error", "error", encoding.TryParsingCustomError(err))
				return err
			}

			if progress.IsSyncing() {
				log.Info(
					"L2 execution engine is syncing",
					"currentBlockID", progress.CurrentBlockID,
					"highestOriginBlockID", progress.HighestOriginBlockID,
					"progress", progress.SyncProgress,
					"time", time.Since(start),
				)
				return errSyncing
			}

			return nil
		},
		backoff.WithContext(backoff.NewExponentialBackOff(), ctx),
	)
}

// LatestL2KnownL1Header fetches the L2 execution engine's latest known L1 header,
// if we can't find the L1Origin data, we will use the L1 genesis header instead.
func (c *Client) LatestL2KnownL1Header(ctx context.Context) (*types.Header, error) {
	ctxWithTimeout, cancel := CtxWithTimeoutOrDefault(ctx, DefaultRpcTimeout)
	defer cancel()

	// Try to fetch the latest known L1 header from the L2 execution engine.
	headL1Origin, err := c.L2.HeadL1Origin(ctxWithTimeout)
	if err != nil {
		switch err.Error() {
		case ethereum.NotFound.Error():
			return c.GetGenesisL1Header(ctxWithTimeout)
		default:
			return nil, err
		}
	}

	if headL1Origin == nil {
		return c.GetGenesisL1Header(ctxWithTimeout)
	}

	// Fetch the L1 header from the L1 chain.
	header, err := c.L1.HeaderByHash(ctxWithTimeout, headL1Origin.L1BlockHash)
	if err != nil {
		switch err.Error() {
		case ethereum.NotFound.Error():
			log.Warn("Latest L2 known L1 header not found, use genesis instead", "hash", headL1Origin.L1BlockHash)
			return c.GetGenesisL1Header(ctxWithTimeout)
		default:
			return nil, err
		}
	}

	log.Info("Latest L2 known L1 header", "height", header.Number, "hash", header.Hash())

	return header, nil
}

// GetGenesisL1Header fetches the L1 header that including L2 genesis block.
func (c *Client) GetGenesisL1Header(ctx context.Context) (*types.Header, error) {
	ctxWithTimeout, cancel := CtxWithTimeoutOrDefault(ctx, DefaultRpcTimeout)
	defer cancel()

	genesisHeight, err := c.GetActivationBlockNumber(ctxWithTimeout)
	if err != nil {
		return nil, err
	}

	return c.L1.HeaderByNumber(ctxWithTimeout, genesisHeight)
}

// L2ParentByCurrentBlockID fetches the block header from L2 execution engine with the largest block id that
// smaller than the given `blockId`.
func (c *Client) L2ParentByCurrentBlockID(ctx context.Context, blockID *big.Int) (*types.Header, error) {
	ctxWithTimeout, cancel := CtxWithTimeoutOrDefault(ctx, DefaultRpcTimeout)
	defer cancel()

	var (
		parentHash    common.Hash
		parentBlockID = new(big.Int).Sub(blockID, common.Big1)
	)

	log.Debug("Get parent block by block ID", "parentBlockID", parentBlockID)

	if parentBlockID.Cmp(common.Big0) == 0 {
		return c.L2.HeaderByNumber(ctxWithTimeout, common.Big0)
	}

	l1Origin, err := c.L2.L1OriginByID(ctxWithTimeout, parentBlockID)
	if err != nil {
		if err.Error() != ethereum.NotFound.Error() {
			return nil, err
		}

		// In some cases, the L1Origin data is not found in the L2 execution engine, we will try to fetch the parent
		// by the parent block ID.
		log.Warn("L1Origin not found, try to fetch parent by ID", "blockID", parentBlockID)

		parent, err := c.L2.BlockByNumber(ctxWithTimeout, parentBlockID)
		if err != nil {
			return nil, err
		}

		parentHash = parent.Hash()
	} else {
		parentHash = l1Origin.L2BlockHash
	}

	log.Debug("Parent block L1 origin", "l1Origin", l1Origin, "parentBlockID", parentBlockID)

	return c.L2.HeaderByHash(ctxWithTimeout, parentHash)
}

// WaitL1Header keeps waiting for the L1 block header of the given block ID.
func (c *Client) WaitL1Header(ctx context.Context, blockID *big.Int) (*types.Header, error) {
	// In test environment, we will keep mining blocks until the block is found.
	if os.Getenv("RUN_TESTS") == "true" {
		for {
			head, err := c.L1.HeaderByNumber(ctx, nil)
			if err != nil {
				return nil, err
			}
			if head.Number.Cmp(blockID) >= 0 {
				return head, nil
			}

			if err := c.L1.CallContext(context.Background(), nil, "evm_mine"); err != nil {
				return nil, err
			}
		}
	}
	return waitHeader(ctx, c.L1, blockID)
}

// WaitL2Header keeps waiting for the L2 block header of the given block ID.
func (c *Client) WaitL2Header(ctx context.Context, blockID *big.Int) (*types.Header, error) {
	return waitHeader(ctx, c.L2, blockID)
}

// WaitL2Block keeps waiting for the L2 block of the given block ID.
func (c *Client) WaitL2Block(ctx context.Context, blockID *big.Int) (*types.Block, error) {
	return waitBlock(ctx, c.L2, blockID)
}

// waitForFetchResult keeps polling the provided fetch function until a non-nil
// response is returned or the context times out.
func waitForFetchResult[T any](
	ctx context.Context,
	blockID *big.Int,
	fetchFn func(context.Context, *big.Int) (T, error),
) (T, error) {
	var (
		ctxWithTimeout = ctx
		cancel         context.CancelFunc
		response       T
		emptyResponse  T
		err            error
	)

	ticker := time.NewTicker(rpcPollingInterval)
	defer ticker.Stop()

	if _, ok := ctx.Deadline(); !ok {
		ctxWithTimeout, cancel = context.WithTimeout(ctx, defaultWaitTimeout)
		defer cancel()
	}

	log.Debug("Start fetching result from execution engine", "blockID", blockID)

	for ; true; <-ticker.C {
		if ctxWithTimeout.Err() != nil {
			return emptyResponse, ctxWithTimeout.Err()
		}

		response, err = fetchFn(ctxWithTimeout, blockID)
		if err != nil {
			log.Debug(
				"Fetch result from execution engine not found, keep retrying",
				"blockID", blockID,
				"error", err,
			)
			continue
		}

		return response, nil
	}

	return emptyResponse, fmt.Errorf("failed to fetch result from L2 execution engine, blockID: %d", blockID)
}

// waitBlock keeps polling the execution engine for the given block number.
func waitBlock(ctx context.Context, ethClient *EthClient, blockID *big.Int) (*types.Block, error) {
	return waitForFetchResult(ctx, blockID, ethClient.BlockByNumber)
}

// waitHeader keeps polling the execution engine for the given block header.
func waitHeader(ctx context.Context, ethClient *EthClient, blockID *big.Int) (*types.Header, error) {
	return waitForFetchResult(ctx, blockID, ethClient.HeaderByNumber)
}

// evaluateProposalSeal reports whether proposalID's last block is sealed (the whole
// proposal has been landed by the driver). candidateL1Origin is the candidate last block's
// L1Origin, or nil when the block was beacon-synced and has no L1Origin row. nextHeader is
// the header of the block right after the candidate, or nil when it does not exist yet.
func evaluateProposalSeal(
	proposalID *big.Int,
	candidateL1Origin *rawdb.L1Origin,
	nextHeader *types.Header,
) (bool, error) {
	// A preconfirmation candidate (L1Origin present with zero/empty L1 block height) means
	// the driver has not landed the proposal yet; preconfirmation blocks must never be proven.
	if candidateL1Origin != nil &&
		(candidateL1Origin.L1BlockHeight == nil || candidateL1Origin.L1BlockHeight.Cmp(common.Big0) == 0) {
		return false, nil
	}

	// The boundary is sealed only once the next block exists and belongs to a newer proposal.
	if nextHeader == nil {
		return false, nil
	}

	nextProposalID, err := core.DecodeShastaProposalID(nextHeader.Extra)
	if err != nil {
		return false, err
	}
	return nextProposalID.Cmp(proposalID) > 0, nil
}

// WaitProposalHeader keeps waiting for the proposal block header of the given proposal ID from the L2 execution engine.
func (c *Client) WaitProposalHeader(ctx context.Context, proposalID *big.Int) (*types.Header, error) {
	var (
		ctxWithTimeout = ctx
		cancel         context.CancelFunc
	)

	ticker := time.NewTicker(rpcPollingInterval)
	defer ticker.Stop()

	if _, ok := ctx.Deadline(); !ok {
		ctxWithTimeout, cancel = context.WithTimeout(ctx, defaultWaitTimeout)
		defer cancel()
	}

	log.Debug("Start fetching block header from L2 execution engine", "proposalID", proposalID)

	for ; true; <-ticker.C {
		if ctxWithTimeout.Err() != nil {
			return nil, ctxWithTimeout.Err()
		}

		// Resolve the candidate last block of the proposal from block extraData. This works for
		// both event-synced and beacon-synced blocks and never returns a preconfirmation block.
		candidateID, err := c.ProposalLastBlockID(ctxWithTimeout, proposalID)
		if err != nil {
			log.Debug(
				"Resolve proposal last block ID not ready, keep retrying",
				"proposalID", proposalID,
				"error", err,
			)
			continue
		}

		// Fetch the candidate's L1Origin: absent (NotFound) for beacon-synced blocks, present
		// with zero L1 height for preconfirmation blocks.
		candidateL1Origin, err := c.L2.L1OriginByID(ctxWithTimeout, candidateID)
		if err != nil && err.Error() != ethereum.NotFound.Error() {
			log.Debug(
				"Fetch candidate L1Origin failed, keep retrying",
				"proposalID", proposalID,
				"blockID", candidateID,
				"error", err,
			)
			continue
		}

		// Fetch the header right after the candidate to confirm the proposal boundary.
		nextHeader, err := c.L2.HeaderByNumber(ctxWithTimeout, new(big.Int).Add(candidateID, common.Big1))
		if err != nil && err.Error() != ethereum.NotFound.Error() {
			log.Debug(
				"Fetch boundary header failed, keep retrying",
				"proposalID", proposalID,
				"blockID", candidateID,
				"error", err,
			)
			continue
		}

		sealed, err := evaluateProposalSeal(proposalID, candidateL1Origin, nextHeader)
		if err != nil {
			return nil, err
		}
		if !sealed {
			continue
		}

		return c.L2.HeaderByNumber(ctxWithTimeout, candidateID)
	}

	return nil, fmt.Errorf("failed to fetch block header from L2 execution engine, proposalID: %d", proposalID)
}

// GetPoolContent fetches the transactions list from L2 execution engine's transactions pool with given
// upper limit.
func (c *Client) GetPoolContent(
	ctx context.Context,
	beneficiary common.Address,
	blockMaxGasLimit uint32,
	maxBytesPerTxList uint64,
	locals []common.Address,
	maxTransactionsLists uint64,
	minTip uint64,
) ([]*miner.PreBuiltTxList, error) {
	ctxWithTimeout, cancel := CtxWithTimeoutOrDefault(ctx, DefaultRpcTimeout)
	defer cancel()

	l2Head, err := c.L2.HeaderByNumber(ctx, nil)
	if err != nil {
		return nil, err
	}

	baseFee, err := c.CalculateBaseFee(ctx, l2Head)
	if err != nil {
		return nil, err
	}

	var localsArg []string
	for _, local := range locals {
		localsArg = append(localsArg, local.Hex())
	}

	return c.L2Engine.TxPoolContentWithMinTip(
		ctxWithTimeout,
		beneficiary,
		baseFee,
		uint64(blockMaxGasLimit),
		maxBytesPerTxList,
		localsArg,
		maxTransactionsLists,
		minTip,
	)
}

// L2AccountNonce fetches the nonce of the given L2 account at a specified height.
func (c *Client) L2AccountNonce(
	ctx context.Context,
	account common.Address,
	blockHash common.Hash,
) (uint64, error) {
	ctxWithTimeout, cancel := CtxWithTimeoutOrDefault(ctx, DefaultRpcTimeout)
	defer cancel()

	var result hexutil.Uint64
	return uint64(result), c.L2.CallContext(
		ctxWithTimeout,
		&result,
		"eth_getTransactionCount",
		account,
		rpc.BlockNumberOrHashWithHash(blockHash, false),
	)
}

// L2SyncProgress represents the sync progress of a L2 execution engine. `ethereum.SyncProgress` is used to check
// the sync progress of verified proposals, and block IDs are used to check the sync progress of pending proposals.
type L2SyncProgress struct {
	*ethereum.SyncProgress
	CurrentBlockID       *big.Int
	HighestOriginBlockID *big.Int
}

// IsSyncing returns true if the L2 execution engine is syncing with L1.
func (p *L2SyncProgress) IsSyncing() bool {
	if p.SyncProgress == nil {
		return false
	}

	if p.CurrentBlockID == nil || p.HighestOriginBlockID == nil {
		return true
	}

	return p.CurrentBlockID.Cmp(p.HighestOriginBlockID) < 0
}

// L2ExecutionEngineSyncProgress fetches the sync progress of the given L2 execution engine.
func (c *Client) L2ExecutionEngineSyncProgress(ctx context.Context) (*L2SyncProgress, error) {
	ctxWithTimeout, cancel := CtxWithTimeoutOrDefault(ctx, DefaultRpcTimeout)
	defer cancel()

	var (
		progress = new(L2SyncProgress)
		err      error
	)
	g, ctx := errgroup.WithContext(ctxWithTimeout)

	g.Go(func() error {
		progress.SyncProgress, err = c.L2.SyncProgress(ctx)
		return err
	})
	g.Go(func() error {
		coreState, err := c.GetCoreState(&bind.CallOpts{Context: ctx})
		if err != nil {
			return err
		}

		// If the next proposal ID is 1, it means there is no proposal on L2 yet.
		if coreState.NextProposalId.Cmp(common.Big1) <= 0 {
			progress.HighestOriginBlockID = common.Big0
			return nil
		}

		l1Origin, err := c.L2Engine.LastL1OriginByBatchID(
			ctx,
			new(big.Int).Sub(coreState.NextProposalId, common.Big1),
		)
		if err != nil &&
			err.Error() != ethereum.NotFound.Error() &&
			err.Error() != eth.ErrProposalLastBlockUncertain.Error() {
			return err
		}
		// If the L1Origin is not found, it means the L2 execution engine has not synced yet,
		// we set the highest origin block ID to max uint64.
		if l1Origin == nil {
			progress.HighestOriginBlockID = new(big.Int).SetUint64(^uint64(0)) // Max uint64
			return nil
		}
		progress.HighestOriginBlockID = l1Origin.BlockID

		return nil
	})
	g.Go(func() error {
		headL1Origin, err := c.L2.HeadL1Origin(ctx)
		if err != nil {
			switch err.Error() {
			case ethereum.NotFound.Error():
				// There is only genesis block in the L2 execution engine, or it has not started
				// syncing pending proposals yet.
				progress.CurrentBlockID = common.Big0
				return nil
			default:
				return err
			}
		}
		progress.CurrentBlockID = headL1Origin.BlockID
		return nil
	})

	if err := g.Wait(); err != nil {
		return nil, err
	}

	return progress, nil
}

// ReorgCheckResult represents the information about whether the L1 block has been reorged
// and how to reset the L1 cursor.
type ReorgCheckResult struct {
	IsReorged                    bool
	L1CurrentToReset             *types.Header
	LastHandledProposalIDToReset *big.Int
}

// CheckL1Reorg checks whether the L2 block's corresponding L1 block has been reorged or not.
// We will skip the reorg check if:
//  1. When the L2 chain has just finished a P2P sync, so there is no L1Origin information recorded in
//     its local database, and we assume the last verified L2 block is old enough, so its corresponding
//     L1 block should have also been finalized.
//
// Then we will check:
// 1. If the L2 block's corresponding L1 block which in L1Origin has been reorged
// 2. If the L1 information which in the given L2 block's anchor transaction has been reorged
//
// And if a reorg is detected, we return a new L1 block cursor which need to reset to.
func (c *Client) CheckL1Reorg(ctx context.Context, proposalID *big.Int) (*ReorgCheckResult, error) {
	var (
		result                 = new(ReorgCheckResult)
		ctxWithTimeout, cancel = CtxWithTimeoutOrDefault(ctx, DefaultRpcTimeout)
		err                    error
	)
	defer cancel()

	if proposalID.Cmp(common.Big0) == 0 {
		result.IsReorged = true
		if result.L1CurrentToReset, err = c.GetGenesisL1Header(ctxWithTimeout); err != nil {
			return nil, err
		}
		return result, nil
	}

	for {
		// If we rollback to the genesis block, then there is no L1Origin information recorded in the L2 execution
		// engine for that batch, so we will query the protocol to use `GenesisHeight` value to reset the L1 cursor.
		if proposalID.Cmp(common.Big0) == 0 {
			result.IsReorged = true
			if result.L1CurrentToReset, err = c.GetGenesisL1Header(ctxWithTimeout); err != nil {
				return nil, err
			}

			return result, nil
		}

		// 1. Check whether the last L2 block's corresponding L1 block which in L1Origin has been reorged.
		l1Origin, err := c.L2Engine.LastL1OriginByBatchID(ctxWithTimeout, proposalID)
		if err != nil {
			// If the L2 EE is just synced through P2P, so there is no L1Origin information recorded in
			// its local database, we skip this check.
			if err.Error() == ethereum.NotFound.Error() || err.Error() == eth.ErrProposalLastBlockUncertain.Error() {
				log.Info("L1Origin not found, the L2 execution engine has just synced from P2P network", "proposalID", proposalID)
				return result, nil
			}

			return nil, err
		}

		// Compare the L1 header hash in the L1Origin with the current L1 header hash in the L1 chain.
		l1Header, err := c.L1.HeaderByNumber(ctxWithTimeout, l1Origin.L1BlockHeight)
		if err != nil {
			// We can not find the L1 header which in the L1Origin, which means that L1 block has been reorged.
			if err.Error() == ethereum.NotFound.Error() {
				result.IsReorged = true
				proposalID = new(big.Int).Sub(proposalID, common.Big1)
				continue
			}
			return nil, fmt.Errorf("failed to fetch L1 header (%d): %w", l1Origin.L1BlockHeight, err)
		}

		if l1Header.Hash() != l1Origin.L1BlockHash {
			log.Info(
				"Reorg detected",
				"proposalID", proposalID,
				"l1Height", l1Origin.L1BlockHeight,
				"l1HashOld", l1Origin.L1BlockHash,
				"l1HashNew", l1Header.Hash(),
			)
			proposalID = new(big.Int).Sub(proposalID, common.Big1)
			result.IsReorged = true
			continue
		}

		// 2. Check whether the L1 information which in the given L2 block's anchor transaction has been reorged.
		isSyncedL1SnippetInvalid, err := c.checkSyncedL1SnippetFromAnchor(
			ctxWithTimeout,
			l1Origin.BlockID,
			l1Origin.L1BlockHeight.Uint64(),
		)
		if err != nil {
			return nil, fmt.Errorf("failed to check L1 reorg from anchor transaction: %w", err)
		}
		if isSyncedL1SnippetInvalid {
			proposalID = new(big.Int).Sub(proposalID, common.Big1)
			result.IsReorged = true
			continue
		}

		result.L1CurrentToReset = l1Header
		result.LastHandledProposalIDToReset = proposalID
		break
	}

	log.Debug(
		"Check L1 reorg",
		"isReorged", result.IsReorged,
		"l1CurrentToResetNumber", result.L1CurrentToReset.Number,
		"l1CurrentToResetHash", result.L1CurrentToReset.Hash(),
		"proposalIDToReset", result.LastHandledProposalIDToReset,
	)

	return result, nil
}

// checkSyncedL1SnippetFromAnchor checks whether the L1 snippet synced from the anchor transaction is valid.
func (c *Client) checkSyncedL1SnippetFromAnchor(
	ctx context.Context,
	blockID *big.Int,
	l1Height uint64,
) (bool, error) {
	log.Debug("Check synced L1 snippet from anchor", "blockID", blockID, "l1Height", l1Height)
	block, err := c.L2.BlockByNumber(ctx, blockID)
	if err != nil {
		log.Error("Failed to fetch L2 block", "blockID", blockID, "error", err)
		return false, err
	}
	parent, err := c.L2.BlockByHash(ctx, block.ParentHash())
	if err != nil {
		log.Error("Failed to fetch L2 parent block", "blockID", blockID, "parentHash", block.ParentHash(), "error", err)
		return false, err
	}

	l1StateRoot, l1HeightInAnchor, parentGasUsed, err := c.GetSyncedL1SnippetFromAnchor(
		block.Transactions()[0],
	)
	if err != nil {
		log.Error("Failed to parse L1 snippet from anchor transaction", "blockID", blockID, "error", err)
		return false, err
	}

	if parentGasUsed != 0 && parentGasUsed != uint32(parent.GasUsed()) {
		log.Info(
			"Reorg detected due to parent gas used mismatch",
			"blockID", blockID,
			"parentGasUsedInAnchor", parentGasUsed,
			"parentGasUsed", parent.GasUsed(),
		)
		return true, nil
	}

	l1Header, err := c.L1.HeaderByNumber(ctx, new(big.Int).SetUint64(l1HeightInAnchor))
	if err != nil {
		log.Error("Failed to fetch L1 header", "blockID", blockID, "error", err)
		return false, err
	}

	if l1StateRoot != (common.Hash{}) && l1Header.Root != l1StateRoot {
		log.Info(
			"Reorg detected due to L1 state root mismatch",
			"blockID", blockID,
			"l1StateRootInAnchor", l1StateRoot,
			"l1StateRoot", l1Header.Root,
		)
		return true, nil
	}

	return false, nil
}

// ProposalLastBlockID returns the last L2 block number of the given proposal, resolved from
// block extraData so it works for both event-synced and beacon-synced proposals. Proposal 0
// (the genesis parent) resolves to block 0.
func (c *Client) ProposalLastBlockID(ctx context.Context, proposalID *big.Int) (*big.Int, error) {
	if proposalID.Cmp(common.Big0) == 0 {
		return common.Big0, nil
	}

	ctxWithTimeout, cancel := CtxWithTimeoutOrDefault(ctx, DefaultRpcTimeout)
	defer cancel()

	blockID, err := c.L2Engine.LastBlockIDByBatchID(ctxWithTimeout, proposalID)
	if err != nil {
		return nil, err
	}
	if blockID == nil {
		return nil, fmt.Errorf("last block ID not found for proposal ID %d", proposalID)
	}

	return blockID.ToInt(), nil
}

// GetSyncedL1SnippetFromAnchor parses the anchor transaction calldata, and returns the synced L1 snippet,
func (c *Client) GetSyncedL1SnippetFromAnchor(tx *types.Transaction) (
	l1StateRoot common.Hash,
	l1Height uint64,
	parentGasUsed uint32,
	err error,
) {
	var method *abi.Method
	if method, err = encoding.ShastaAnchorABI.MethodById(tx.Data()); err != nil {
		if method, err = encoding.TaikoAnchorABI.MethodById(tx.Data()); err != nil {
			return common.Hash{}, 0, 0, fmt.Errorf("failed to get anchor method by ID: %w", err)
		}
	}

	var ok bool
	switch method.Name {
	case "anchorV2", "anchorV3":
		args := map[string]interface{}{}

		if err := method.Inputs.UnpackIntoMap(args, tx.Data()[4:]); err != nil {
			return common.Hash{}, 0, 0, err
		}

		l1Height, ok = args["_anchorBlockId"].(uint64)
		if !ok {
			return common.Hash{},
				0,
				0,
				errors.New("failed to parse anchorBlockId from anchorV2 / anchorV3 transaction calldata")
		}
		l1StateRoot, ok = args["_anchorStateRoot"].([32]byte)
		if !ok {
			return common.Hash{},
				0,
				0,
				errors.New("failed to parse anchorStateRoot from anchorV2 / anchorV3 transaction calldata")
		}
		parentGasUsed, ok = args["_parentGasUsed"].(uint32)
		if !ok {
			return common.Hash{},
				0,
				0,
				errors.New("failed to parse parentGasUsed from anchorV2 / anchorV3 transaction calldata")
		}
	case "anchorV4":
		args := map[string]interface{}{}

		if err := method.Inputs.UnpackIntoMap(args, tx.Data()[4:]); err != nil {
			return common.Hash{}, 0, 0, err
		}

		checkpointParams, exists := args["_checkpoint"]
		if !exists {
			return common.Hash{},
				0,
				0,
				errors.New("anchor transaction calldata missing checkpoint params")
		}

		blockValue := reflect.ValueOf(checkpointParams)
		if blockValue.Kind() != reflect.Struct {
			return common.Hash{},
				0,
				0,
				errors.New("unexpected checkpoint params type in anchor transaction calldata")
		}

		blockNumberField := blockValue.FieldByName("BlockNumber")
		if !blockNumberField.IsValid() {
			return common.Hash{},
				0,
				0,
				errors.New("anchorBlockNumber field missing in anchor transaction calldata")
		}

		blockNumber, ok := blockNumberField.Interface().(*big.Int)
		if !ok {
			return common.Hash{},
				0,
				0,
				errors.New("failed to parse anchorBlockNumber from anchor transaction calldata")
		}
		l1Height = blockNumber.Uint64()

		stateRootField := blockValue.FieldByName("StateRoot")
		if !stateRootField.IsValid() {
			return common.Hash{},
				0,
				0,
				errors.New("anchorStateRoot field missing in anchor transaction calldata")
		}

		root, ok := stateRootField.Interface().([32]byte)
		if !ok {
			return common.Hash{},
				0,
				0,
				errors.New("failed to parse anchorStateRoot from anchor transaction calldata")
		}
		l1StateRoot = root
	default:
		return common.Hash{}, 0, 0, fmt.Errorf(
			"invalid method name for anchor / anchorV2 / anchorV3 / anchorV4 transaction: %s",
			method.Name,
		)
	}

	return l1StateRoot, l1Height, parentGasUsed, nil
}

// CalculateBaseFee calculates the base fee after fork from the L2 protocol.
func (c *Client) CalculateBaseFee(ctx context.Context, l2Head *types.Header) (*big.Int, error) {
	if l2Head.Number.Cmp(common.Big0) == 0 {
		return new(big.Int).SetUint64(params.ShastaInitialBaseFee), nil
	}

	// Calculate base fee according to EIP-4396.
	parentBlock, err := c.L2.HeaderByHash(ctx, l2Head.ParentHash)
	if err != nil {
		return nil, fmt.Errorf("failed to fetch parent block: %w", err)
	}
	shastaTime := uint64(0)
	config := &params.ChainConfig{ShastaTime: &shastaTime, ChainID: c.L2.ChainID}
	log.Info(
		"Params for base fee calculation",
		"parentBlockNumber", l2Head.Number,
		"parentGasLimit", l2Head.GasLimit,
		"parentGasUsed", l2Head.GasUsed,
		"parentBaseFee", l2Head.BaseFee,
		"parentTime", l2Head.Time-parentBlock.Time,
		"elasticityMultiplier", config.ElasticityMultiplier(),
		"baseFeeMaxChangeDenominator", config.BaseFeeChangeDenominator(),
		"chainID", config.ChainID,
	)
	return misc.CalcEIP4396BaseFee(config, l2Head, l2Head.Time-parentBlock.Time), nil
}

// GetActivationBlockNumber resolves the L1 block number when the inbox was activated.
func (c *Client) GetActivationBlockNumber(ctx context.Context) (*big.Int, error) {
	ctxWithTimeout, cancel := CtxWithTimeoutOrDefault(ctx, DefaultRpcTimeout)
	defer cancel()

	activationTimestamp, err := c.ShastaClients.Inbox.ActivationTimestamp(&bind.CallOpts{Context: ctxWithTimeout})
	if err != nil {
		return nil, fmt.Errorf("failed to fetch activation timestamp: %w", err)
	}

	// If activation timestamp is zero, returns zero block number.
	if activationTimestamp.Cmp(common.Big0) == 0 {
		return common.Big0, nil
	}

	// If L1 beacon is not set, we will find the execution block number by timestamp through binary search.
	if c.L1Beacon == nil {
		return c.findExecutionBlockNumberByTimestamp(ctxWithTimeout, activationTimestamp.Uint64())
	}

	return c.L1Beacon.ExecutionBlockNumberByTimestamp(ctxWithTimeout, activationTimestamp.Uint64())
}

// findExecutionBlockNumberByTimestamp finds the L1 block number with the largest timestamp that is smaller than or
// equal to the given timestamp.
func (c *Client) findExecutionBlockNumberByTimestamp(ctx context.Context, timestamp uint64) (*big.Int, error) {
	latestHeader, err := c.L1.HeaderByNumber(ctx, nil)
	if err != nil {
		return nil, fmt.Errorf("failed to fetch latest L1 header: %w", err)
	}
	if latestHeader.Time < timestamp {
		return nil, fmt.Errorf(
			"activation timestamp %d is greater than latest L1 timestamp %d",
			timestamp,
			latestHeader.Time,
		)
	}

	low, high := uint64(0), latestHeader.Number.Uint64()
	for low < high {
		mid := low + (high-low)/2

		header, err := c.L1.HeaderByNumber(ctx, new(big.Int).SetUint64(mid))
		if err != nil {
			return nil, fmt.Errorf("failed to fetch L1 header %d: %w", mid, err)
		}

		if header.Time >= timestamp {
			high = mid
		} else {
			low = mid + 1
		}
	}

	return new(big.Int).SetUint64(low), nil
}

// GetPreconfWhiteListOperator resolves the current preconfirmation whitelist operator address.
func (c *Client) GetPreconfWhiteListOperator(opts *bind.CallOpts) (common.Address, error) {
	if c.ShastaClients.PreconfWhitelist == nil {
		return common.Address{}, errors.New("preconfirmations whitelist contract is not set")
	}

	opts, cancel := prepCallOpts(opts)
	defer cancel()

	proposer, err := c.ShastaClients.PreconfWhitelist.GetOperatorForCurrentEpoch(opts)
	if err != nil {
		return common.Address{}, fmt.Errorf("failed to get preconfirmation whitelist operator: %w", err)
	}

	opInfo, err := c.ShastaClients.PreconfWhitelist.Operators(opts, proposer)
	if err != nil {
		return common.Address{}, fmt.Errorf("failed to get preconfirmation whitelist operator info: %w", err)
	}

	return opInfo.SequencerAddress, nil
}

// GetNextPreconfWhiteListOperator resolves the next preconfirmation whitelist operator address.
func (c *Client) GetNextPreconfWhiteListOperator(opts *bind.CallOpts) (common.Address, error) {
	if c.ShastaClients.PreconfWhitelist == nil {
		return common.Address{}, errors.New("preconfirmation whitelist contract is not set")
	}

	opts, cancel := prepCallOpts(opts)
	defer cancel()

	proposer, err := c.ShastaClients.PreconfWhitelist.GetOperatorForNextEpoch(opts)
	if err != nil {
		return common.Address{}, fmt.Errorf("failed to get preconfirmation whitelist operator: %w", err)
	}

	opInfo, err := c.ShastaClients.PreconfWhitelist.Operators(opts, proposer)
	if err != nil {
		return common.Address{}, fmt.Errorf("failed to get preconfirmation whitelist operator info: %w", err)
	}

	return opInfo.SequencerAddress, nil
}

// GetAllPreconfOperators fetch all possible preconfirmation operators added to the whitelist contract,
// regardless of whether they are active or not, or eligible for the current or next epoch.
func (c *Client) GetAllPreconfOperators(opts *bind.CallOpts) ([]common.Address, error) {
	if c.ShastaClients.PreconfWhitelist == nil {
		return nil, errors.New("preconfirmation whitelist contract is not set")
	}

	opts, cancel := prepCallOpts(opts)
	defer cancel()

	count, err := c.ShastaClients.PreconfWhitelist.OperatorCount(opts)
	if err != nil {
		return nil, fmt.Errorf("failed to get total preconfirmation whitelist operators: %w", err)
	}

	var operators []common.Address
	for i := uint8(0); i < count; i++ {
		operator, err := c.ShastaClients.PreconfWhitelist.OperatorMapping(opts, big.NewInt(int64(i)))
		if err != nil {
			return nil, fmt.Errorf("failed to get preconfirmation whitelist operator by index %d: %w", i, err)
		}
		operators = append(operators, operator)
	}

	return operators, nil
}

// GetAllActiveOperators fetch all active preconfirmation operators added to the whitelist contract.
func (c *Client) GetAllActiveOperators(opts *bind.CallOpts) ([]common.Address, error) {
	if c.ShastaClients.PreconfWhitelist == nil {
		return nil, errors.New("preconfirmation whitelist contract is not set")
	}

	opts, cancel := prepCallOpts(opts)
	defer cancel()

	// offset=0 returns the current epoch's start timestamp.
	currentEpochTimestamp, err := c.ShastaClients.PreconfWhitelist.EpochStartTimestamp(opts, big.NewInt(0))
	if err != nil {
		return nil, fmt.Errorf("failed to get current epoch timestamp: %w", err)
	}

	count, err := c.ShastaClients.PreconfWhitelist.OperatorCount(opts)
	if err != nil {
		return nil, fmt.Errorf("failed to get total preconfirmation whitelist operators: %w", err)
	}

	var operators []common.Address
	for i := 0; i < int(count); i++ {
		proposer, err := c.ShastaClients.PreconfWhitelist.OperatorMapping(opts, big.NewInt(int64(i)))
		if err != nil {
			return nil, fmt.Errorf("failed to get preconfirmation whitelist proposer by index %d: %w", i, err)
		}
		opInfo, err := c.ShastaClients.PreconfWhitelist.Operators(opts, proposer)
		if err != nil {
			return nil, fmt.Errorf("failed to get preconfirmation whitelist operator info: %w", err)
		}
		isActive, err := c.ShastaClients.PreconfWhitelist.IsOperatorActive(opts, proposer, currentEpochTimestamp)
		if err != nil {
			return nil, fmt.Errorf("failed to check if preconfirmation whitelist operator is active: %w", err)
		}
		if isActive {
			operators = append(operators, opInfo.SequencerAddress)
		}
	}

	return operators, nil
}

// GetProposalHash gets the proposal hash from the inbox contract.
func (c *Client) GetProposalHash(opts *bind.CallOpts, proposalID *big.Int) (common.Hash, error) {
	opts, cancel := prepCallOpts(opts)
	defer cancel()

	return c.ShastaClients.Inbox.GetProposalHash(opts, proposalID)
}

// GetAnchorState gets the anchor state from Anchor contract.
func (c *Client) GetAnchorState(opts *bind.CallOpts) (
	*shastaBindings.AnchorBlockState,
	error,
) {
	opts, cancel := prepCallOpts(opts)
	defer cancel()

	blockState, err := c.ShastaClients.Anchor.GetBlockState(opts)
	if err != nil {
		return nil, fmt.Errorf("failed to get anchor block state: %w", err)
	}

	return &blockState, nil
}

// GetInboxConfigs gets the inbox contract configurations.
func (c *Client) GetInboxConfigs(opts *bind.CallOpts) (*shastaBindings.IInboxConfig, error) {
	opts, cancel := prepCallOpts(opts)
	defer cancel()

	cfg, err := c.ShastaClients.Inbox.GetConfig(opts)
	if err != nil {
		return nil, fmt.Errorf("failed to get the inbox config: %w", err)
	}

	return &cfg, nil
}

// GetCoreState gets the core state from the inbox contract.
func (c *Client) GetCoreState(opts *bind.CallOpts) (*shastaBindings.IInboxCoreState, error) {
	opts, cancel := prepCallOpts(opts)
	defer cancel()

	state, err := c.ShastaClients.Inbox.GetCoreState(opts)
	if err != nil {
		return nil, fmt.Errorf("failed to get the inbox core state: %w", err)
	}

	return &state, nil
}

// GetProposalByID gets the proposal by ID from the inbox contract.
func (c *Client) GetProposalByID(
	ctx context.Context,
	proposalID *big.Int,
) (*shastaBindings.ShastaInboxClientProposed, *types.Log, error) {
	ctxWithTimeout, cancel := CtxWithTimeoutOrDefault(ctx, DefaultRpcTimeout)
	defer cancel()

	blockID, err := c.L2Engine.LastBlockIDByBatchID(ctxWithTimeout, proposalID)
	if err != nil {
		return nil, nil, fmt.Errorf("failed to get last block ID for proposal %d: %w", proposalID, err)
	}

	block, err := c.L2.BlockByNumber(ctxWithTimeout, blockID.ToInt())
	if err != nil {
		return nil, nil, fmt.Errorf("failed to get L2 block by ID %d: %w", blockID.ToInt(), err)
	}

	_, anchorNumber, _, err := c.GetSyncedL1SnippetFromAnchor(block.Transactions()[0])
	if err != nil {
		return nil, nil, fmt.Errorf("failed to get synced L1 snippet from anchor transaction: %w", err)
	}

	iter, err := c.ShastaClients.Inbox.FilterProposed(&bind.FilterOpts{
		Start:   anchorNumber,
		Context: ctxWithTimeout,
	}, []*big.Int{proposalID}, nil)
	if err != nil {
		return nil, nil, fmt.Errorf("failed to filter proposed events from the inbox: %w", err)
	}
	defer iter.Close()

	var (
		event *shastaBindings.ShastaInboxClientProposed
		log   *types.Log
	)
	for iter.Next() {
		if iter.Event.Id.Cmp(proposalID) != 0 {
			continue
		}
		event = iter.Event
		log = &iter.Event.Raw
	}
	if event == nil || log == nil {
		return nil, nil, fmt.Errorf("proposal event not found for ID %d", proposalID)
	}

	return event, log, nil
}

// EncodeProveInput encodes the prove method input using the inbox contract.
func (c *Client) EncodeProveInput(opts *bind.CallOpts, input *shastaBindings.IInboxProveInput) ([]byte, error) {
	opts, cancel := prepCallOpts(opts)
	defer cancel()

	return c.ShastaClients.Inbox.EncodeProveInput(opts, *input)
}

// EncodeProposeInput encodes the propose method input using the inbox contract.
func (c *Client) EncodeProposeInput(opts *bind.CallOpts, input *shastaBindings.IInboxProposeInput) ([]byte, error) {
	opts, cancel := prepCallOpts(opts)
	defer cancel()

	return c.ShastaClients.Inbox.EncodeProposeInput(opts, *input)
}

// DecodeProposeInput decodes the propose method input using the inbox contract.
func (c *Client) DecodeProposeInput(opts *bind.CallOpts, data []byte) (*shastaBindings.IInboxProposeInput, error) {
	opts, cancel := prepCallOpts(opts)
	defer cancel()

	input, err := c.ShastaClients.Inbox.DecodeProposeInput(opts, data)
	if err != nil {
		return nil, err
	}

	return &input, nil
}
