import crypto from "node:crypto";
import {ChainConfig} from "@lodestar/config";
import {
  BLOB_TX_TYPE,
  BYTES_PER_FIELD_ELEMENT,
  FIELD_ELEMENTS_PER_BLOB,
  ForkName,
  ForkPostBellatrix,
  ForkPostCapella,
  ForkSeq,
  SLOTS_PER_EPOCH,
} from "@lodestar/params";
import {computeTimeAtSlot} from "@lodestar/state-transition";
import {ExecutionPayload, RootHex, bellatrix, deneb, gloas, ssz} from "@lodestar/types";
import {fromHex, toRootHex} from "@lodestar/utils";
import {ZERO_HASH_HEX} from "../../constants/index.js";
import {INTEROP_BLOCK_HASH} from "../../node/utils/interop/state.js";
import {kzgCommitmentToVersionedHash} from "../../util/blobs.js";
import {kzg} from "../../util/kzg.js";
import {ClientCode, ExecutionPayloadStatus, PayloadIdCache} from "./interface.js";
import {
  BlobsBundleRpc,
  EngineApiRpcParamTypes,
  EngineApiRpcReturnTypes,
  ExecutionPayloadBodyRpc,
  ExecutionPayloadRpc,
  ExecutionRequestsRpc,
  PayloadStatus,
  deserializePayloadAttributes,
  serializeBlobsBundle,
  serializeExecutionPayload,
  serializeExecutionRequests,
} from "./types.js";
import {JsonRpcBackend, quantityToNum} from "./utils.js";

const INTEROP_GAS_LIMIT = 30e6;
const PRUNE_PAYLOAD_ID_AFTER_MS = 5000;

export type ExecutionEngineMockOpts = {
  genesisBlockHash?: string;
  eth1BlockHash?: string;
  onlyPredefinedResponses?: boolean;
  genesisTime?: number;
  config?: ChainConfig;
};

type ExecutionBlock = {
  parentHash: RootHex;
  blockHash: RootHex;
  timestamp: number;
  blockNumber: number;
};

const TX_TYPE_EIP1559 = 2;

type PreparedPayload = {
  executionPayload: ExecutionPayloadRpc;
  blobsBundle: BlobsBundleRpc;
  executionRequests: ExecutionRequestsRpc;
};

/**
 * Mock ExecutionEngine for fast prototyping and unit testing
 */
export class ExecutionEngineMockBackend implements JsonRpcBackend {
  // Public state to check if notifyForkchoiceUpdate() is called properly
  headBlockHash = ZERO_HASH_HEX;
  safeBlockHash = ZERO_HASH_HEX;
  finalizedBlockHash = ZERO_HASH_HEX;
  readonly payloadIdCache = new PayloadIdCache();

  /** Known valid blocks */
  private readonly validBlocks = new Map<RootHex, ExecutionBlock>();
  /** Preparing payloads to be retrieved via engine_getPayloadV1 */
  private readonly preparingPayloads = new Map<number, PreparedPayload>();
  private readonly payloadsForDeletion = new Map<number, number>();
  private readonly predefinedPayloadStatuses = new Map<RootHex, PayloadStatus>();

  private payloadId = 0;
  private capellaForkTimestamp: number;
  private denebForkTimestamp: number;
  private electraForkTimestamp: number;
  private fuluForkTimestamp: number;
  private gloasForkTimestamp: number;

  readonly handlers: {
    [K in keyof EngineApiRpcParamTypes]: (...args: EngineApiRpcParamTypes[K]) => EngineApiRpcReturnTypes[K];
  };

  constructor(private readonly opts: ExecutionEngineMockOpts) {
    this.validBlocks.set(opts.genesisBlockHash ?? ZERO_HASH_HEX, {
      parentHash: ZERO_HASH_HEX,
      blockHash: ZERO_HASH_HEX,
      timestamp: 0,
      blockNumber: 0,
    });

    const eth1BlockHash = opts.eth1BlockHash ?? toRootHex(INTEROP_BLOCK_HASH);

    this.validBlocks.set(eth1BlockHash, {
      parentHash: ZERO_HASH_HEX,
      blockHash: eth1BlockHash,
      timestamp: 0,
      blockNumber: 1,
    });

    const {config} = opts;

    this.capellaForkTimestamp =
      opts.genesisTime && config
        ? computeTimeAtSlot(config, config.CAPELLA_FORK_EPOCH * SLOTS_PER_EPOCH, opts.genesisTime)
        : Infinity;
    this.denebForkTimestamp =
      opts.genesisTime && config
        ? computeTimeAtSlot(config, config.DENEB_FORK_EPOCH * SLOTS_PER_EPOCH, opts.genesisTime)
        : Infinity;
    this.electraForkTimestamp =
      opts.genesisTime && config
        ? computeTimeAtSlot(config, config.ELECTRA_FORK_EPOCH * SLOTS_PER_EPOCH, opts.genesisTime)
        : Infinity;
    this.fuluForkTimestamp =
      opts.genesisTime && config
        ? computeTimeAtSlot(config, config.FULU_FORK_EPOCH * SLOTS_PER_EPOCH, opts.genesisTime)
        : Infinity;
    this.gloasForkTimestamp =
      opts.genesisTime && config
        ? computeTimeAtSlot(config, config.GLOAS_FORK_EPOCH * SLOTS_PER_EPOCH, opts.genesisTime)
        : Infinity;

    this.handlers = {
      engine_newPayloadV1: this.notifyNewPayload.bind(this),
      engine_newPayloadV2: this.notifyNewPayload.bind(this),
      engine_newPayloadV3: this.notifyNewPayload.bind(this),
      engine_newPayloadV4: this.notifyNewPayload.bind(this),
      engine_newPayloadV5: this.notifyNewPayload.bind(this),
      engine_forkchoiceUpdatedV1: this.notifyForkchoiceUpdate.bind(this),
      engine_forkchoiceUpdatedV2: this.notifyForkchoiceUpdate.bind(this),
      engine_forkchoiceUpdatedV3: this.notifyForkchoiceUpdate.bind(this),
      engine_forkchoiceUpdatedV4: this.notifyForkchoiceUpdate.bind(this),
      engine_getPayloadV1: this.getPayloadV1.bind(this),
      engine_getPayloadV2: this.getPayloadV5.bind(this),
      engine_getPayloadV3: this.getPayloadV5.bind(this),
      engine_getPayloadV4: this.getPayloadV5.bind(this),
      engine_getPayloadV5: this.getPayloadV5.bind(this),
      engine_getPayloadV6: this.getPayloadV5.bind(this),
      engine_getPayloadBodiesByHashV1: this.getPayloadBodiesByHash.bind(this),
      engine_getPayloadBodiesByRangeV1: this.getPayloadBodiesByRange.bind(this),
      engine_getClientVersionV1: this.getClientVersionV1.bind(this),
      engine_getBlobsV1: this.getBlobs.bind(this),
      engine_getBlobsV2: this.getBlobsV2.bind(this),
    };
  }

  private getPayloadBodiesByHash(
    _blockHex: EngineApiRpcParamTypes["engine_getPayloadBodiesByHashV1"][0]
  ): EngineApiRpcReturnTypes["engine_getPayloadBodiesByHashV1"] {
    return [] as ExecutionPayloadBodyRpc[];
  }

  private getPayloadBodiesByRange(
    _start: EngineApiRpcParamTypes["engine_getPayloadBodiesByRangeV1"][0],
    _count: EngineApiRpcParamTypes["engine_getPayloadBodiesByRangeV1"][1]
  ): EngineApiRpcReturnTypes["engine_getPayloadBodiesByRangeV1"] {
    return [] as ExecutionPayloadBodyRpc[];
  }

  /**
   * Mock manipulator to add predefined responses before execution engine client calls
   */
  addPredefinedPayloadStatus(blockHash: RootHex, payloadStatus: PayloadStatus): void {
    this.predefinedPayloadStatuses.set(blockHash, payloadStatus);
  }

  /**
   * `engine_newPayloadV1`
   */
  private notifyNewPayload(
    executionPayloadRpc: EngineApiRpcParamTypes["engine_newPayloadV1"][0],
    // add versionedHashes validation later if required
    _versionedHashes?: EngineApiRpcParamTypes["engine_newPayloadV3"][1]
  ): EngineApiRpcReturnTypes["engine_newPayloadV1"] {
    const blockHash = executionPayloadRpc.blockHash;
    const parentHash = executionPayloadRpc.parentHash;

    // For optimistic sync spec tests, allow to define responses ahead of time
    const predefinedResponse = this.predefinedPayloadStatuses.get(blockHash);
    if (predefinedResponse) {
      return predefinedResponse;
    }

    if (this.opts.onlyPredefinedResponses) {
      throw Error(`No predefined response for blockHash ${blockHash}`);
    }

    // 1. Client software MUST validate blockHash value as being equivalent to Keccak256(RLP(ExecutionBlockHeader)),
    //    where ExecutionBlockHeader is the execution layer block header (the former PoW block header structure).
    //    Fields of this object are set to the corresponding payload values and constant values according to the Block
    //    structure section of EIP-3675, extended with the corresponding section of EIP-4399. Client software MUST run
    //    this validation in all cases even if this branch or any other branches of the block tree are in an active sync
    //    process.
    //
    // > Mock does not do this validation

    // 2. Client software MAY initiate a sync process if requisite data for payload validation is missing. Sync process
    // is specified in the Sync section.
    //
    // > N/A: Mock can't sync

    // 3. Client software MUST validate the payload if it extends the canonical chain and requisite data for the
    //    validation is locally available. The validation process is specified in the Payload validation section.
    //
    // > Mock only validates that parent is known
    if (!this.validBlocks.has(parentHash)) {
      // if requisite data for the payload's acceptance or validation is missing
      // return {status: SYNCING, latestValidHash: null, validationError: null}
      return {status: ExecutionPayloadStatus.SYNCING, latestValidHash: null, validationError: null};
    }

    // 4. Client software MAY NOT validate the payload if the payload doesn't belong to the canonical chain.
    //
    // > N/A: Mock does not track the chain dag

    // Mock logic: persist valid payload as part of canonical chain

    this.validBlocks.set(blockHash, {
      parentHash,
      blockHash,
      timestamp: quantityToNum(executionPayloadRpc.timestamp),
      blockNumber: quantityToNum(executionPayloadRpc.blockNumber),
    });

    // IF the payload has been fully validated while processing the call
    // RETURN payload status from the Payload validation process
    // If validation succeeds, the response MUST contain {status: VALID, latestValidHash: payload.blockHash}
    return {status: ExecutionPayloadStatus.VALID, latestValidHash: blockHash, validationError: null};
  }

  /**
   * `engine_forkchoiceUpdatedV1`
   */
  private notifyForkchoiceUpdate(
    forkChoiceData: EngineApiRpcParamTypes["engine_forkchoiceUpdatedV1"][0],
    payloadAttributesRpc: EngineApiRpcParamTypes["engine_forkchoiceUpdatedV1"][1]
  ): EngineApiRpcReturnTypes["engine_forkchoiceUpdatedV1"] {
    const {headBlockHash, safeBlockHash, finalizedBlockHash} = forkChoiceData;

    // For optimistic sync spec tests, allow to define responses ahead of time
    const predefinedResponse = this.predefinedPayloadStatuses.get(headBlockHash);
    if (predefinedResponse) {
      return {
        payloadStatus: predefinedResponse,
        payloadId: null,
      };
    }

    if (this.opts.onlyPredefinedResponses) {
      throw Error(`No predefined response for headBlockHash ${headBlockHash}`);
    }

    // 1. Client software MAY initiate a sync process if forkchoiceState.headBlockHash references an unknown payload or
    //    a payload that can't be validated because data that are requisite for the validation is missing. The sync
    //    process is specified in the Sync section.
    //
    // > N/A: Mock can't sync

    // 2. Client software MAY skip an update of the forkchoice state and MUST NOT begin a payload build process if
    //    forkchoiceState.headBlockHash references an ancestor of the head of canonical chain. In the case of such an
    //    event, client software MUST return {payloadStatus:
    //    {status: VALID, latestValidHash: forkchoiceState.headBlockHash, validationError: null}, payloadId: null}.
    //
    // > TODO

    // 3. If forkchoiceState.headBlockHash references a PoW block, client software MUST validate this block with
    //    respect to terminal block conditions according to EIP-3675. This check maps to the transition block validity
    //    section of the EIP. Additionally, if this validation fails, client software MUST NOT update the forkchoice
    //    state and MUST NOT begin a payload build process.
    //
    // > N/A: All networks have completed the merge transition

    // 4. Before updating the forkchoice state, client software MUST ensure the validity of the payload referenced by
    //    forkchoiceState.headBlockHash, and MAY validate the payload while processing the call. The validation process
    //    is specified in the Payload validation section. If the validation process fails, client software MUST NOT
    //    update the forkchoice state and MUST NOT begin a payload build process.
    //
    // > N/A payload already validated
    const headBlock = this.validBlocks.get(headBlockHash);
    if (!headBlock) {
      // IF references an unknown payload or a payload that can't be validated because requisite data is missing
      // RETURN {payloadStatus: {status: SYNCING, latestValidHash: null, validationError: null}, payloadId: null}
      //
      return {
        payloadStatus: {status: ExecutionPayloadStatus.SYNCING, latestValidHash: null, validationError: null},
        payloadId: null,
      };
    }

    // 5. Client software MUST update its forkchoice state if payloads referenced by forkchoiceState.headBlockHash and
    //    forkchoiceState.finalizedBlockHash are VALID.
    if (!this.validBlocks.has(finalizedBlockHash)) {
      throw Error(`Unknown finalizedBlockHash ${finalizedBlockHash}`);
    }
    this.headBlockHash = headBlockHash;
    this.safeBlockHash = safeBlockHash;
    this.finalizedBlockHash = finalizedBlockHash;

    // 6. 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.
    //
    // > N/A: Mock does not track the chain dag

    if (payloadAttributesRpc) {
      const payloadAttributes = deserializePayloadAttributes(payloadAttributesRpc);

      // 7. Client software MUST ensure that payloadAttributes.timestamp is greater than timestamp of a block referenced
      //    by forkchoiceState.headBlockHash. If this condition isn't held client software MUST respond with
      //   `-38003: Invalid payload attributes` and MUST NOT begin a payload build process.
      //    In such an event, the forkchoiceState update MUST NOT be rolled back.
      if (headBlock.timestamp > payloadAttributes.timestamp) {
        throw Error("Invalid payload attributes");
      }

      // 8. Client software MUST begin a payload build process building on top of forkchoiceState.headBlockHash and
      //    identified via buildProcessId value if payloadAttributes is not null and the forkchoice state has been
      //    updated successfully. The build process is specified in the Payload building section.
      const payloadId = this.payloadId++;

      // Generate empty payload first to be correct with respect to fork
      const fork = this.timestampToFork(payloadAttributes.timestamp);
      const executionPayload = ssz[fork].ExecutionPayload.defaultValue();

      // Make executionPayload valid
      executionPayload.parentHash = fromHex(headBlockHash);
      executionPayload.feeRecipient = fromHex(payloadAttributes.suggestedFeeRecipient);
      executionPayload.prevRandao = payloadAttributes.prevRandao;
      executionPayload.blockNumber = headBlock.blockNumber + 1;
      executionPayload.gasLimit = INTEROP_GAS_LIMIT;
      executionPayload.gasUsed = Math.floor(0.5 * INTEROP_GAS_LIMIT);
      executionPayload.timestamp = payloadAttributes.timestamp;
      executionPayload.blockHash = crypto.randomBytes(32);
      executionPayload.transactions = [];

      // Between 0 and 4 transactions for all forks
      const eip1559TxCount = Math.round(4 * Math.random());
      for (let i = 0; i < eip1559TxCount; i++) {
        const tx = crypto.randomBytes(512);
        tx[0] = TX_TYPE_EIP1559;
        executionPayload.transactions.push(tx);
      }

      const commitments: deneb.KZGCommitment[] = [];
      const blobs: deneb.Blob[] = [];
      const proofs: deneb.KZGProof[] = [];

      if (ForkSeq[fork] >= ForkSeq.fulu) {
        // if post fulu, add between 0 and 3 data column transactions based on slot with BlobsBundleV2
        const fuluTxCount = executionPayload.blockNumber % 4;
        for (let i = 0; i < fuluTxCount; i++) {
          const blob = generateRandomBlob();
          const commitment = kzg.blobToKzgCommitment(blob);
          const {proofs: cellProofs} = kzg.computeCellsAndKzgProofs(blob);
          executionPayload.transactions.push(transactionForKzgCommitment(commitment));
          commitments.push(commitment);
          blobs.push(blob);
          proofs.push(...cellProofs);
        }
      } else if (ForkSeq[fork] >= ForkSeq.deneb && ForkSeq[fork] < ForkSeq.fulu) {
        // if post deneb, add between 0 and 2 blob transactions
        const denebTxCount = executionPayload.blockNumber % 3;
        for (let i = 0; i < denebTxCount; i++) {
          const blob = generateRandomBlob();
          const commitment = kzg.blobToKzgCommitment(blob);
          const proof = kzg.computeBlobKzgProof(blob, commitment);
          executionPayload.transactions.push(transactionForKzgCommitment(commitment));
          commitments.push(commitment);
          blobs.push(blob);
          proofs.push(proof);
        }
      }

      if (ForkSeq[fork] >= ForkSeq.capella) {
        (executionPayload as ExecutionPayload<ForkPostCapella>).withdrawals = ssz.capella.Withdrawals.defaultValue();
      }

      if (ForkSeq[fork] >= ForkSeq.gloas && payloadAttributes.slotNumber != null) {
        (executionPayload as gloas.ExecutionPayload).slotNumber = payloadAttributes.slotNumber;
      }

      this.preparingPayloads.set(payloadId, {
        executionPayload: serializeExecutionPayload(fork, executionPayload),
        blobsBundle: serializeBlobsBundle({
          commitments,
          blobs,
          proofs,
        }),
        executionRequests: serializeExecutionRequests({
          deposits: ssz.electra.DepositRequests.defaultValue(),
          withdrawals: ssz.electra.WithdrawalRequests.defaultValue(),
          consolidations: ssz.electra.ConsolidationRequests.defaultValue(),
        }),
      });

      // IF the payload is deemed VALID and the build process has begun
      // {payloadStatus: {status: VALID, latestValidHash: forkchoiceState.headBlockHash, validationError: null}, payloadId: buildProcessId}
      return {
        payloadStatus: {status: ExecutionPayloadStatus.VALID, latestValidHash: null, validationError: null},
        payloadId: String(payloadId as number),
      };
    }

    // Don't start build process
    // IF the payload is deemed VALID and a build process hasn't been started
    // {payloadStatus: {status: VALID, latestValidHash: forkchoiceState.headBlockHash, validationError: null}, payloadId: null}
    return {
      payloadStatus: {status: ExecutionPayloadStatus.VALID, latestValidHash: null, validationError: null},
      payloadId: null,
    };
  }

  /**
   * `engine_getPayloadV1`
   *
   * 1. Given the payloadId client software MUST respond with the most recent version of the payload that is available in the corresponding building process at the time of receiving the call.
   * 2. The call MUST be responded with 5: Unavailable payload error if the building process identified by the payloadId doesn't exist.
   * 3. Client software MAY stop the corresponding building process after serving this call.
   */
  private getPayloadV1(
    payloadId: EngineApiRpcParamTypes["engine_getPayloadV1"][0]
  ): EngineApiRpcReturnTypes["engine_getPayloadV1"] {
    return this.getPayloadV5(payloadId).executionPayload;
  }

  private getPayloadV5(
    payloadId: EngineApiRpcParamTypes["engine_getPayloadV5"][0]
  ): EngineApiRpcReturnTypes["engine_getPayloadV5"] {
    // 1. Given the payloadId client software MUST return the most recent version of the payload that is available in
    //    the corresponding build process at the time of receiving the call.
    const payloadIdNbr = Number(payloadId);
    const payload = this.preparingPayloads.get(payloadIdNbr);

    // 2. The call MUST return -38001: Unknown payload error if the build process identified by the payloadId does not
    //    exist.
    if (!payload) {
      throw Error(`Unknown payloadId ${payloadId}`);
    }

    // 3. Client software MAY stop the corresponding build process after serving this call.
    // Do after a while to allow getBlobsBundle()
    const now = Date.now();
    for (const [oldPayloadId, addedTimestampMs] of this.payloadsForDeletion.entries()) {
      if (addedTimestampMs < now - PRUNE_PAYLOAD_ID_AFTER_MS) {
        this.preparingPayloads.delete(oldPayloadId);
        this.payloadsForDeletion.delete(oldPayloadId);
      }
    }
    this.payloadsForDeletion.set(payloadIdNbr, now);

    return {
      executionPayload: payload.executionPayload,
      blockValue: String(1e9),
      blobsBundle: payload.blobsBundle,
      executionRequests: payload.executionRequests,
    };
  }

  private getClientVersionV1(
    _clientVersion: EngineApiRpcParamTypes["engine_getClientVersionV1"][0]
  ): EngineApiRpcReturnTypes["engine_getClientVersionV1"] {
    return [{code: ClientCode.XX, name: "mock", version: "", commit: ""}];
  }

  private getBlobs(
    versionedHashes: EngineApiRpcParamTypes["engine_getBlobsV1"][0]
  ): EngineApiRpcReturnTypes["engine_getBlobsV1"] {
    return versionedHashes.map((_vh) => null);
  }

  private getBlobsV2(
    _versionedHashes: EngineApiRpcParamTypes["engine_getBlobsV2"][0]
  ): EngineApiRpcReturnTypes["engine_getBlobsV2"] {
    return null;
  }

  private timestampToFork(timestamp: number): ForkPostBellatrix {
    if (timestamp >= this.gloasForkTimestamp) return ForkName.gloas;
    if (timestamp >= this.fuluForkTimestamp) return ForkName.fulu;
    if (timestamp >= this.electraForkTimestamp) return ForkName.electra;
    if (timestamp >= this.denebForkTimestamp) return ForkName.deneb;
    if (timestamp >= this.capellaForkTimestamp) return ForkName.capella;
    return ForkName.bellatrix;
  }
}

function transactionForKzgCommitment(kzgCommitment: deneb.KZGCommitment): bellatrix.Transaction {
  // Just use versionedHash as the transaction encoding to mock newPayloadV3 verification
  // prefixed with BLOB_TX_TYPE
  const transaction = new Uint8Array(33);
  const versionedHash = kzgCommitmentToVersionedHash(kzgCommitment);
  transaction[0] = BLOB_TX_TYPE;
  transaction.set(versionedHash, 1);
  return transaction;
}

/**
 * Generate random blob of sequential integers such that each element is < BLS_MODULUS
 */
function generateRandomBlob(): deneb.Blob {
  const blob = new Uint8Array(FIELD_ELEMENTS_PER_BLOB * BYTES_PER_FIELD_ELEMENT);
  const dv = new DataView(blob.buffer, blob.byteOffset, blob.byteLength);
  for (let i = 0; i < FIELD_ELEMENTS_PER_BLOB; i++) {
    dv.setUint32(i * BYTES_PER_FIELD_ELEMENT, i);
  }
  return blob;
}
