// 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 execmodule

import (
	"context"
	"errors"
	"fmt"
	"math"
	"runtime"
	"strconv"
	"sync"
	"time"

	"github.com/erigontech/erigon/common"
	"github.com/erigontech/erigon/common/dbg"
	"github.com/erigontech/erigon/common/log/v3"
	"github.com/erigontech/erigon/db/consensuschain"
	"github.com/erigontech/erigon/db/kv"
	"github.com/erigontech/erigon/db/kv/rawdbv3"
	"github.com/erigontech/erigon/db/rawdb"
	"github.com/erigontech/erigon/db/rawdb/rawtemporaldb"
	dbstate "github.com/erigontech/erigon/db/state"
	"github.com/erigontech/erigon/db/state/execctx"
	"github.com/erigontech/erigon/execution/commitment/commitmentdb"
	"github.com/erigontech/erigon/execution/metrics"
	"github.com/erigontech/erigon/execution/protocol/rules"
	"github.com/erigontech/erigon/execution/stagedsync"
	"github.com/erigontech/erigon/execution/stagedsync/stages"
	"github.com/erigontech/erigon/execution/types"
)

type forkchoiceOutcome struct {
	result ForkChoiceResult
	err    error
}

func sendForkchoiceResultWithoutWaiting(ch chan forkchoiceOutcome, result ForkChoiceResult, alreadySent bool) error {
	if alreadySent {
		return nil
	}
	select {
	case ch <- forkchoiceOutcome{result: result}:
	default:
	}
	return nil
}

func sendForkchoiceErrorWithoutWaiting(logger log.Logger, ch chan forkchoiceOutcome, err error, alreadySent bool) error {
	if alreadySent {
		logger.Warn("forkchoice: error received after result was sent", "error", err)
		return fmt.Errorf("duplicate fcu error: %w", err)
	}
	select {
	case ch <- forkchoiceOutcome{err: err}:
	default:
	}
	return err
}

// verifyForkchoiceHashes verifies the finalized and safe hash of the forkchoice state
func (e *ExecModule) verifyForkchoiceHashes(ctx context.Context, tx kv.Tx, blockHash, finalizedHash, safeHash common.Hash) (bool, error) {
	// Client software MUST return -38002: Invalid forkchoice state error if the payload referenced by
	// forkchoiceState.headBlockHash is VALID and a payload referenced by either forkchoiceState.finalizedBlockHash or
	// forkchoiceState.safeBlockHash does not belong to the chain defined by forkchoiceState.headBlockHash
	headNumber, err := e.blockReader.HeaderNumber(ctx, tx, blockHash)
	if err != nil {
		return false, err
	}
	finalizedNumber, err := e.blockReader.HeaderNumber(ctx, tx, finalizedHash)
	if err != nil {
		return false, err
	}
	safeNumber, err := e.blockReader.HeaderNumber(ctx, tx, safeHash)
	if err != nil {
		return false, err
	}

	if finalizedHash != (common.Hash{}) && finalizedHash != blockHash {
		canonical, err := e.isCanonicalHash(ctx, tx, finalizedHash)
		if err != nil {
			return false, err
		}
		if !canonical || *headNumber <= *finalizedNumber {
			return false, nil
		}

	}
	if safeHash != (common.Hash{}) && safeHash != blockHash {
		canonical, err := e.isCanonicalHash(ctx, tx, safeHash)
		if err != nil {
			return false, err
		}

		if !canonical || *headNumber <= *safeNumber {
			return false, nil
		}
	}
	return true, nil
}

func (e *ExecModule) UpdateForkChoice(ctx context.Context, headHash, safeHash, finalizedHash common.Hash) (ForkChoiceResult, error) {
	outcomeCh := make(chan forkchoiceOutcome, 1)

	// Spawn the actual forkchoice work using the module's background context so
	// it is not cancelled when the caller's context times out. We return as soon
	// as the result lands on outcomeCh — for a merge-extending fork at tip the
	// result is sent before flush/commit, so the consensus client is not blocked
	// on the EL commit. The semaphore is released — by the forkchoice goroutine, or
	// by the background commit/prune goroutine it hands off to — only after the FCU
	// cleanup has run, so any follow-up op (AssembleBlock, next FCU) that acquires
	// the semaphore observes fully-settled state.
	go func() {
		if err := e.updateForkChoice(e.backgroundCtx, headHash, safeHash, finalizedHash, outcomeCh); err != nil {
			e.logger.Debug("updateforkchoice failed", "err", err)
		}
	}()

	select {
	case outcome := <-outcomeCh:
		return outcome.result, outcome.err
	case <-ctx.Done():
		if ctx.Err() == context.DeadlineExceeded {
			e.logger.Debug("treating forkChoiceUpdated as asynchronous as it is taking too long")
			return ForkChoiceResult{Status: ExecutionStatusBusy}, nil
		}
		e.logger.Debug("forkChoiceUpdate cancelled")
		return ForkChoiceResult{}, ctx.Err()
	}
}

func writeForkChoiceHashes(tx kv.RwTx, blockHash, safeHash, finalizedHash common.Hash) {
	if finalizedHash != (common.Hash{}) {
		rawdb.WriteForkchoiceFinalized(tx, finalizedHash)
	}
	if safeHash != (common.Hash{}) {
		rawdb.WriteForkchoiceSafe(tx, safeHash)
	}
	rawdb.WriteHeadBlockHash(tx, blockHash)
	rawdb.WriteForkchoiceHead(tx, blockHash)
}

type canonicalEntry struct {
	hash   common.Hash
	number uint64
}

func (e *ExecModule) unwindIfNeeded(
	ctx context.Context,
	tx kv.TemporalRwTx,
	currentContext *execctx.SharedDomains,
	fcuHeader *types.Header,
	blockHash common.Hash,
	safeHash common.Hash,
	finalizedHash common.Hash,
	canonicalHash common.Hash,
	finishProgressBefore uint64,
	isSynced bool,
) (*ForkChoiceResult, error) {
	var finalisedBlockNum uint64
	lastKnownFinalisedHash := rawdb.ReadForkchoiceFinalized(tx)
	if lastKnownFinalisedHash != (common.Hash{}) {
		bn, err := e.blockReader.HeaderNumber(ctx, tx, lastKnownFinalisedHash)
		if err != nil {
			return nil, err
		}
		if bn == nil {
			return &ForkChoiceResult{
				LatestValidHash: common.Hash{},
				Status:          ExecutionStatusInvalidForkchoice,
			}, nil
		}
		finalisedBlockNum = *bn
	}
	// as per https://github.com/ethereum/execution-apis/pull/786
	// we short circuit reorgs if:
	//   1. the head is an ancestor of the last finalised block
	//   2. the head is a duplicate FCU (e.g. CLs sending the same FCU repeatedly)
	if fcuHeader.Number.Sign() > 0 && canonicalHash == blockHash &&
		(fcuHeader.Number.Uint64() < finalisedBlockNum || fcuHeader.Number.Uint64() == finishProgressBefore) {
		writeForkChoiceHashes(tx, blockHash, safeHash, finalizedHash)
		valid, err := e.verifyForkchoiceHashes(ctx, tx, blockHash, finalizedHash, safeHash)
		if err != nil {
			return nil, err
		}
		if !valid {
			return &ForkChoiceResult{
				LatestValidHash: common.Hash{},
				Status:          ExecutionStatusInvalidForkchoice,
			}, nil
		}
		return &ForkChoiceResult{
			LatestValidHash: blockHash,
			Status:          ExecutionStatusSuccess,
		}, nil
	}
	if fcuHeader.Number.Sign() == 0 && canonicalHash != blockHash {
		return &ForkChoiceResult{
			LatestValidHash: rawdb.ReadHeadBlockHash(tx),
			Status:          ExecutionStatusBadBlock,
			ValidationError: "forkchoice head is a non-genesis block at height 0",
		}, nil
	}
	// Find the canonical reconnection point, and collect all hashes on the way
	newCanonicals := make([]*canonicalEntry, 0, 64)
	newCanonicals = append(newCanonicals, &canonicalEntry{
		hash:   fcuHeader.Hash(),
		number: fcuHeader.Number.Uint64(),
	})
	var currentParentNumber uint64
	if fcuHeader.Number.Sign() > 0 {
		currentParentHash := fcuHeader.ParentHash
		currentParentNumber = fcuHeader.Number.Uint64() - 1
		isCanonicalHash, err := e.isCanonicalHash(ctx, tx, currentParentHash)
		if err != nil {
			return nil, err
		}
		for !isCanonicalHash {
			newCanonicals = append(newCanonicals, &canonicalEntry{
				hash:   currentParentHash,
				number: currentParentNumber,
			})
			currentHeader, err := e.blockReader.Header(ctx, tx, currentParentHash, currentParentNumber)
			if err != nil {
				return nil, err
			}
			if currentHeader == nil {
				return &ForkChoiceResult{
					LatestValidHash: common.Hash{},
					Status:          ExecutionStatusMissingSegment,
				}, nil
			}
			currentParentHash = currentHeader.ParentHash
			if currentHeader.Number.Sign() == 0 {
				panic("assert:uint64 underflow") //uint-underflow
			}
			currentParentNumber = currentHeader.Number.Uint64() - 1
			isCanonicalHash, err = e.isCanonicalHash(ctx, tx, currentParentHash)
			if err != nil {
				return nil, err
			}
		}
	}
	unwindTarget := currentParentNumber
	// Determine current canonical tip from TxNums. If unwindTarget is at or
	// above the canonical tip, there's nothing above to roll back — skip the
	// unwind path entirely and proceed straight to forward-filling new
	// canonicals. This is the recovery path when chaindata canonical lags
	// state's commitBlock (e.g. after a snapshot/state misalignment), where
	// the conservative minUnwindable check would otherwise reject the FCU
	// as ReorgTooDeep even though no state actually needs unwinding.
	lastCanonicalBlock, _, errLast := rawdbv3.TxNums.Last(tx)
	if errLast != nil {
		return nil, errLast
	}
	if unwindTarget < lastCanonicalBlock {
		minUnwindableBlock, err := rawtemporaldb.CanUnwindToBlockNum(tx)
		if err != nil {
			return nil, err
		}
		if unwindTarget < minUnwindableBlock {
			e.logger.Warn("reorg target below minimum unwindable block", "unwindTarget", unwindTarget, "minUnwindableBlock", minUnwindableBlock)
			return &ForkChoiceResult{
				LatestValidHash: common.Hash{},
				Status:          ExecutionStatusReorgTooDeep,
			}, nil
		}
		if err := e.pipelineExecutor.UnwindTo(unwindTarget, stagedsync.ForkChoice, tx); err != nil {
			return nil, err
		}
		if err = e.hook.BeforeRun(tx, isSynced); err != nil {
			return nil, err
		}
		// Run the unwind
		if err := e.pipelineExecutor.RunUnwind(currentContext, tx); err != nil {
			err = fmt.Errorf("updateForkChoice: %w", err)
			return nil, err
		}
	}
	// SD.Unwind (inside RunUnwind) tx-aware-invalidates the BranchCache by
	// the unwound txNum, so no whole-cache clear is needed here.
	if fcuHeader.Number.Sign() > 0 {
		UpdateForkChoiceDepth(fcuHeader.Number.Uint64() - 1 - unwindTarget)
	}
	if err := rawdbv3.TxNums.Truncate(tx, currentParentNumber+1); err != nil {
		return nil, err
	}
	// Mark all new canonicals as canonicals
	chainReader := consensuschain.NewReader(e.config, tx, e.blockReader, e.logger)
	for _, canonicalSegment := range newCanonicals {
		b, _, _ := rawdb.ReadBody(tx, canonicalSegment.hash, canonicalSegment.number)
		h := rawdb.ReadHeader(tx, canonicalSegment.hash, canonicalSegment.number)
		if b == nil || h == nil {
			return nil, fmt.Errorf("unexpected chain cap: %d", canonicalSegment.number)
		}
		if canonicalSegment.number > 0 {
			if err := e.engine.VerifyHeader(chainReader, h, true); err != nil {
				return nil, err
			}
			if err := e.engine.VerifyUncles(chainReader, h, b.Uncles); err != nil {
				return nil, err
			}
		}
		if err := rawdb.WriteCanonicalHash(tx, canonicalSegment.hash, canonicalSegment.number); err != nil {
			return nil, err
		}
	}
	if len(newCanonicals) > 0 {
		if err := rawdbv3.TxNums.Truncate(tx, newCanonicals[0].number); err != nil {
			return nil, err
		}
		// make sure we truncate any previous canonical hashes that go beyond the current head height
		// so that AppendCanonicalTxNums does not mess up the txNums index
		if err := rawdb.TruncateCanonicalHash(tx, newCanonicals[0].number+1, false); err != nil {
			return nil, err
		}
		if err := rawdb.AppendCanonicalTxNums(tx, newCanonicals[len(newCanonicals)-1].number); err != nil {
			return nil, err
		}
	}
	return nil, nil
}

func (e *ExecModule) updateForkChoice(ctx context.Context, originalBlockHash, safeHash, finalizedHash common.Hash, outcomeCh chan forkchoiceOutcome) (err error) {
	if !e.semaphore.TryAcquire(1) {
		e.logger.Trace("ethereumExecutionModule.updateForkChoice: ExecutionStatus_Busy")
		sendForkchoiceResultWithoutWaiting(outcomeCh, ForkChoiceResult{
			LatestValidHash: common.Hash{},
			Status:          ExecutionStatusBusy,
		}, false)
		return fmt.Errorf("semaphore timeout")
	}
	shouldReleaseSema := true
	defer func() {
		if shouldReleaseSema {
			e.semaphore.Release(1)
		}
	}()

	defer UpdateForkChoiceDuration(time.Now())
	// The next semaphore acquirer must observe settled state, so the bg-commit/
	// bg-prune paths run this eagerly before handing the semaphore to their goroutine.
	cleanupBeforeSemaRelease := sync.OnceFunc(func() {
		e.currentContext.ResetPendingUpdates()
		e.forkValidator.ClearWithUnwind()
	})
	defer cleanupBeforeSemaRelease()

	resumeReadAhead, err := e.suspendReadAhead(ctx)
	if err != nil {
		return sendForkchoiceErrorWithoutWaiting(e.logger, outcomeCh, fmt.Errorf("suspend read-ahead: %w", err), false)
	}
	defer resumeReadAhead()

	var validationError string

	// Open a RO tx as the base for all reads. Writes accumulate in the block
	// overlay (MemoryMutation) which implements kv.TemporalRwTx. No MDBX write
	// lock is held during pipeline execution — a brief RwTx is opened only at
	// commit time to flush everything atomically.
	roTx, err := e.db.BeginTemporalRo(ctx) //nolint:gocritic // deferred via closure below (bg-commit path sets roTx=nil after ownership transfer)
	if err != nil {
		return sendForkchoiceErrorWithoutWaiting(e.logger, outcomeCh, err, false)
	}
	defer func() {
		if roTx != nil {
			roTx.Rollback()
		}
	}() // closure: CommitCycle may reassign roTx; bg-commit path sets it to nil after ownership transfer

	// Check if InsertBlocks already created a block overlay with data
	// (headers, bodies, TDs, canonical hashes).
	var hasOverlay bool
	if e.currentContext != nil && e.currentContext.BlockOverlay() != nil {
		hasOverlay = true
	}

	// Create SharedDomains on the raw RO tx. Domain state (accounts,
	// storage, code, commitment) is read from the committed DB state.
	var isDomainAheadOfBlocks bool
	currentContext, err := execctx.NewSharedDomains(ctx, roTx, e.logger)
	if err != nil {
		// we handle isDomainAheadOfBlocks=true after AppendCanonicalTxNums to allow the node to catch up
		isDomainAheadOfBlocks = errors.Is(err, commitmentdb.ErrBehindCommitment)
		if !isDomainAheadOfBlocks {
			return sendForkchoiceErrorWithoutWaiting(e.logger, outcomeCh, err, false)
		}
	}
	if currentContext != nil {
		currentContext.SetInMemHistoryReads(inMemHistoryReads)
		// Wire the state cache so canonical execution reads benefit from
		// the per-execution Account/Storage/Code cache. Previously only
		// ValidateChain (fork validation, exec_module.go) set this, leaving
		// the canonical execution path running uncached against the aggTx.
		currentContext.SetStateCache(e.stateCache)
		currentContext.SetCodeStore(e.codeStore)
	}

	// Clear the published overlay before closing the SD, so concurrent
	// readers (e.g. a second FCU calling GetHeaderByHash) don't access
	// a closed SharedDomains via Events.LatestSD().
	teardownOverlay := func() {
		if currentContext == nil {
			return
		}
		if dispatcher := e.pipelineExecutor.Dispatcher(); dispatcher != nil {
			dispatcher.PublishOverlay(nil)
		}
		currentContext.Close()
		currentContext = nil
	}
	defer teardownOverlay()

	// Initialize the SD's block overlay for table-level writes (canonical
	// hashes, stage progress, forkchoice markers, TxNums, etc.). All
	// pipeline reads cascade through the overlay to the RO tx; writes
	// stay in memory until commit.
	if err := currentContext.InitBlockOverlay(roTx, roTx.Debug().Dirs().Tmp); err != nil {
		return sendForkchoiceErrorWithoutWaiting(e.logger, outcomeCh, fmt.Errorf("updateForkChoice: init block overlay: %w", err), false)
	}
	var tx kv.TemporalRwTx = currentContext.BlockOverlay()

	// If InsertBlocks wrote block data, flush it into the block overlay
	// so the pipeline can see it. The InsertBlocks overlay retains its data.
	if hasOverlay {
		e.currentContext.BlockOverlay().UpdateTxn(roTx)
		if err := e.currentContext.BlockOverlay().Flush(ctx, tx); err != nil {
			return sendForkchoiceErrorWithoutWaiting(e.logger, outcomeCh, fmt.Errorf("updateForkChoice: flush block overlay: %w", err), false)
		}
	}

	blockHash := originalBlockHash

	// Step one, find reconnection point, and mark all of those headers as canonical.
	fcuHeader, err := e.blockReader.HeaderByHash(ctx, tx, originalBlockHash)
	if err != nil {
		return sendForkchoiceErrorWithoutWaiting(e.logger, outcomeCh, err, false)
	}
	if fcuHeader == nil {
		return sendForkchoiceErrorWithoutWaiting(e.logger, outcomeCh, fmt.Errorf("forkchoice: block %x not found or was marked invalid", blockHash), false)
	}

	e.hook.LastNewBlockSeen(fcuHeader.Number.Uint64()) // used by eth_syncing

	finishProgressBefore, err := stages.GetStageProgress(tx, stages.Finish)
	if err != nil {
		return sendForkchoiceErrorWithoutWaiting(e.logger, outcomeCh, err, false)
	}
	headersProgressBefore, err := stages.GetStageProgress(tx, stages.Headers)
	if err != nil {
		return sendForkchoiceErrorWithoutWaiting(e.logger, outcomeCh, err, false)
	}

	e.logger.Debug("[execmodule] updating fork choice", "number", fcuHeader.Number.Uint64(), "hash", fcuHeader.Hash())
	UpdateForkChoiceArrivalDelay(fcuHeader.Time)
	metrics.UpdateBlockConsumerPreExecutionDelay(fcuHeader.Time, fcuHeader.Number.Uint64(), e.logger)
	defer metrics.UpdateBlockConsumerPostExecutionDelay(fcuHeader.Time, fcuHeader.Number.Uint64(), e.logger)

	var limitedBigJump bool
	limitedBigJumpPadding := uint64(2)
	if e.syncCfg.LoopBlockLimit > 0 && fcuHeader.Number.Uint64() > finishProgressBefore {
		// note fcuHeader.Number.Uint64() may be < finishProgressBefore - protect from underflow by checking it is >
		limitedBigJump = (fcuHeader.Number.Uint64()-finishProgressBefore)+limitedBigJumpPadding > uint64(e.syncCfg.LoopBlockLimit)
	}

	isSynced := finishProgressBefore > 0 && finishProgressBefore > e.blockReader.FrozenBlocks() && finishProgressBefore == headersProgressBefore
	if limitedBigJump {
		isSynced = false
		e.logger.Info("[sync] limited big jump", "from", finishProgressBefore, "to", fcuHeader.Number.Uint64(), "amount", uint64(e.syncCfg.LoopBlockLimit), "padding", limitedBigJumpPadding)
	}

	canonicalHash, err := e.canonicalHash(ctx, tx, fcuHeader.Number.Uint64())
	if err != nil {
		return sendForkchoiceErrorWithoutWaiting(e.logger, outcomeCh, err, false)
	}

	result, err := e.unwindIfNeeded(ctx, tx, currentContext, fcuHeader, blockHash, safeHash, finalizedHash, canonicalHash, finishProgressBefore, isSynced)
	if err != nil {
		return sendForkchoiceErrorWithoutWaiting(e.logger, outcomeCh, err, false)
	}
	if result != nil {
		return sendForkchoiceResultWithoutWaiting(outcomeCh, *result, false)
	}
	if isDomainAheadOfBlocks {
		// Open a brief RwTx to flush accumulated overlay + SD state atomically.
		commitRwTx, err := e.db.BeginTemporalRw(ctx)
		if err != nil {
			return sendForkchoiceErrorWithoutWaiting(e.logger, outcomeCh, fmt.Errorf("isDomainAhead: begin rw: %w", err), false)
		}
		defer commitRwTx.Rollback()
		if err := currentContext.Commit(ctx, commitRwTx); err != nil {
			return sendForkchoiceErrorWithoutWaiting(e.logger, outcomeCh, err, false)
		}
		return sendForkchoiceResultWithoutWaiting(outcomeCh, ForkChoiceResult{
			LatestValidHash: common.Hash{},
			Status:          ExecutionStatusTooFarAway,
			ValidationError: "domain ahead of blocks",
		}, false)
	}

	// Set Progress for headers and bodies accordingly.
	if err := stages.SaveStageProgress(tx, stages.Headers, fcuHeader.Number.Uint64()); err != nil {
		return sendForkchoiceErrorWithoutWaiting(e.logger, outcomeCh, err, false)
	}
	if err := stages.SaveStageProgress(tx, stages.BlockHashes, fcuHeader.Number.Uint64()); err != nil {
		return sendForkchoiceErrorWithoutWaiting(e.logger, outcomeCh, err, false)
	}
	if err := stages.SaveStageProgress(tx, stages.Bodies, fcuHeader.Number.Uint64()); err != nil {
		return sendForkchoiceErrorWithoutWaiting(e.logger, outcomeCh, err, false)
	}
	if err = rawdb.WriteHeadHeaderHash(tx, blockHash); err != nil {
		return sendForkchoiceErrorWithoutWaiting(e.logger, outcomeCh, err, false)
	}

	mergeExtendingFork := blockHash == e.forkValidator.ExtendingForkHeadHash()
	stateFlushingInParallel := mergeExtendingFork && e.syncCfg.ParallelStateFlushing
	if mergeExtendingFork {
		e.logger.Debug("[updateForkchoice] Fork choice update: flushing in-memory state (built by previous newPayload)")
		if stateFlushingInParallel {
			// Send forkchoice early (We already know the fork is valid)
			sendForkchoiceResultWithoutWaiting(outcomeCh, ForkChoiceResult{
				LatestValidHash: blockHash,
				Status:          ExecutionStatusSuccess,
				ValidationError: validationError,
			}, false)
			e.logHeadUpdated(blockHash, fcuHeader, 0, "head validated", false)
		}
		if err := e.forkValidator.MergeExtendingFork(ctx, tx, currentContext, e.accum); err != nil {
			return sendForkchoiceErrorWithoutWaiting(e.logger, outcomeCh, err, stateFlushingInParallel)
		}
		rawdb.WriteHeadBlockHash(tx, blockHash)
	}
	// Run the forkchoice.
	// TODO: rename initialCycle → atTip (inverted polarity) across stage/prune APIs.
	const smallBlockJumpThreshold = 16
	headNum := fcuHeader.Number.Uint64()
	initialCycle := headNum > finishProgressBefore && headNum-finishProgressBefore > smallBlockJumpThreshold

	tx, currentContext, err = e.pipelineExecutor.RunLoop(ctx, currentContext, tx, RunLoopConfig{
		InitialCycle: initialCycle,
		PruneFn: func(ctx context.Context, initialCycle bool, rwtx kv.TemporalRwTx, sd *execctx.SharedDomains) error {
			// Chain tip (!initialCycle): no in-loop work — post-RunLoop path
			// handles flush+commit+prune.
			if !initialCycle {
				return nil
			}
			// Catchup: drain the agg-level collation+prune on its own RW tx.
			roTx.Rollback()
			if _, pruneErr := e.runForkchoicePrune(true); pruneErr != nil && !errors.Is(pruneErr, context.Canceled) {
				e.logger.Warn("[commit-cycle] prune failed", "err", pruneErr)
			}
			return nil
		},
		CommitCycle: func(ctx context.Context, hasMore bool, sd *execctx.SharedDomains) (kv.TemporalRwTx, *execctx.SharedDomains, error) {
			// Chain tip: post-RunLoop path commits.
			if !initialCycle {
				return nil, nil, nil
			}
			commitRwTx, err := e.db.BeginTemporalRw(ctx)
			if err != nil {
				return nil, nil, fmt.Errorf("updateForkChoice: begin rw after hasMore: %w", err)
			}
			defer commitRwTx.Rollback() // idempotent after a successful Commit
			// The committed sd is spent; RunLoop closes it and continues on the
			// fresh SD built below (no reuse).
			if err := sd.Commit(ctx, commitRwTx); err != nil {
				return nil, nil, fmt.Errorf("updateForkChoice: flush+commit sd after hasMore: %w", err)
			}
			// Fresh RO snapshot + SharedDomains + block overlay on the committed state.
			roTx, err = e.db.BeginTemporalRo(ctx) //nolint:gocritic
			if err != nil {
				return nil, nil, fmt.Errorf("updateForkChoice: begin ro after hasMore: %w", err)
			}
			freshSD, err := execctx.NewSharedDomains(ctx, roTx, e.logger)
			if err != nil {
				roTx.Rollback()
				return nil, nil, fmt.Errorf("updateForkChoice: new sd after hasMore: %w", err)
			}
			freshSD.SetInMemHistoryReads(inMemHistoryReads)
			freshSD.SetStateCache(e.stateCache)
			freshSD.SetCodeStore(e.codeStore)
			if err := freshSD.InitBlockOverlay(roTx, roTx.Debug().Dirs().Tmp); err != nil {
				roTx.Rollback()
				freshSD.Close()
				return nil, nil, fmt.Errorf("updateForkChoice: init overlay after hasMore: %w", err)
			}
			newTx := freshSD.BlockOverlay()
			// Re-flush InsertBlocks data into the fresh overlay.
			if hasOverlay {
				e.currentContext.BlockOverlay().UpdateTxn(roTx)
				if err := e.currentContext.BlockOverlay().Flush(ctx, newTx); err != nil {
					freshSD.Close()
					return nil, nil, fmt.Errorf("updateForkChoice: re-flush overlay after hasMore: %w", err)
				}
			}
			return newTx, freshSD, nil
		},
	})
	if err != nil {
		err = fmt.Errorf("updateForkChoice: %w", err)
		e.logger.Warn("Cannot update chain head", "hash", blockHash, "err", err)
		if errors.Is(err, rules.ErrInvalidBlock) {
			return sendForkchoiceResultWithoutWaiting(outcomeCh, ForkChoiceResult{
				Status:          ExecutionStatusBadBlock,
				ValidationError: err.Error(),
				LatestValidHash: rawdb.ReadHeadBlockHash(tx),
			}, stateFlushingInParallel)
		}
		return sendForkchoiceErrorWithoutWaiting(e.logger, outcomeCh, err, stateFlushingInParallel)
	}

	// if head hash was set then success otherwise no
	headHash := rawdb.ReadHeadBlockHash(tx)
	headNumber, err := e.blockReader.HeaderNumber(ctx, tx, headHash)
	if err != nil {
		return sendForkchoiceErrorWithoutWaiting(e.logger, outcomeCh, err, stateFlushingInParallel)
	}
	if headNumber != nil {
		e.forkValidator.NotifyCurrentHeight(*headNumber)
	}

	var status ExecutionStatus

	if headHash != blockHash {
		status = ExecutionStatusBadBlock
		blockHashBlockNum, _ := e.blockReader.HeaderNumber(ctx, tx, blockHash)

		validationError = "headHash and blockHash mismatch"
		if headNumber != nil && e.logger != nil {
			headNum := strconv.FormatUint(*headNumber, 10)
			hashBlockNum := "unknown"
			if blockHashBlockNum != nil {
				hashBlockNum = strconv.FormatUint(*blockHashBlockNum, 10)
			}
			e.logger.Warn("bad forkchoice", "head", headHash, "head block", headNum, "hash", blockHash, "hash block", hashBlockNum)
		}
		currentContext.Close()
		currentContext = nil
		if e.fcuBackgroundCommit {
			e.closeModuleContext()
		}
	} else {
		status = ExecutionStatusSuccess
		// Update forks...
		writeForkChoiceHashes(tx, blockHash, safeHash, finalizedHash)

		valid, err := e.verifyForkchoiceHashes(ctx, tx, blockHash, finalizedHash, safeHash)
		if err != nil {
			return sendForkchoiceErrorWithoutWaiting(e.logger, outcomeCh, err, stateFlushingInParallel)
		}
		if !valid {
			return sendForkchoiceResultWithoutWaiting(outcomeCh, ForkChoiceResult{
				Status:          ExecutionStatusInvalidForkchoice,
				LatestValidHash: common.Hash{},
			}, stateFlushingInParallel)
		}
		if err := rawdb.TruncateCanonicalChain(ctx, tx, *headNumber+1); err != nil {
			return sendForkchoiceErrorWithoutWaiting(e.logger, outcomeCh, err, stateFlushingInParallel)
		}

		if err := rawdbv3.TxNums.Truncate(tx, *headNumber+1); err != nil {
			return sendForkchoiceErrorWithoutWaiting(e.logger, outcomeCh, err, stateFlushingInParallel)
		}

		txnum, err := rawdbv3.TxNums.Max(ctx, tx, fcuHeader.Number.Uint64())
		if err != nil {
			return sendForkchoiceErrorWithoutWaiting(e.logger, outcomeCh, err, stateFlushingInParallel)
		}

		e.logHeadUpdated(blockHash, fcuHeader, txnum, "head updated", stateFlushingInParallel)

		// Close the persistent SD (overlay was already flushed into rwTx at
		// the start of updateForkChoice). Clear e.currentContext so InsertBlocks
		// creates a fresh overlay for the next block cycle.
		if hasOverlay {
			e.closeModuleContext()
		}

		// Dispatch notifications from the SD overlay (before flush/commit).
		// After this, all consumers have the data — the semaphore can be
		// released and flush/commit/prune can proceed without blocking the
		// next FCU.
		e.logger.Debug("[updateForkChoice] dispatching notifications", "head", blockHash, "bgCommit", e.fcuBackgroundCommit)
		if err := e.dispatchNotificationsFromOverlay(currentContext, finishProgressBefore); err != nil {
			return sendForkchoiceErrorWithoutWaiting(e.logger, outcomeCh, fmt.Errorf("fcu: dispatch notifications: %w", err), stateFlushingInParallel)
		}

		// Hand the semaphore to a background goroutine: FCU cleanup runs first,
		// so the next acquirer observes settled state whenever it gets in.
		handOffSemaphore := func(work func() error) {
			shouldReleaseSema = false
			cleanupBeforeSemaRelease()
			go func() {
				defer e.semaphore.Release(1)
				if err := work(); err != nil && !errors.Is(err, context.Canceled) {
					e.logger.Error("Error running background post forkchoice", "err", err)
				}
			}()
		}

		// Flush + commit: foreground by default, background only if
		// fcuBackgroundCommit is explicitly enabled.
		var commitTimings []any
		if e.fcuBackgroundCommit {
			// Transfer roTx + SD ownership to the goroutine so the outer
			// defers become no-ops.
			bgRoTx, bgSD := roTx, currentContext
			roTx, currentContext = nil, nil
			dispatcher := e.pipelineExecutor.Dispatcher()
			handOffSemaphore(func() error {
				defer bgSD.Close()
				// bgRoTx is rolled back inside runForkchoiceFlushCommit between
				// Flush and Commit so the commit sees openTxs=1 in MDBX; this
				// defer is redundant (Rollback is idempotent).
				defer bgRoTx.Rollback()
				err := e.runPostForkchoice(bgSD, bgRoTx, finishProgressBefore, isSynced, initialCycle)
				// Signal that the DB commit is done — RPC consumers can
				// drop their SD reference and read from committed DB.
				if dispatcher != nil {
					dispatcher.PublishOverlay(nil)
				}
				return err
			})
		} else {
			// Foreground commit: pass the outer roTx so it gets released
			// between Flush and Commit (same openTxs=2→1 optimization as
			// the bg-commit path).
			ct, err := e.runForkchoiceFlushCommit(currentContext, roTx, finishProgressBefore, isSynced)
			if err != nil {
				return sendForkchoiceErrorWithoutWaiting(e.logger, outcomeCh, err, stateFlushingInParallel)
			}
			commitTimings = ct

			// Prune: background by default (fcuBackgroundPrune=true). RunPrune
			// shares the pipeline Sync with the next FCU's RunLoop, so the
			// goroutine keeps the semaphore until done.
			if e.fcuBackgroundPrune {
				// Prune doesn't use the overlay/SD — tear down eagerly to free
				// the RAM now and keep the outer defer out of the next FCU's way.
				teardownOverlay()
				handOffSemaphore(func() error {
					return e.runPostForkchoice(nil, nil, finishProgressBefore, isSynced, initialCycle)
				})
			} else {
				pruneTimings, err := e.runForkchoicePrune(initialCycle)
				if err != nil {
					return sendForkchoiceErrorWithoutWaiting(e.logger, outcomeCh, err, stateFlushingInParallel)
				}
				commitTimings = append(commitTimings, pruneTimings...)
			}
		}

		e.logTimings("Timings: Forkchoice", commitTimings)
	}

	return sendForkchoiceResultWithoutWaiting(outcomeCh, ForkChoiceResult{
		LatestValidHash: headHash,
		Status:          status,
		ValidationError: validationError,
	}, stateFlushingInParallel)
}

// runPostForkchoice runs the enabled background FCU work: flush+commit+UpdateHead
// when fcuBackgroundCommit (sd non-nil), prune when fcuBackgroundPrune.
// Notifications have already been dispatched inline from the overlay.
func (e *ExecModule) runPostForkchoice(sd *execctx.SharedDomains, bgRoTx kv.TemporalTx, finishProgressBefore uint64, isSynced bool, initialCycle bool) error {
	var timings []any
	if e.fcuBackgroundCommit && sd != nil {
		commitTimings, err := e.runForkchoiceFlushCommit(sd, bgRoTx, finishProgressBefore, isSynced)
		if err != nil {
			return err
		}
		timings = append(timings, commitTimings...)
	}
	if e.fcuBackgroundPrune {
		pruneTimings, err := e.runForkchoicePrune(initialCycle)
		if err != nil {
			return err
		}
		timings = append(timings, pruneTimings...)
	}
	e.logTimings("Timings: Post-Forkchoice", timings)
	return nil
}

// logTimings logs the collected timings with memory stats appended; no-op when empty.
func (e *ExecModule) logTimings(msg string, timings []any) {
	if len(timings) == 0 {
		return
	}
	var m runtime.MemStats
	dbg.ReadMemStats(&m)
	timings = append(timings, "alloc", common.ByteCount(m.Alloc), "sys", common.ByteCount(m.Sys))
	e.logger.Info(msg, timings...)
}

// dispatchNotificationsFromOverlay sends pre-commit notifications from the
// SD's block overlay. The state version is supplied separately because the
// domain flush, not the metadata overlay, owns its durable sequence advance.
// Dispatch must finish before the execution semaphore is released so the next
// FCU cannot overtake these notifications.
func (e *ExecModule) dispatchNotificationsFromOverlay(sd *execctx.SharedDomains, finishProgressBefore uint64) error {
	dispatcher := e.pipelineExecutor.Dispatcher()
	if dispatcher == nil || e.accum == nil {
		e.logger.Debug("[dispatchNotifications] skipped: dispatcher or accum nil", "dispatcherNil", dispatcher == nil, "accumNil", e.accum == nil)
		return nil
	}
	overlay := sd.BlockOverlay()
	if overlay == nil {
		e.logger.Debug("[dispatchNotifications] skipped: overlay nil")
		return nil
	}
	finishProgressAfter, err := stages.GetStageProgress(overlay, stages.Finish)
	if err != nil {
		return err
	}
	stateVersion, err := sd.ProjectedStateVersion()
	if err != nil {
		return fmt.Errorf("project notification state version: %w", err)
	}
	// Publish the overlay BEFORE dispatching notifications. This ensures
	// the BlockListener (overlay-aware shutter) sees the overlay as active
	// before any StateChangeBatch arrives, so it can buffer events properly.
	e.logger.Debug("[dispatchNotifications] publishing SD")
	dispatcher.PublishOverlay(sd)

	e.logger.Debug("[dispatchNotifications] dispatching", "finishBefore", finishProgressBefore, "finishAfter", finishProgressAfter)
	if err := dispatcher.Dispatch(
		e.backgroundCtx,
		overlay,
		stateVersion,
		e.accum.Accumulator,
		e.accum.RecentReceipts,
		finishProgressBefore,
		finishProgressAfter,
		e.pipelineExecutor.Sync().PrevUnwindPoint(),
	); err != nil {
		return err
	}

	return nil
}

// runForkchoiceFlushCommit opens a brief RwTx, flushes the SharedDomains
// (block overlay + domain mem), commits, then updates the sentry head.
//
// roTxToCloseBeforeCommit (may be nil) is released between Flush and Commit so
// the commit transaction observes openTxs=1 in MDBX rather than 2. This lets
// MDBX GC reclaim pages freed during the commit window immediately, instead of
// pinning them behind the still-open RO reader until the next commit. SD.Flush
// only writes in-memory state to rwTx and does not read from the RO tx, so
// closing it after Flush is safe. Rollback is idempotent, so callers keep their
// outer `defer roTx.Rollback()` unchanged.
func (e *ExecModule) runForkchoiceFlushCommit(sd *execctx.SharedDomains, roTxToCloseBeforeCommit kv.TemporalTx, finishProgressBefore uint64, isSynced bool) ([]any, error) {
	timings := make([]any, 0, 2)

	rwTx, err := e.db.BeginTemporalRw(e.backgroundCtx)
	if err != nil {
		return nil, fmt.Errorf("fcu flush+commit: begin rw: %w", err)
	}
	defer rwTx.Rollback()
	// Release the read snapshot before committing — Commit doesn't read it.
	if roTxToCloseBeforeCommit != nil {
		roTxToCloseBeforeCommit.Rollback()
	}
	flushStart := time.Now()
	if err := sd.Commit(e.backgroundCtx, rwTx); err != nil {
		return nil, err
	}
	timings = append(timings, "flush+commit", common.Round(time.Since(flushStart), 0))

	// Update head and announce block range (notifications already dispatched).
	if e.hook != nil {
		if err := e.db.View(e.backgroundCtx, func(tx kv.Tx) error {
			return e.hook.UpdateHead(tx, finishProgressBefore, isSynced)
		}); err != nil {
			return nil, err
		}
	}
	// Force fsync so data is durable before the next slot.
	if err := e.db.Update(e.backgroundCtx, func(tx kv.RwTx) error {
		return kv.IncrementKey(tx, kv.DatabaseInfo, []byte("chaindata_force"))
	}); err != nil {
		return nil, err
	}
	return timings, nil
}

func (e *ExecModule) runForkchoicePrune(initialCycle bool) ([]any, error) {
	var timings []any
	pruneStart := time.Now()
	defer UpdateForkChoicePruneDuration(pruneStart)

	// Kick collation (build files) + prune via the same path that
	// stageloop.StageLoopIteration uses. Without this call from the FCU
	// path, files would never advance once the node is at tip — execution
	// keeps writing to MDBX above the file boundary, but no new file is
	// built so the data above never becomes prunable. Result: MDBX grows
	// unbounded (commitment domain especially) until the next initial-cycle
	// trip through StageLoopIteration. CollateAndPrune internally
	// opens its own RW tx and calls the pruneFn callback inside it.
	if hasAgg, ok := e.db.(dbstate.HasAgg); ok {
		if agg, ok := hasAgg.Agg().(*dbstate.Aggregator); ok && agg != nil {
			// Same adaptive prune budget as PruneExecutionStage (stage_execute.go):
			// base = SecondsPerSlot/3, +200ms per 100 prunable steps, capped at 2/3 slot.
			baseTimeout := time.Duration(e.config.SecondsPerSlot()*1000/3) * time.Millisecond
			maxTimeout := time.Duration(e.config.SecondsPerSlot()*2000/3) * time.Millisecond
			pruneTimeout := min(baseTimeout+time.Duration(agg.MaxPrunableStepsBacklog()/100)*200*time.Millisecond, maxTimeout)
			if err := agg.CollateAndPrune(e.backgroundCtx, e.db, func(tx kv.TemporalRwTx) error {
				if e.codeStore != nil {
					if err := e.codeStore.Evict(tx); err != nil {
						return err
					}
				}
				return e.pipelineExecutor.RunPrune(e.backgroundCtx, tx, initialCycle, pruneTimeout)
			}, e.logger); err != nil {
				return nil, err
			}
		}
	}

	timings = append(timings, "prune", common.Round(time.Since(pruneStart), 0))
	if len(timings) > 0 {
		timings = append(timings, "initialCycle", initialCycle)
	}
	return timings, nil
}

func (e *ExecModule) logHeadUpdated(blockHash common.Hash, fcuHeader *types.Header, txnum uint64, msg string, debug bool) {
	if e.logger == nil {
		return
	}

	var m runtime.MemStats
	dbg.ReadMemStats(&m)
	blockTimings := e.forkValidator.GetTimings(blockHash)

	logArgs := []any{"hash", blockHash, "number", fcuHeader.Number.Uint64()}
	if txnum != 0 {
		logArgs = append(logArgs, "txnum", txnum)
	}
	logArgs = append(logArgs, "age", common.PrettyAge(time.Unix(int64(fcuHeader.Time), 0)),
		"execution", common.Round(blockTimings[BlockTimingsValidationIndex], 0))

	if !debug {
		totalTime := blockTimings[BlockTimingsValidationIndex]
		if !e.syncCfg.ParallelStateFlushing {
			totalTime += blockTimings[BlockTimingsFlushExtendingFork]
		}
		gasUsedMgas := float64(fcuHeader.GasUsed) / 1e6
		mgasPerSec := gasUsedMgas / totalTime.Seconds()
		metrics.ChainTipMgasPerSec.Add(mgasPerSec)

		const blockRange = 30 // ~1 hour
		const alpha = 2.0 / (blockRange + 1)

		// Gas-weighted EWMA: accumulate gas and time separately.
		// Near-empty blocks contribute tiny values to both, so they barely move the ratio.
		e.accumGasMgas = alpha*gasUsedMgas + (1-alpha)*e.accumGasMgas
		e.accumTimeSec = alpha*totalTime.Seconds() + (1-alpha)*e.accumTimeSec
		avgMgasSec := e.accumGasMgas / e.accumTimeSec
		// if mgasPerSec or avgMgasSec are 0, Inf or -Inf, do not log it but dont return either
		if mgasPerSec > 0 && mgasPerSec != math.Inf(1) && avgMgasSec > 0 && avgMgasSec != math.Inf(1) {
			logArgs = append(logArgs, "mgas/s", fmt.Sprintf("%.2f", mgasPerSec), "avg mgas/s", fmt.Sprintf("%.2f", avgMgasSec))
		}
	}

	logArgs = append(logArgs, "alloc", common.ByteCount(m.Alloc), "sys", common.ByteCount(m.Sys))

	dbgLevel := log.LvlInfo
	if debug {
		dbgLevel = log.LvlDebug
	}
	e.logger.Log(dbgLevel, msg, logArgs...)
}
