import { AztecNodeService } from '@aztec/aztec-node';
import { getTimestampRangeForEpoch } from '@aztec/aztec.js/block';
import { getContractInstanceFromInstantiationParams } from '@aztec/aztec.js/contracts';
import { Fr } from '@aztec/aztec.js/fields';
import { MerkleTreeId } from '@aztec/aztec.js/trees';
import { EpochCache } from '@aztec/epoch-cache';
import { createExtendedL1Client } from '@aztec/ethereum/client';
import { DefaultL1ContractsConfig } from '@aztec/ethereum/config';
import { RollupContract } from '@aztec/ethereum/contracts';
import { createDelayer, waitUntilL1Timestamp, wrapClientWithDelayer } from '@aztec/ethereum/l1-tx-utils';
import { ChainMonitor } from '@aztec/ethereum/test';
import { CheckpointNumber, EpochNumber } from '@aztec/foundation/branded-types';
import { SecretValue } from '@aztec/foundation/config';
import { randomBytes } from '@aztec/foundation/crypto/random';
import { withLoggerBindings } from '@aztec/foundation/log/server';
import { retryUntil } from '@aztec/foundation/retry';
import { sleep } from '@aztec/foundation/sleep';
import { SpamContract } from '@aztec/noir-test-contracts.js/Spam';
import { TestContract } from '@aztec/noir-test-contracts.js/Test';
import { getMockPubSubP2PServiceFactory } from '@aztec/p2p/test-helpers';
import { SequencerState } from '@aztec/sequencer-client';
import { EthAddress } from '@aztec/stdlib/block';
import { getProofSubmissionDeadlineTimestamp } from '@aztec/stdlib/epoch-helpers';
import { tryStop } from '@aztec/stdlib/interfaces/server';
import { join } from 'path';
import { privateKeyToAccount } from 'viem/accounts';
import { createAndSyncProverNode, getPrivateKeyFromIndex, setup } from '../fixtures/utils.js';
export const WORLD_STATE_CHECKPOINT_HISTORY = 2;
export const WORLD_STATE_BLOCK_CHECK_INTERVAL = 50;
export const ARCHIVER_POLL_INTERVAL = 50;
export const DEFAULT_L1_BLOCK_TIME = process.env.CI ? 12 : 8;
/**
 * Tests building of epochs using fast block times and short epochs.
 * Spawns an aztec node and a prover node with fake proofs.
 * Sequencer is allowed to build empty blocks.
 */ export class EpochsTestContext {
    context;
    l1Client;
    rollup;
    constants;
    logger;
    monitor;
    epochCache;
    proverDelayer;
    sequencerDelayer;
    proverNodes = [];
    nodes = [];
    epochDuration;
    L1_BLOCK_TIME_IN_S;
    L2_SLOT_DURATION_IN_S;
    static async setup(opts = {}) {
        const test = new EpochsTestContext();
        await test.setup(opts);
        return test;
    }
    static getSlotDurations(opts = {}) {
        const envEthereumSlotDuration = process.env.L1_BLOCK_TIME ? parseInt(process.env.L1_BLOCK_TIME) : DEFAULT_L1_BLOCK_TIME;
        const ethereumSlotDuration = opts.ethereumSlotDuration ?? envEthereumSlotDuration;
        const aztecSlotDuration = opts.aztecSlotDuration ?? (opts.aztecSlotDurationInL1Slots ?? 2) * ethereumSlotDuration;
        const aztecEpochDuration = opts.aztecEpochDuration ?? 6;
        const aztecProofSubmissionEpochs = opts.aztecProofSubmissionEpochs ?? 1;
        const l1PublishingTime = opts.l1PublishingTime ?? 1;
        return {
            l1PublishingTime,
            ethereumSlotDuration,
            aztecSlotDuration,
            aztecEpochDuration,
            aztecProofSubmissionEpochs
        };
    }
    async setup(opts = {}) {
        const { ethereumSlotDuration, aztecSlotDuration, aztecEpochDuration, aztecProofSubmissionEpochs, l1PublishingTime } = EpochsTestContext.getSlotDurations(opts);
        this.L1_BLOCK_TIME_IN_S = ethereumSlotDuration;
        this.L2_SLOT_DURATION_IN_S = aztecSlotDuration;
        // Set up system without any account nor protocol contracts
        // and with faster block times and shorter epochs.
        const context = await setup(opts.numberOfAccounts ?? 0, {
            automineL1Setup: true,
            checkIntervalMs: 50,
            archiverPollingIntervalMS: ARCHIVER_POLL_INTERVAL,
            worldStateBlockCheckIntervalMS: WORLD_STATE_BLOCK_CHECK_INTERVAL,
            aztecEpochDuration,
            aztecSlotDuration,
            ethereumSlotDuration,
            aztecProofSubmissionEpochs,
            aztecTargetCommitteeSize: opts.initialValidators?.length ?? 0,
            minTxsPerBlock: 0,
            realProofs: false,
            startProverNode: true,
            proverTestDelayMs: opts.proverTestDelayMs ?? 0,
            // We use numeric incremental prover ids for simplicity, but we can switch to
            // using the prover's eth address if the proverId is used for something in the rollup contract
            // Use numeric EthAddress for deterministic prover id
            proverId: EthAddress.fromNumber(1),
            worldStateCheckpointHistory: WORLD_STATE_CHECKPOINT_HISTORY,
            exitDelaySeconds: DefaultL1ContractsConfig.exitDelaySeconds,
            slasherFlavor: 'none',
            l1PublishingTime,
            ...opts
        }, // Use checkpointed chain tip for PXE by default to avoid issues with blocks being dropped due to pruned anchor blocks.
        // Can be overridden via opts.pxeOpts.
        {
            syncChainTip: 'checkpointed',
            ...opts.pxeOpts
        });
        this.context = context;
        this.proverNodes = context.proverNode ? [
            context.proverNode
        ] : [];
        this.nodes = context.aztecNode ? [
            context.aztecNode
        ] : [];
        this.logger = context.logger;
        this.l1Client = context.deployL1ContractsValues.l1Client;
        this.rollup = RollupContract.getFromConfig(context.config);
        this.epochCache = await EpochCache.create(this.rollup, context.config, {
            dateProvider: context.dateProvider
        });
        // Loop that tracks L1 and L2 block numbers and logs whenever there's a new one.
        this.monitor = new ChainMonitor(this.rollup, context.dateProvider, this.logger).start();
        this.proverDelayer = context.proverDelayer;
        this.sequencerDelayer = context.sequencerDelayer;
        if (context.proverNode && !this.proverDelayer || context.sequencer && !this.sequencerDelayer) {
            throw new Error(`Could not find prover or sequencer delayer`);
        }
        // Constants used for time calculation
        this.epochDuration = aztecEpochDuration;
        this.constants = {
            epochDuration: aztecEpochDuration,
            slotDuration: aztecSlotDuration,
            l1StartBlock: await this.rollup.getL1StartBlock(),
            l1GenesisTime: await this.rollup.getL1GenesisTime(),
            ethereumSlotDuration,
            proofSubmissionEpochs: Number(await this.rollup.getProofSubmissionEpochs()),
            targetCommitteeSize: await this.rollup.getTargetCommitteeSize(),
            rollupManaLimit: Number(await this.rollup.getManaLimit())
        };
        this.logger.info(`L2 genesis at L1 block ${this.constants.l1StartBlock} (timestamp ${this.constants.l1GenesisTime})`);
    }
    async teardown() {
        await this.monitor.stop();
        await Promise.all(this.proverNodes.map((node)=>tryStop(node, this.logger)));
        await Promise.all(this.nodes.map((node)=>tryStop(node, this.logger)));
        await this.context.teardown();
    }
    async createProverNode(opts = {}) {
        this.logger.warn('Creating and syncing a simulated prover node...');
        const proverNodePrivateKey = this.getNextPrivateKey();
        const proverIndex = this.proverNodes.length + 1;
        const { mockGossipSubNetwork } = this.context;
        const { proverNode } = await withLoggerBindings({
            actor: `prover-${proverIndex}`
        }, ()=>createAndSyncProverNode(proverNodePrivateKey, {
                ...this.context.config,
                p2pEnabled: this.context.config.p2pEnabled || mockGossipSubNetwork !== undefined,
                proverId: EthAddress.fromNumber(proverIndex),
                dontStart: opts.dontStart,
                ...opts
            }, {
                dataDirectory: join(this.context.config.dataDirectory, randomBytes(8).toString('hex'))
            }, {
                dateProvider: this.context.dateProvider,
                p2pClientDeps: {
                    p2pServiceFactory: mockGossipSubNetwork ? getMockPubSubP2PServiceFactory(mockGossipSubNetwork) : undefined,
                    rpcTxProviders: [
                        this.context.aztecNode
                    ]
                }
            }, {
                prefilledPublicData: this.context.prefilledPublicData ?? [],
                dontStart: opts.dontStart
            }));
        this.proverNodes.push(proverNode);
        return proverNode;
    }
    createNonValidatorNode(opts = {}) {
        this.logger.warn('Creating and syncing a node without a validator...');
        return this.createNode({
            ...opts,
            disableValidator: true
        });
    }
    createValidatorNode(privateKeys, opts = {}) {
        this.logger.warn('Creating and syncing a validator node...');
        return this.createNode({
            ...opts,
            disableValidator: false,
            validatorPrivateKeys: new SecretValue(privateKeys)
        });
    }
    async createNode(opts = {}) {
        const nodeIndex = this.nodes.length + 1;
        const actorPrefix = opts.disableValidator ? 'node' : 'validator';
        const { mockGossipSubNetwork } = this.context;
        const resolvedConfig = {
            ...this.context.config,
            ...opts
        };
        const p2pEnabled = resolvedConfig.p2pEnabled || mockGossipSubNetwork !== undefined;
        const p2pIp = resolvedConfig.p2pIp ?? (p2pEnabled ? '127.0.0.1' : undefined);
        const node = await withLoggerBindings({
            actor: `${actorPrefix}-${nodeIndex}`
        }, ()=>AztecNodeService.createAndSync({
                ...resolvedConfig,
                dataDirectory: join(this.context.config.dataDirectory, randomBytes(8).toString('hex')),
                validatorPrivateKeys: opts.validatorPrivateKeys ?? new SecretValue([]),
                nodeId: resolvedConfig.nodeId || `${actorPrefix}-${nodeIndex}`,
                p2pEnabled,
                p2pIp
            }, {
                dateProvider: this.context.dateProvider,
                p2pClientDeps: {
                    p2pServiceFactory: mockGossipSubNetwork ? getMockPubSubP2PServiceFactory(mockGossipSubNetwork) : undefined
                },
                slashingProtectionDb: opts.slashingProtectionDb
            }, {
                prefilledPublicData: this.context.prefilledPublicData,
                ...opts
            }));
        this.nodes.push(node);
        return node;
    }
    getNextPrivateKey() {
        const key = getPrivateKeyFromIndex(this.nodes.length + this.proverNodes.length + 1);
        return `0x${key.toString('hex')}`;
    }
    /** Waits until the epoch begins (ie until the immediately previous L1 block is mined). */ async waitUntilEpochStarts(epoch) {
        const [start] = getTimestampRangeForEpoch(EpochNumber(epoch), this.constants);
        this.logger.info(`Waiting until L1 timestamp ${start} is reached as the start of epoch ${epoch}`);
        await waitUntilL1Timestamp(this.l1Client, start - BigInt(this.L1_BLOCK_TIME_IN_S), undefined, 30 * this.epochDuration);
        return start;
    }
    /** Waits until the given checkpoint number is mined. */ async waitUntilCheckpointNumber(target, timeout = 120) {
        await retryUntil(()=>Promise.resolve(target <= this.monitor.checkpointNumber), `Wait until checkpoint ${target}`, timeout, 0.1);
    }
    /** Waits until the given checkpoint number is marked as proven. */ async waitUntilProvenCheckpointNumber(target, timeout = 120) {
        await retryUntil(()=>Promise.resolve(target <= this.monitor.provenCheckpointNumber), `Wait proven checkpoint ${target}`, timeout, 0.1);
        return this.monitor.provenCheckpointNumber;
    }
    /** Waits until the last slot of the proof submission window for a given epoch. */ async waitUntilLastSlotOfProofSubmissionWindow(epochNumber) {
        const deadline = getProofSubmissionDeadlineTimestamp(EpochNumber.fromBigInt(BigInt(epochNumber)), this.constants);
        const oneSlotBefore = deadline - BigInt(this.constants.slotDuration);
        const date = new Date(Number(oneSlotBefore) * 1000);
        this.logger.info(`Waiting until last slot of submission window for epoch ${epochNumber} at ${date}`, {
            oneSlotBefore
        });
        // Use a timeout that accounts for the full proof submission window
        const proofSubmissionWindowDuration = this.constants.proofSubmissionEpochs * this.epochDuration * this.L2_SLOT_DURATION_IN_S;
        await waitUntilL1Timestamp(this.l1Client, oneSlotBefore, undefined, proofSubmissionWindowDuration * 2);
    }
    /** Waits for the aztec node to sync to the target block number. */ async waitForNodeToSync(blockNumber, type) {
        const waitTime = ARCHIVER_POLL_INTERVAL + WORLD_STATE_BLOCK_CHECK_INTERVAL;
        let synched = false;
        while(!synched){
            await sleep(waitTime);
            const [syncState, tips] = await Promise.all([
                this.context.aztecNode.getWorldStateSyncStatus(),
                await this.context.aztecNode.getL2Tips()
            ]);
            this.logger.info(`Wait for node synch ${blockNumber} ${type}`, {
                blockNumber,
                type,
                syncState,
                tips
            });
            if (type === 'proven') {
                synched = tips.proven.block.number >= blockNumber && syncState.latestBlockNumber >= blockNumber;
            } else if (type === 'finalized') {
                synched = syncState.finalizedBlockNumber >= blockNumber;
            } else {
                synched = syncState.oldestHistoricBlockNumber >= blockNumber;
            }
        }
    }
    /** Registers the SpamContract on the given wallet. */ async registerSpamContract(wallet, salt = Fr.ZERO) {
        const instance = await getContractInstanceFromInstantiationParams(SpamContract.artifact, {
            constructorArgs: [],
            constructorArtifact: undefined,
            salt,
            publicKeys: undefined,
            deployer: undefined
        });
        await wallet.registerContract(instance, SpamContract.artifact);
        return SpamContract.at(instance.address, wallet);
    }
    /** Registers the TestContract on the given wallet. */ async registerTestContract(wallet, salt = Fr.ZERO) {
        const instance = await getContractInstanceFromInstantiationParams(TestContract.artifact, {
            constructorArgs: [],
            constructorArtifact: undefined,
            salt,
            publicKeys: undefined,
            deployer: undefined
        });
        await wallet.registerContract(instance, TestContract.artifact);
        return TestContract.at(instance.address, wallet);
    }
    /** Creates an L1 client using a fresh account with funds from anvil, with a tx delayer already set up. */ async createL1Client() {
        const rawClient = createExtendedL1Client([
            ...this.l1Client.chain.rpcUrls.default.http
        ], privateKeyToAccount(this.getNextPrivateKey()), this.l1Client.chain);
        const delayer = createDelayer(this.context.dateProvider, {
            ethereumSlotDuration: this.L1_BLOCK_TIME_IN_S
        }, {});
        const client = wrapClientWithDelayer(rawClient, delayer);
        expect(await client.getBalance({
            address: client.account.address
        })).toBeGreaterThan(0n);
        return {
            client,
            delayer
        };
    }
    /** Verifies whether the given block number is found on the aztec node. */ async verifyHistoricBlock(blockNumber, expectedSuccess) {
        // We use `findLeavesIndexes` here, but could use any function that queries the world-state
        // at a particular block, so we know whether that historic block is available or has been
        // pruned. Note that `getBlock` would not work here, since it only hits the archiver.
        const result = await this.context.aztecNode.findLeavesIndexes(blockNumber, MerkleTreeId.NULLIFIER_TREE, [
            Fr.ZERO
        ]).then((_)=>true).catch((_)=>false);
        expect(result).toBe(expectedSuccess);
    }
    /** Verifies at least one checkpoint has the target number of blocks (for MBPS validation). */ async assertMultipleBlocksPerSlot(targetBlockCount) {
        const archiver = this.context.aztecNode.getBlockSource();
        const checkpoints = await archiver.getCheckpoints(CheckpointNumber(1), 50);
        this.logger.warn(`Retrieved ${checkpoints.length} checkpoints from archiver`, {
            checkpoints: checkpoints.map((pc)=>pc.checkpoint.getStats())
        });
        let expectedBlockNumber = checkpoints[0].checkpoint.blocks[0].number;
        let targetFound = false;
        for (const checkpoint of checkpoints){
            const blockCount = checkpoint.checkpoint.blocks.length;
            targetFound = targetFound || blockCount >= targetBlockCount;
            this.logger.verbose(`Checkpoint ${checkpoint.checkpoint.number} has ${blockCount} blocks`, {
                checkpoint: checkpoint.checkpoint.getStats()
            });
            for(let i = 0; i < blockCount; i++){
                const block = checkpoint.checkpoint.blocks[i];
                expect(block.indexWithinCheckpoint).toBe(i);
                expect(block.checkpointNumber).toBe(checkpoint.checkpoint.number);
                expect(block.number).toBe(expectedBlockNumber);
                expectedBlockNumber++;
            }
        }
        expect(targetFound).toBe(true);
    }
    watchSequencerEvents(sequencers, getMetadata = ()=>({})) {
        const stateChanges = [];
        const failEvents = [];
        // Note we do not include the 'block-tx-count-check-failed' event here, since it is fine if we dont build
        // due to lack of txs available.
        const failEventsKeys = [
            'block-build-failed',
            'checkpoint-publish-failed',
            'proposer-rollup-check-failed'
        ];
        const makeEvent = (i, eventName, args)=>({
                ...args,
                type: eventName,
                sequencerIndex: i + 2,
                ...getMetadata(i)
            });
        sequencers.forEach((sequencer, i)=>{
            const sequencerIndex = i + 2;
            sequencer.getSequencer().on('state-changed', (args)=>{
                const noisyStates = [
                    SequencerState.IDLE,
                    SequencerState.PROPOSER_CHECK,
                    SequencerState.SYNCHRONIZING
                ];
                if (!noisyStates.includes(args.newState)) {
                    const evt = makeEvent(i, 'state-changed', args);
                    stateChanges.push(evt);
                    this.logger.verbose(`Sequencer ${sequencerIndex} transitioned from state ${args.oldState} to state ${args.newState}`, evt);
                }
            });
            failEventsKeys.forEach((eventName)=>{
                sequencer.getSequencer().on(eventName, (args)=>{
                    const evt = makeEvent(i, eventName, args);
                    failEvents.push(evt);
                    this.logger.error(`Failed event ${eventName} from sequencer ${sequencerIndex}`, undefined, evt);
                });
            });
        });
        return {
            failEvents,
            stateChanges
        };
    }
}
