import EventEmitter from "node:events";
import type {StrictEventEmitter} from "strict-event-emitter-types";
import {ChainForkConfig} from "@lodestar/config";
import {computeEpochAtSlot, computeTimeAtSlot, getCurrentSlot} from "@lodestar/state-transition";
import type {Epoch, Slot} from "@lodestar/types";
import {ErrorAborted} from "@lodestar/utils";

export enum ClockEvent {
  /**
   * This event signals the start of a new slot, and that subsequent calls to `clock.currentSlot` will equal `slot`.
   * This event is guaranteed to be emitted every `SLOT_DURATION_MS` milliseconds.
   */
  slot = "clock:slot",
  /**
   * This event signals the start of a new epoch, and that subsequent calls to `clock.currentEpoch` will return `epoch`.
   * This event is guaranteed to be emitted every `SLOT_DURATION_MS * SLOTS_PER_EPOCH` milliseconds.
   */
  epoch = "clock:epoch",
}

export type ClockEvents = {
  [ClockEvent.slot]: (slot: Slot) => void;
  [ClockEvent.epoch]: (epoch: Epoch) => void;
};

/**
 * Tracks the current chain time, measured in `Slot`s and `Epoch`s
 *
 * The time is dependent on:
 * - `state.genesisTime` - the genesis time
 * - `SLOT_DURATION_MS` - # of milliseconds per slot
 * - `SLOTS_PER_EPOCH` - # of slots per epoch
 */
export type IClock = StrictEventEmitter<EventEmitter, ClockEvents> & {
  readonly genesisTime: Slot;
  readonly currentSlot: Slot;
  /**
   * If it's too close to next slot, maxCurrentSlot = currentSlot + 1
   */
  readonly currentSlotWithGossipDisparity: Slot;
  readonly currentEpoch: Epoch;
  /** Returns the slot if the internal clock were advanced by `toleranceSec`. */
  slotWithFutureTolerance(toleranceSec: number): Slot;
  /** Returns the slot if the internal clock were reversed by `toleranceSec`. */
  slotWithPastTolerance(toleranceSec: number): Slot;
  /**
   * Check if a slot is current slot given MAXIMUM_GOSSIP_CLOCK_DISPARITY.
   */
  isCurrentSlotGivenGossipDisparity(slot: Slot): boolean;
  /**
   * Returns a promise that waits until at least `slot` is reached
   * Resolves when the current slot >= `slot`
   * Rejects if the clock is aborted
   */
  waitForSlot(slot: Slot): Promise<void>;
  /**
   * Return second from a slot to either toSec or now.
   */
  secFromSlot(slot: Slot, toSec?: number): number;
  /**
   * Return milliseconds from a slot to either toMs or now.
   */
  msFromSlot(slot: Slot, toMs?: number): number;
};

/**
 * A local clock, the clock time is assumed to be trusted
 */
export class Clock extends EventEmitter implements IClock {
  readonly genesisTime: number;
  private readonly config: ChainForkConfig;
  private timeoutId: number | NodeJS.Timeout;
  private readonly signal: AbortSignal;
  private _currentSlot: number;

  constructor({config, genesisTime, signal}: {config: ChainForkConfig; genesisTime: number; signal: AbortSignal}) {
    super();

    this.config = config;
    this.genesisTime = genesisTime;
    this.timeoutId = setTimeout(this.onNextSlot, this.msUntilNextSlot());
    this.signal = signal;
    this._currentSlot = getCurrentSlot(this.config, this.genesisTime);
    this.signal.addEventListener("abort", () => clearTimeout(this.timeoutId), {once: true});
  }

  get currentSlot(): Slot {
    const slot = getCurrentSlot(this.config, this.genesisTime);
    if (slot > this._currentSlot) {
      clearTimeout(this.timeoutId);
      this.onNextSlot(slot);
    }
    return slot;
  }
  /**
   * If it's too close to next slot given MAXIMUM_GOSSIP_CLOCK_DISPARITY, return currentSlot + 1.
   * Otherwise return currentSlot
   *
   * Spec: phase0/p2p-interface.md - gossip validation uses `current_time + MAXIMUM_GOSSIP_CLOCK_DISPARITY < message_time`
   * to reject future messages (strict `<`), so the boundary (exactly equal) is accepted, hence `<=` here.
   */
  get currentSlotWithGossipDisparity(): Slot {
    const currentSlot = this.currentSlot;
    const nextSlotTime = computeTimeAtSlot(this.config, currentSlot + 1, this.genesisTime) * 1000;
    return nextSlotTime - Date.now() <= this.config.MAXIMUM_GOSSIP_CLOCK_DISPARITY ? currentSlot + 1 : currentSlot;
  }

  get currentEpoch(): Epoch {
    return computeEpochAtSlot(this.currentSlot);
  }

  /** Returns the slot if the internal clock were advanced by `toleranceSec`. */
  slotWithFutureTolerance(toleranceSec: number): Slot {
    // this is the same to getting slot at now + toleranceSec
    return getCurrentSlot(this.config, this.genesisTime - toleranceSec);
  }

  /** Returns the slot if the internal clock were reversed by `toleranceSec`. */
  slotWithPastTolerance(toleranceSec: number): Slot {
    // this is the same to getting slot at now - toleranceSec
    return getCurrentSlot(this.config, this.genesisTime + toleranceSec);
  }

  /**
   * Check if a slot is current slot given MAXIMUM_GOSSIP_CLOCK_DISPARITY.
   *
   * Uses `<=` for disparity checks because the spec rejects with strict `<`
   * (phase0/p2p-interface.md), meaning the boundary (exactly equal) is accepted.
   */
  isCurrentSlotGivenGossipDisparity(slot: Slot): boolean {
    const currentSlot = this.currentSlot;
    if (currentSlot === slot) {
      return true;
    }
    const nextSlotTime = computeTimeAtSlot(this.config, currentSlot + 1, this.genesisTime) * 1000;
    // we're too close to next slot, accept next slot
    if (nextSlotTime - Date.now() <= this.config.MAXIMUM_GOSSIP_CLOCK_DISPARITY) {
      return slot === currentSlot + 1;
    }
    const currentSlotTime = computeTimeAtSlot(this.config, currentSlot, this.genesisTime) * 1000;
    // we've just passed the current slot, accept previous slot
    if (Date.now() - currentSlotTime <= this.config.MAXIMUM_GOSSIP_CLOCK_DISPARITY) {
      return slot === currentSlot - 1;
    }
    return false;
  }

  async waitForSlot(slot: Slot): Promise<void> {
    if (this.signal.aborted) {
      throw new ErrorAborted();
    }

    if (this.currentSlot >= slot) {
      return;
    }

    return new Promise((resolve, reject) => {
      const onSlot = (clockSlot: Slot): void => {
        if (clockSlot >= slot) {
          onDone();
        }
      };

      const onDone = (): void => {
        this.off(ClockEvent.slot, onSlot);
        this.signal.removeEventListener("abort", onAbort);
        resolve();
      };

      const onAbort = (): void => {
        this.off(ClockEvent.slot, onSlot);
        reject(new ErrorAborted());
      };

      this.on(ClockEvent.slot, onSlot);
      this.signal.addEventListener("abort", onAbort, {once: true});
    });
  }

  secFromSlot(slot: Slot, toSec = Date.now() / 1000): number {
    return toSec - computeTimeAtSlot(this.config, slot, this.genesisTime);
  }

  msFromSlot(slot: Slot, toMs = Date.now()): number {
    return toMs - computeTimeAtSlot(this.config, slot, this.genesisTime) * 1000;
  }

  private onNextSlot = (slot?: Slot): void => {
    const clockSlot = slot ?? getCurrentSlot(this.config, this.genesisTime);
    // process multiple clock slots in the case the main thread has been saturated for > SLOT_DURATION_MS
    while (this._currentSlot < clockSlot && !this.signal.aborted) {
      const previousSlot = this._currentSlot;
      this._currentSlot++;

      this.emit(ClockEvent.slot, this._currentSlot);

      const previousEpoch = computeEpochAtSlot(previousSlot);
      const currentEpoch = computeEpochAtSlot(this._currentSlot);

      if (previousEpoch < currentEpoch) {
        this.emit(ClockEvent.epoch, currentEpoch);
      }
    }

    if (!this.signal.aborted) {
      //recursively invoke onNextSlot
      this.timeoutId = setTimeout(this.onNextSlot, this.msUntilNextSlot());
    }
  };

  private msUntilNextSlot(): number {
    const milliSecondsPerSlot = this.config.SLOT_DURATION_MS;
    const diffInMilliSeconds = Date.now() - this.genesisTime * 1000;
    return milliSecondsPerSlot - (diffInMilliSeconds % milliSecondsPerSlot);
  }
}
