package simulation

import (
	"bytes"
	"encoding/binary"
	"encoding/hex"
	"fmt"
	"io"
	"math/rand"
	"testing"
	"time"

	abci "git.cw.tr/mukan-network/mukan-consensus/abci/types"
	cmtproto "git.cw.tr/mukan-network/mukan-consensus/proto/tendermint/types"

	"cosmossdk.io/core/header"
	"cosmossdk.io/log"

	"git.cw.tr/mukan-network/mukan-sdk/baseapp"
	"git.cw.tr/mukan-network/mukan-sdk/codec"
	sdk "git.cw.tr/mukan-network/mukan-sdk/types"
	"git.cw.tr/mukan-network/mukan-sdk/types/simulation"
)

const AverageBlockTime = 6 * time.Second

// initialize the chain for the simulation
func initChain(
	r *rand.Rand,
	params Params,
	accounts []simulation.Account,
	app *baseapp.BaseApp,
	appStateFn simulation.AppStateFn,
	config simulation.Config,
	cdc codec.JSONCodec,
) (mockValidators, time.Time, []simulation.Account, string) {
	blockMaxGas := int64(-1)
	if config.BlockMaxGas > 0 {
		blockMaxGas = config.BlockMaxGas
	}
	appState, accounts, chainID, genesisTimestamp := appStateFn(r, accounts, config)
	consensusParams := randomConsensusParams(r, appState, cdc, blockMaxGas)
	req := abci.RequestInitChain{
		AppStateBytes:   appState,
		ChainId:         chainID,
		ConsensusParams: consensusParams,
		Time:            genesisTimestamp,
	}
	res, err := app.InitChain(&req)
	if err != nil {
		panic(err)
	}
	validators := newMockValidators(r, res.Validators, params)

	return validators, genesisTimestamp, accounts, chainID
}

// SimulateFromSeed tests an application by running the provided
// operations, testing the provided invariants, but using the provided config.Seed.
func SimulateFromSeed(
	tb testing.TB,
	w io.Writer,
	app *baseapp.BaseApp,
	appStateFn simulation.AppStateFn,
	randAccFn simulation.RandomAccountFn,
	ops WeightedOperations,
	blockedAddrs map[string]bool,
	config simulation.Config,
	cdc codec.JSONCodec,
) (stopEarly bool, exportedParams Params, err error) {
	tb.Helper()
	mode, _, _ := getTestingMode(tb)
	expParams, _, err := SimulateFromSeedX(tb, log.NewTestLogger(tb), w, app, appStateFn, randAccFn, ops, blockedAddrs, config, cdc, NewLogWriter(mode))
	return false, expParams, err
}

// SimulateFromSeedX tests an application by running the provided
// operations, testing the provided invariants, but using the provided config.Seed.
func SimulateFromSeedX(
	tb testing.TB,
	logger log.Logger,
	w io.Writer,
	app *baseapp.BaseApp,
	appStateFn simulation.AppStateFn,
	randAccFn simulation.RandomAccountFn,
	ops WeightedOperations,
	blockedAddrs map[string]bool,
	config simulation.Config,
	cdc codec.JSONCodec,
	logWriter LogWriter,
) (exportedParams Params, accs []simulation.Account, err error) {
	tb.Helper()
	// in case we have to end early, don't os.Exit so that we can run cleanup code.
	testingMode, _, b := getTestingMode(tb)

	r := rand.New(newByteSource(config.FuzzSeed, config.Seed))
	params := RandomParams(r)

	startTime := time.Now()
	logger.Info("Starting SimulateFromSeed with randomness", "time", startTime)
	logger.Debug("Randomized simulation setup", "params", mustMarshalJSONIndent(params))

	timeDiff := maxTimePerBlock - minTimePerBlock
	accs = randAccFn(r, params.NumKeys())
	eventStats := NewEventStats()

	// Second variable to keep pending validator set (delayed one block since
	// TM 0.24) Initially this is the same as the initial validator set
	validators, blockTime, accs, chainID := initChain(r, params, accs, app, appStateFn, config, cdc)
	// At least 2 accounts must be added here, otherwise when executing SimulateMsgSend
	// two accounts will be selected to meet the conditions from != to and it will fall into an infinite loop.
	if len(accs) <= 1 {
		return params, accs, fmt.Errorf("at least two genesis accounts are required")
	}

	config.ChainID = chainID

	// remove module account address if they exist in accs
	var tmpAccs []simulation.Account

	for _, acc := range accs {
		if !blockedAddrs[acc.Address.String()] {
			tmpAccs = append(tmpAccs, acc)
		}
	}

	accs = tmpAccs
	nextValidators := validators
	if len(nextValidators) == 0 {
		tb.Skip("skipping: empty validator set in genesis")
		return params, accs, nil
	}

	var (
		pastTimes          []time.Time
		pastVoteInfos      [][]abci.VoteInfo
		timeOperationQueue []simulation.FutureOperation

		blockHeight     = int64(config.InitialBlockHeight)
		proposerAddress = validators.randomProposer(r)
		opCount         = 0
	)

	finalizeBlockReq := RandomRequestFinalizeBlock(
		r,
		params,
		validators,
		pastTimes,
		pastVoteInfos,
		eventStats.Tally,
		blockHeight,
		blockTime,
		validators.randomProposer(r),
	)

	// These are operations which have been queued by previous operations
	operationQueue := NewOperationQueue()

	blockSimulator := createBlockSimulator(
		tb,
		testingMode,
		w,
		params,
		eventStats.Tally,
		ops,
		operationQueue,
		timeOperationQueue,
		logWriter,
		config,
	)

	if !testingMode {
		b.ResetTimer()
	} else {
		// recover logs in case of panic
		defer func() {
			if r := recover(); r != nil {
				logger.Error("simulation halted due to panic", "height", blockHeight)
				logWriter.PrintLogs()
				panic(r)
			}
		}()
	}

	// set exported params to the initial state
	if config.ExportParamsPath != "" && config.ExportParamsHeight == 0 {
		exportedParams = params
	}

	for blockHeight < int64(config.NumBlocks+config.InitialBlockHeight) {
		pastTimes = append(pastTimes, blockTime)
		pastVoteInfos = append(pastVoteInfos, finalizeBlockReq.DecidedLastCommit.Votes)

		// Run the BeginBlock handler
		logWriter.AddEntry(BeginBlockEntry(blockHeight))

		res, err := app.FinalizeBlock(finalizeBlockReq)
		if err != nil {
			return params, accs, fmt.Errorf("block finalization failed at height %d: %w", blockHeight, err)
		}

		ctx := app.NewContextLegacy(false, cmtproto.Header{
			Height:          blockHeight,
			Time:            blockTime,
			ProposerAddress: proposerAddress,
			ChainID:         config.ChainID,
		}).WithHeaderInfo(header.Info{
			Height:  blockHeight,
			Time:    blockTime,
			ChainID: config.ChainID,
		})

		// run queued operations; ignores block size if block size is too small
		numQueuedOpsRan, futureOps := runQueuedOperations(
			tb, operationQueue, blockTime, int(blockHeight), r, app, ctx, accs, logWriter,
			eventStats.Tally, config.Lean, config.ChainID,
		)

		numQueuedTimeOpsRan, timeFutureOps := runQueuedTimeOperations(tb,
			timeOperationQueue, int(blockHeight), blockTime,
			r, app, ctx, accs, logWriter, eventStats.Tally,
			config.Lean, config.ChainID,
		)

		futureOps = append(futureOps, timeFutureOps...)
		queueOperations(operationQueue, timeOperationQueue, futureOps)

		// run standard operations
		operations := blockSimulator(r, app, ctx, accs, cmtproto.Header{
			Height:          blockHeight,
			Time:            blockTime,
			ProposerAddress: proposerAddress,
			ChainID:         config.ChainID,
		})
		opCount += operations + numQueuedOpsRan + numQueuedTimeOpsRan

		blockHeight++

		logWriter.AddEntry(EndBlockEntry(blockHeight))

		blockTime = blockTime.Add(time.Duration(minTimePerBlock) * time.Second)
		blockTime = blockTime.Add(time.Duration(int64(r.Intn(int(timeDiff)))) * time.Second)
		proposerAddress = validators.randomProposer(r)

		if config.Commit {
			app.SimWriteState()
			if _, err := app.Commit(); err != nil {
				return params, accs, fmt.Errorf("commit failed at height %d: %w", blockHeight, err)
			}
		}

		if proposerAddress == nil {
			logger.Info("Simulation stopped early as all validators have been unbonded; nobody left to propose a block", "height", blockHeight)
			break
		}

		// Generate a random RequestBeginBlock with the current validator set
		// for the next block
		finalizeBlockReq = RandomRequestFinalizeBlock(r, params, validators, pastTimes, pastVoteInfos, eventStats.Tally, blockHeight, blockTime, proposerAddress)

		// Update the validator set, which will be reflected in the application
		// on the next block
		validators = nextValidators
		nextValidators = updateValidators(tb, r, params, validators, res.ValidatorUpdates, eventStats.Tally)
		if len(nextValidators) == 0 {
			tb.Skip("skipping: empty validator set")
			return params, accs, nil
		}

		// update the exported params
		if config.ExportParamsPath != "" && int64(config.ExportParamsHeight) == blockHeight {
			exportedParams = params
		}
	}

	logger.Info("Simulation complete", "height", blockHeight, "block-time", blockTime, "opsCount", opCount,
		"run-time", time.Since(startTime), "app-hash", hex.EncodeToString(app.LastCommitID().Hash))

	if config.ExportStatsPath != "" {
		fmt.Println("Exporting simulation statistics...")
		eventStats.ExportJSON(config.ExportStatsPath)
	} else {
		eventStats.Print(w)
	}
	return exportedParams, accs, err
}

type blockSimFn func(
	r *rand.Rand,
	app *baseapp.BaseApp,
	ctx sdk.Context,
	accounts []simulation.Account,
	header cmtproto.Header,
) (opCount int)

// Returns a function to simulate blocks. Written like this to avoid constant
// parameters being passed every time, to minimize memory overhead.
func createBlockSimulator(tb testing.TB, printProgress bool, w io.Writer, params Params,
	event func(route, op, evResult string), ops WeightedOperations,
	operationQueue OperationQueue, timeOperationQueue []simulation.FutureOperation,
	logWriter LogWriter, config simulation.Config,
) blockSimFn {
	tb.Helper()
	lastBlockSizeState := 0 // state for [4 * uniform distribution]
	blocksize := 0
	selectOp := ops.getSelectOpFn()

	return func(
		r *rand.Rand, app *baseapp.BaseApp, ctx sdk.Context, accounts []simulation.Account, header cmtproto.Header,
	) (opCount int) {
		_, _ = fmt.Fprintf(
			w, "\rSimulating... block %d/%d, operation %d/%d.",
			header.Height, config.NumBlocks, opCount, blocksize,
		)
		lastBlockSizeState, blocksize = getBlockSize(r, params, lastBlockSizeState, config.BlockSize)

		type opAndR struct {
			op   simulation.Operation
			rand *rand.Rand
		}

		opAndRz := make([]opAndR, 0, blocksize)

		// Predetermine the blocksize slice so that we can do things like block
		// out certain operations without changing the ops that follow.
		for range blocksize {
			opAndRz = append(opAndRz, opAndR{
				op:   selectOp(r),
				rand: r,
			})
		}

		for i := range blocksize {
			// NOTE: the Rand 'r' should not be used here.
			opAndR := opAndRz[i]
			op, r2 := opAndR.op, opAndR.rand
			opMsg, futureOps, err := op(r2, app, ctx, accounts, config.ChainID)
			opMsg.LogEvent(event)

			if !config.Lean || opMsg.OK {
				logWriter.AddEntry(MsgEntry(header.Height, int64(i), opMsg))
			}

			if err != nil {
				logWriter.PrintLogs()
				tb.Fatalf(`error on block  %d/%d, operation (%d/%d) from x/%s for msg %q:
%v
Comment: %s`,
					header.Height, config.NumBlocks, opCount, blocksize, opMsg.Route, opMsg.Name, err, opMsg.Comment)
			}

			queueOperations(operationQueue, timeOperationQueue, futureOps)

			if printProgress && opCount%50 == 0 {
				_, _ = fmt.Fprintf(w, "\rSimulating... block %d/%d, operation %d/%d. ",
					header.Height, config.NumBlocks, opCount, blocksize)
			}

			opCount++
		}

		return opCount
	}
}

func runQueuedOperations(
	tb testing.TB,
	queueOps map[int][]simulation.Operation,
	blockTime time.Time,
	height int,
	r *rand.Rand,
	app *baseapp.BaseApp,
	ctx sdk.Context,
	accounts []simulation.Account,
	logWriter LogWriter,
	event func(route, op, evResult string),
	lean bool,
	chainID string,
) (numOpsRan int, allFutureOps []simulation.FutureOperation) {
	tb.Helper()
	queuedOp, ok := queueOps[height]
	if !ok {
		return 0, nil
	}

	// Keep all future operations
	allFutureOps = make([]simulation.FutureOperation, 0)

	numOpsRan = len(queuedOp)
	for i := range numOpsRan {
		opMsg, futureOps, err := queuedOp[i](r, app, ctx, accounts, chainID)
		if len(futureOps) > 0 {
			allFutureOps = append(allFutureOps, futureOps...)
		}

		opMsg.LogEvent(event)

		if !lean || opMsg.OK {
			logWriter.AddEntry((QueuedMsgEntry(int64(height), opMsg)))
		}

		if err != nil {
			logWriter.PrintLogs()
			tb.FailNow()
		}
	}
	delete(queueOps, height)

	return numOpsRan, allFutureOps
}

func runQueuedTimeOperations(tb testing.TB, queueOps []simulation.FutureOperation,
	height int, currentTime time.Time, r *rand.Rand,
	app *baseapp.BaseApp, ctx sdk.Context, accounts []simulation.Account,
	logWriter LogWriter, event func(route, op, evResult string),
	lean bool, chainID string,
) (numOpsRan int, allFutureOps []simulation.FutureOperation) {
	tb.Helper()
	// Keep all future operations
	allFutureOps = make([]simulation.FutureOperation, 0)

	numOpsRan = 0
	for len(queueOps) > 0 && currentTime.After(queueOps[0].BlockTime) {
		opMsg, futureOps, err := queueOps[0].Op(r, app, ctx, accounts, chainID)

		opMsg.LogEvent(event)

		if !lean || opMsg.OK {
			logWriter.AddEntry(QueuedMsgEntry(int64(height), opMsg))
		}

		if err != nil {
			logWriter.PrintLogs()
			tb.FailNow()
		}

		if len(futureOps) > 0 {
			allFutureOps = append(allFutureOps, futureOps...)
		}

		queueOps = queueOps[1:]
		numOpsRan++
	}

	return numOpsRan, allFutureOps
}

const (
	rngMax  = 1 << 63
	rngMask = rngMax - 1
)

// byteSource offers deterministic pseudo-random numbers for math.Rand with fuzzer support.
// The 'seed' data is read in big endian to uint64. When exhausted,
// it falls back to a standard random number generator initialized with a specific 'seed' value.
type byteSource struct {
	seed     *bytes.Reader
	fallback *rand.Rand
}

// newByteSource creates a new byteSource with a specified byte slice and seed. This gives a fixed sequence of pseudo-random numbers.
// Initially, it utilizes the byte slice. Once that's exhausted, it continues generating numbers using the provided seed.
func newByteSource(fuzzSeed []byte, seed int64) *byteSource {
	return &byteSource{
		seed:     bytes.NewReader(fuzzSeed),
		fallback: rand.New(rand.NewSource(seed)),
	}
}

func (s *byteSource) Uint64() uint64 {
	if s.seed.Len() < 8 {
		return s.fallback.Uint64()
	}
	var b [8]byte
	if _, err := s.seed.Read(b[:]); err != nil && err != io.EOF {
		panic(err) // Should not happen.
	}
	return binary.BigEndian.Uint64(b[:])
}

func (s *byteSource) Int63() int64 {
	return int64(s.Uint64() & rngMask)
}
func (s *byteSource) Seed(seed int64) {}
