import {
  EndAction,
  EntryId,
  isOntimeEvent,
  isPlayableEvent,
  LogOrigin,
  MaybeNumber,
  OffsetMode,
  OntimeEvent,
  Playback,
  TimerLifeCycle,
  TimerPhase,
  TimerState,
} from 'ontime-types';
import { millisToString, validatePlayback } from 'ontime-utils';

import { deepEqual } from 'fast-equals';

import { logger } from '../../classes/Logger.js';
import * as runtimeState from '../../stores/runtimeState.js';
import type { RuntimeState } from '../../stores/runtimeState.js';
import { eventStore } from '../../stores/EventStore.js';
import { triggerReportEntry } from '../../api-data/report/report.service.js';
import { timerConfig } from '../../setup/config.js';
import { triggerAutomations } from '../../api-data/automation/automation.service.js';
import { getCurrentRundown, getEntryWithId, getRundownMetadata } from '../../api-data/rundown/rundown.dao.js';

import { EventTimer } from '../EventTimer.js';
import { RestorePoint, restoreService } from '../RestoreService.js';
import { skippedOutOfEvent } from '../timerUtils.js';

import {
  filterTimedEvents,
  findNextPlayableId,
  findNextPlayableWithCue,
  findPreviousPlayableId,
  getEventAtIndex,
  getForceUpdate,
  getShouldClockUpdate,
  getShouldTimerUpdate,
} from './rundownService.utils.js';

type RuntimeStateEventKeys = keyof Pick<RuntimeState, 'eventNext' | 'eventNow'>;

/**
 * Service manages runtime status of app
 * Coordinating with necessary services
 */
class RuntimeService {
  private readonly eventTimer: EventTimer;
  private lastIntegrationClockUpdate = -1;
  private lastIntegrationTimerValue = -1;

  /** last time we updated the socket */
  static previousTimerUpdate: number;
  static previousRuntimeUpdate: number;
  static previousTimerValue: MaybeNumber; // previous timer value, could be null
  static previousClockUpdate: number;

  /** last known state */
  static previousState: RuntimeState;

  constructor(eventTimer: EventTimer) {
    this.eventTimer = eventTimer;

    RuntimeService.previousTimerUpdate = -1;
    RuntimeService.previousRuntimeUpdate = -1;
    RuntimeService.previousTimerValue = -1;
    RuntimeService.previousClockUpdate = -1;
    RuntimeService.previousState = {} as RuntimeState;
  }

  @broadcastResult
  setOffsetMode(mode: OffsetMode) {
    runtimeState.setOffsetMode(mode);
  }

  /**
   * Checks result of an update and notifies integrations as needed
   * This is the only exception of a private method that has broadcast result
   * */
  @broadcastResult
  private checkTimerUpdate({ hasTimerFinished, hasSecondaryTimerFinished }: runtimeState.UpdateResult) {
    const newState = runtimeState.getState();
    // 1. find if we need to dispatch integrations related to the phase
    const timerPhaseChanged = RuntimeService.previousState.timer?.phase !== newState.timer.phase;
    if (timerPhaseChanged) {
      if (newState.timer.phase === TimerPhase.Warning) {
        process.nextTick(() => {
          triggerAutomations(TimerLifeCycle.onWarning, newState);
        });
      } else if (newState.timer.phase === TimerPhase.Danger) {
        process.nextTick(() => {
          triggerAutomations(TimerLifeCycle.onDanger, newState);
        });
      }
    }

    // 2. handle edge cases related to roll
    if (newState.timer.playback === Playback.Roll) {
      // check if we need to call any side effects
      const keepOffset = newState.runtime.offset;
      if (hasSecondaryTimerFinished) {
        // if the secondary timer has finished, we need to call roll
        // since event is already loaded
        this.rollLoaded(keepOffset);
      } else if (hasTimerFinished) {
        // if the timer has finished, we need to load next and keep rolling
        process.nextTick(() => {
          triggerAutomations(TimerLifeCycle.onFinish, newState);
        });
        this.handleLoadNext();
        this.rollLoaded(keepOffset);
      } else if (
        // if there is no previous clock, we could not have skipped
        RuntimeService.previousState?.clock &&
        skippedOutOfEvent(newState, RuntimeService.previousState.clock, timerConfig.skipLimit)
      ) {
        // if we have skipped out of the event, we will recall roll
        // to push the playback to the right place
        // this comes with the caveat that we will lose our runtime data
        logger.warning(LogOrigin.Playback, 'Time skip detected, reloading roll');
        this.roll(true);
      }
    }

    // 3. find if we need to process actions related to the timer finishing
    if (newState.timer.playback === Playback.Play && hasTimerFinished) {
      process.nextTick(() => {
        triggerAutomations(TimerLifeCycle.onFinish, newState);
      });

      // handle end action if there was a timer playing
      // actions are added to the queue stack to ensure that the order of operations is maintained
      if (newState.eventNow) {
        if (newState.eventNow.endAction === EndAction.LoadNext) {
          setTimeout(this.loadNext.bind(this), 0);
        } else if (newState.eventNow.endAction === EndAction.PlayNext) {
          setTimeout(this.startNext.bind(this), 0);
        }
      }
    }

    // 4. find if we need to update the timer
    const shouldUpdateTimer = getShouldTimerUpdate(this.lastIntegrationTimerValue, newState.timer.current);
    if (shouldUpdateTimer) {
      process.nextTick(() => {
        triggerAutomations(TimerLifeCycle.onUpdate, newState);
      });

      this.lastIntegrationTimerValue = newState.timer.current ?? -1;
    }

    // 5. find if we need to update the clock
    const shouldUpdateClock = getShouldClockUpdate(this.lastIntegrationClockUpdate, newState.clock);
    if (shouldUpdateClock) {
      process.nextTick(() => {
        triggerAutomations(TimerLifeCycle.onClock, newState);
      });

      this.lastIntegrationClockUpdate = newState.clock;
    }
  }

  /** delay initialisation until we have a restore point */
  public init(resumable: RestorePoint | null) {
    logger.info(LogOrigin.Server, 'Runtime service started');
    this.eventTimer.setOnUpdateCallback((updateResult) => this.checkTimerUpdate(updateResult));

    if (resumable) {
      this.resume(resumable);
    }
  }

  public shutdown() {
    if (this.eventTimer) {
      logger.info(LogOrigin.Server, 'Runtime service shutting down');
      this.eventTimer.shutdown();
    }
  }

  /**
   * Checks if a list of IDs is in the current selection
   */
  private affectsLoaded(affectedIds: string[]): boolean {
    const state = runtimeState.getState();
    const now = state.eventNow?.id;
    const next = state.eventNext?.id;
    return (now !== undefined && affectedIds.includes(now)) || (next !== undefined && affectedIds.includes(next));
  }

  private isNewNext() {
    const rundown = getCurrentRundown();
    const { timedEventOrder } = getRundownMetadata();
    const timedEvents = filterTimedEvents(rundown, timedEventOrder);

    const state = runtimeState.getState();
    const now = state.eventNow?.id;
    const next = state.eventNext?.id;

    // check whether the index of now and next are consecutive
    const indexNow = timedEvents.findIndex((event) => event.id === now);
    const indexNext = timedEvents.findIndex((event) => event.id === next);

    return indexNext - indexNow !== 1;
  }

  /**
   * Called when the underlying data has changed,
   * we check if the change affects the runtime
   */
  public notifyOfChangedEvents(affectedIds?: EntryId[]) {
    const state = runtimeState.getState();
    const hasLoadedElements = state.eventNow !== null || state.eventNext !== null;
    if (!hasLoadedElements) {
      return;
    }

    // we need to reload in a few scenarios:
    // 1. we are not confident that changes do not affect running event (eg. all events where changed)
    const safeOption = affectedIds === undefined;
    // 2. the edited event is in memory (now or next) running
    // behind conditional to avoid doing unnecessary work
    const eventInMemory = safeOption ? false : this.affectsLoaded(affectedIds);
    // 3. the edited event replaces next event
    let isNext = false;

    // if we are not sure, or the event is in memory, we reload
    if (safeOption || eventInMemory) {
      if (state.eventNow !== null) {
        // load stuff again, but keep running if our events still exist
        const eventNow = getEntryWithId(state.eventNow.id);
        if (!isOntimeEvent(eventNow) || !isPlayableEvent(eventNow)) {
          // maybe the event was deleted or the skip state was changed
          runtimeState.stop();
          return;
        }
        const onlyChangedNow = affectedIds?.length === 1 && affectedIds.at(0) === eventNow.id;

        if (onlyChangedNow) {
          runtimeState.updateLoaded(eventNow);
        } else {
          const rundown = getCurrentRundown();
          const { timedEventOrder } = getRundownMetadata();
          runtimeState.updateAll(rundown, timedEventOrder);
        }
        return;
      }
    }

    // Maybe the event will become the next
    isNext = this.isNewNext();
    if (isNext) {
      const rundown = getCurrentRundown();
      const { timedEventOrder } = getRundownMetadata();
      const timedEvents = filterTimedEvents(rundown, timedEventOrder);
      runtimeState.loadNext(timedEvents);
    }
  }

  /**
   * makes calls for loading and starting given event
   * @param {PlayableEvent} event
   * @param {Partial<TimerState & RestorePoint>} initialData
   * @return {boolean} success - whether an event was loaded
   */
  private loadEvent(event: OntimeEvent, initialData?: Partial<TimerState & RestorePoint>): boolean {
    if (!isPlayableEvent(event)) {
      logger.warning(LogOrigin.Playback, `Refused skipped event with ID ${event.id}`);
      return false;
    }
    const previousState = runtimeState.getState();

    // we can ignore events which are not playable
    const rundown = getCurrentRundown();
    const rundownMetadata = getRundownMetadata();
    const success = runtimeState.load(event, rundown, rundownMetadata.playableEventOrder, initialData);

    if (success) {
      logger.info(LogOrigin.Playback, `Loaded event with ID ${event.id}`);
      const newState = runtimeState.getState();
      process.nextTick(() => {
        triggerReportEntry(TimerLifeCycle.onStop, previousState);
        triggerAutomations(TimerLifeCycle.onLoad, newState);
      });
    }
    return success;
  }

  public getRuntimeState() {
    return { playback: runtimeState.getState().timer.playback };
  }

  /**
   * starts event matching given ID
   * @param {string} eventId
   * @return {boolean} success - whether an event was started
   */
  @broadcastResult
  public startById(eventId: string): boolean {
    const event = getEntryWithId(eventId);
    if (!event || !isOntimeEvent(event)) {
      return false;
    }
    const loaded = this.loadEvent(event);
    if (!loaded) {
      return false;
    }
    return this.handleStart();
  }

  /**
   * starts an event at index
   * @param {number} eventIndex
   * @return {boolean} success - whether an event was started
   */
  @broadcastResult
  public startByIndex(eventIndex: number): boolean {
    const rundown = getCurrentRundown();
    const { timedEventOrder } = getRundownMetadata();

    const event = getEventAtIndex(rundown, timedEventOrder, eventIndex);

    if (!event) {
      return false;
    }

    const loaded = this.loadEvent(event);
    if (!loaded) {
      return false;
    }

    return this.handleStart();
  }

  /**
   * starts first event matching given cue
   * @param {string} cue
   * @return {boolean} success - whether an event was started
   */
  @broadcastResult
  public startByCue(cue: string): boolean {
    const state = runtimeState.getState();
    const rundown = getCurrentRundown();
    const { playableEventOrder } = getRundownMetadata();

    const event = findNextPlayableWithCue(
      rundown,
      playableEventOrder,
      cue,
      state.runtime.selectedEventIndex ?? undefined,
    );

    if (!event) {
      return false;
    }

    const loaded = this.loadEvent(event);
    if (!loaded) {
      return false;
    }
    return this.handleStart();
  }

  /**
   * loads event matching given ID
   * @param {string} eventId
   * @return {boolean} success - whether an event was loaded
   */
  @broadcastResult
  public loadById(eventId: string): boolean {
    const event = getEntryWithId(eventId);
    if (!event || !isOntimeEvent(event)) {
      return false;
    }
    return this.loadEvent(event);
  }

  /**
   * loads event matching given ID
   * @param {number} eventIndex
   * @return {boolean} success - whether an event was loaded
   */
  @broadcastResult
  public loadByIndex(eventIndex: number): boolean {
    const rundown = getCurrentRundown();
    const { timedEventOrder } = getRundownMetadata();

    const event = getEventAtIndex(rundown, timedEventOrder, eventIndex);

    if (!event) {
      return false;
    }
    return this.loadEvent(event);
  }

  /**
   * loads first event matching given cue
   * @param {string} cue
   * @return {boolean} success - whether an event was loaded
   */
  @broadcastResult
  public loadByCue(cue: string): boolean {
    const state = runtimeState.getState();
    const rundown = getCurrentRundown();
    const { playableEventOrder } = getRundownMetadata();

    const event = findNextPlayableWithCue(
      rundown,
      playableEventOrder,
      cue,
      state.runtime.selectedEventIndex ?? undefined,
    );

    if (!event) {
      return false;
    }

    return this.loadEvent(event);
  }

  /**
   * Contains logic for loading the previous event
   *
   * we need to isolate handleLoadPrevious so we have control over the side effects
   * startSelected being a private function does not trigger emits
   */
  private handleLoadPrevious(): boolean {
    const state = runtimeState.getState();
    const { playableEventOrder } = getRundownMetadata();

    const previousId = findPreviousPlayableId(playableEventOrder, state.eventNow?.id);
    if (previousId) {
      const previousEvent = getEntryWithId(previousId);
      if (previousEvent && isOntimeEvent(previousEvent)) {
        return this.loadEvent(previousEvent);
      }
    }

    return false;
  }

  /**
   * Loads event before currently selected
   * @return {boolean} success - whether an event was loaded
   */
  @broadcastResult
  public loadPrevious(): boolean {
    return this.handleLoadPrevious();
  }

  /**
   * Contains logic for loading the next event
   *
   * we need to isolate handleLoadNext so we have control over the side effects
   * startSelected being a private function does not trigger emits
   * and pass on runtime offset in case of roll mode
   */
  private handleLoadNext(): boolean {
    const state = runtimeState.getState();
    const { playableEventOrder } = getRundownMetadata();

    const nextId = findNextPlayableId(playableEventOrder, state.eventNow?.id);
    if (nextId) {
      const nextEvent = getEntryWithId(nextId);
      if (!nextEvent || !isOntimeEvent(nextEvent)) {
        return false;
      }

      if (state.timer.playback === Playback.Roll) {
        return this.loadEvent(nextEvent, { firstStart: state.runtime.actualStart });
      }
      return this.loadEvent(nextEvent);
    }

    logger.info(LogOrigin.Playback, 'No next event found! Continuing playback');
    return false;
  }
  /**
   * Loads event after currently selected
   * @return {boolean} success
   */
  @broadcastResult
  public loadNext(): boolean {
    return this.handleLoadNext();
  }

  /**
   * Contains logic for starting selected event
   *
   * we need to isolate handleStart so we have control over the side effects
   * startSelected being a private function does not trigger emits
   */
  private handleStart(): boolean {
    const previousState = runtimeState.getState();
    const canStart = validatePlayback(previousState.timer.playback, previousState.timer.phase).start;
    if (!canStart) {
      return false;
    }

    const didStart = this.eventTimer?.start() ?? false;
    const newState = runtimeState.getState();
    logger.info(LogOrigin.Playback, `Play Mode ${newState.timer.playback.toUpperCase()}`);

    if (didStart) {
      process.nextTick(() => {
        triggerReportEntry(TimerLifeCycle.onStart, newState);
        triggerAutomations(TimerLifeCycle.onStart, newState);
      });
    }
    return didStart;
  }

  /**
   * Starts playback on selected event
   */
  @broadcastResult
  public start(): boolean {
    return this.handleStart();
  }

  /**
   * Starts playback on previous event
   */
  @broadcastResult
  public startPrevious(): boolean {
    const hasPrevious = this.handleLoadPrevious();
    if (!hasPrevious) {
      return false;
    }

    return this.handleStart();
  }

  /**
   * Starts playback on next event
   */
  @broadcastResult
  public startNext(): boolean {
    const hasNext = this.handleLoadNext();
    if (!hasNext) {
      return false;
    }
    return this.handleStart();
  }

  /**
   * Pauses playback on selected event
   */
  @broadcastResult
  public pause() {
    const state = runtimeState.getState();
    const canPause = validatePlayback(state.timer.playback, state.timer.phase).pause;
    if (!canPause) {
      return;
    }
    this.eventTimer?.pause();
    const newState = runtimeState.getState();
    logger.info(LogOrigin.Playback, `Play Mode ${newState.timer.playback.toUpperCase()}`);
    process.nextTick(() => {
      triggerAutomations(TimerLifeCycle.onPause, newState);
    });
  }

  /**
   * Stops timer and unloads any events
   */
  @broadcastResult
  public stop(): boolean {
    const previousState = runtimeState.getState();
    const canStop = validatePlayback(previousState.timer.playback, previousState.timer.phase).stop;
    if (!canStop) {
      return false;
    }
    const didStop = this.eventTimer?.stop();
    if (didStop) {
      const newState = runtimeState.getState();
      logger.info(LogOrigin.Playback, `Play Mode ${newState.timer.playback.toUpperCase()}`);
      process.nextTick(() => {
        triggerReportEntry(TimerLifeCycle.onStop, previousState);
        triggerAutomations(TimerLifeCycle.onStop, newState);
      });

      return true;
    }
    return false;
  }

  /**
   * Reloads current event
   */
  @broadcastResult
  public reload() {
    const state = runtimeState.getState();
    if (state.eventNow) {
      return this.loadEvent(state.eventNow);
    }
    return false;
  }

  /**
   * Handles special case to call roll on a loaded event which we do not want to discard
   */
  private rollLoaded(offset?: number) {
    const rundown = getCurrentRundown();
    const { timedEventOrder } = getRundownMetadata();

    try {
      runtimeState.roll(rundown, timedEventOrder, offset);
    } catch (error) {
      logger.error(LogOrigin.Server, `Roll: ${error}`);
    }
  }

  /**
   * Sets playback to roll
   */
  @broadcastResult
  public roll(skipCheck: boolean = false) {
    const previousState = runtimeState.getState();
    if (!skipCheck) {
      const canRoll = validatePlayback(previousState.timer.playback, previousState.timer.phase).roll;
      if (!canRoll) {
        return;
      }
    }

    try {
      const rundown = getCurrentRundown();
      const rundownMetadata = getRundownMetadata();
      const result = runtimeState.roll(rundown, rundownMetadata.playableEventOrder);

      const newState = runtimeState.getState();
      if (result.eventId !== previousState.eventNow?.id) {
        logger.info(LogOrigin.Playback, `Loaded event with ID ${result.eventId}`);
        process.nextTick(() => {
          triggerReportEntry(TimerLifeCycle.onStop, previousState);
          triggerAutomations(TimerLifeCycle.onLoad, newState);
        });
      }

      if (result.didStart) {
        process.nextTick(() => {
          triggerReportEntry(TimerLifeCycle.onStart, newState);
          triggerAutomations(TimerLifeCycle.onStart, newState);
        });
      }
    } catch (error) {
      logger.error(LogOrigin.Server, `Roll: ${error}`);
      return;
    }

    const newState = runtimeState.getState();

    if (previousState.timer.playback !== newState.timer.playback) {
      logger.info(LogOrigin.Playback, `Play Mode ${newState.timer.playback.toUpperCase()}`);
    }
  }

  /**
   * @description resume playback state given a restore point
   * @param restorePoint
   */
  @broadcastResult
  public resume(restorePoint: RestorePoint) {
    const { selectedEventId, playback } = restorePoint;
    if (playback === Playback.Roll) {
      this.roll();
      return;
    }

    if (!selectedEventId) {
      return;
    }

    // the db would have to change for the event not to exist
    // we do not know the reason for the crash, so we check anyway
    const event = getEntryWithId(selectedEventId);
    if (!isOntimeEvent(event) || !isPlayableEvent(event)) {
      return;
    }

    const rundown = getCurrentRundown();
    const rundownMetadata = getRundownMetadata();
    runtimeState.resume(restorePoint, event, rundown, rundownMetadata.playableEventOrder);

    logger.info(LogOrigin.Playback, 'Resuming playback');
  }

  /**
   * Adds time to current event
   * @param {number} time - time to add in milliseconds
   */
  @broadcastResult
  public addTime(time: number) {
    if (this.eventTimer.addTime(time)) {
      logger.info(LogOrigin.Playback, `${time > 0 ? 'Added' : 'Removed'} ${millisToString(time)}`);
    }
  }
}

// calculate at 30fps, refresh at 1fps
const eventTimer = new EventTimer({
  refresh: timerConfig.updateRate,
  updateInterval: timerConfig.notificationRate,
});
export const runtimeService = new RuntimeService(eventTimer);

/**
 * Decorator manages side effects from updating the runtime
 * This should only be applied to functions that are exposed for consumption
 * ie: whenever an external service makes a request, we update the state with the mutation result
 */
function broadcastResult(_target: any, _propertyKey: string, descriptor: PropertyDescriptor) {
  const originalMethod = descriptor.value;

  descriptor.value = function (...args: any[]) {
    // call the original method and get the state
    const result = originalMethod.apply(this, args);
    const state = runtimeState.getState();

    const batch = eventStore.createBatch();

    // we do the comparison by explicitly for each property
    // to apply custom logic for different datasets

    // if a new event was loaded most things should update
    const hasNewLoaded = state.eventNow?.id !== RuntimeService.previousState?.eventNow?.id;

    // for the very fist run there will be nothing in the previousState so we force an update
    const justStarted = !RuntimeService.previousState?.timer;

    // offset mode has been changed
    const offsetModeChanged = RuntimeService.previousState?.runtime?.offsetMode !== state.runtime.offsetMode;

    // if playback changes most things should update
    const hasChangedPlayback = RuntimeService.previousState.timer?.playback !== state.timer.playback;

    // combine all big changes
    const hasImmediateChanges = hasNewLoaded || justStarted || hasChangedPlayback || offsetModeChanged;

    // we would like the wall clock to tick on a regular rate
    const normalClockUpdate =
      getShouldClockUpdate(RuntimeService.previousClockUpdate, state.clock) ||
      getForceUpdate(RuntimeService.previousClockUpdate, state.clock);

    /**
     * Timer should be updated if
     * - big changes
     * - notification rate has been exceeded
     * - the timer has rolled over into the next UI display unit
     *
     * Then check if there is actually a change in the data
     */
    const shouldUpdateTimer =
      (hasImmediateChanges ||
        getForceUpdate(RuntimeService.previousTimerUpdate, state.clock) ||
        getShouldTimerUpdate(RuntimeService.previousTimerValue, state.timer.current)) &&
      !deepEqual(RuntimeService.previousState?.timer, state.timer);

    /**
     * Runtime should be updated if
     * - clock tick
     * - big changes
     * - the timer is updating so runtime also updates to keep them in sync ???
     * - notification rate has been exceeded
     *
     * Then check if there is actually a change in the data
     */
    const shouldRuntimeUpdate =
      (normalClockUpdate ||
        hasImmediateChanges ||
        shouldUpdateTimer ||
        getForceUpdate(RuntimeService.previousRuntimeUpdate, state.clock)) &&
      !deepEqual(RuntimeService.previousState?.runtime, state.runtime);

    /**
     * the currentBlock object has no ticking values so we only need to check for equality
     */
    const shouldBlockUpdate = !deepEqual(RuntimeService?.previousState.currentBlock, state.currentBlock);

    /**
     * Many other values are calculated based on the clock
     * so if any of them are updated we also need to send the clock
     * in case nothing else is updating the clock will be updated at the notification rate
     */
    const shouldUpdateClock = shouldRuntimeUpdate || shouldBlockUpdate || normalClockUpdate;

    //Now we set all the updates on the eventstore and update the previous value
    if (hasChangedPlayback) {
      batch.add('onAir', state.timer.playback !== Playback.Stop);
    }

    if (shouldUpdateTimer) {
      batch.add('timer', state.timer);
      RuntimeService.previousTimerUpdate = state.clock;
      RuntimeService.previousTimerValue = state.timer.current;
      RuntimeService.previousState.timer = { ...state.timer };
    }

    if (shouldRuntimeUpdate) {
      batch.add('runtime', state.runtime);
      RuntimeService.previousRuntimeUpdate = state.clock;
      RuntimeService.previousState.runtime = { ...state.runtime };
    }

    if (shouldBlockUpdate) {
      batch.add('currentBlock', state.currentBlock);
      RuntimeService.previousState.currentBlock = { ...state.currentBlock };
    }

    if (hasImmediateChanges) {
      saveRestoreState(state);
    }

    if (shouldUpdateClock) {
      RuntimeService.previousClockUpdate = state.clock;
      batch.add('clock', state.clock);
    }

    // Update the events if they have changed
    updateEventIfChanged('eventNow', state);
    updateEventIfChanged('eventNext', state);

    // Helper function to update an event if it has changed
    function updateEventIfChanged(eventKey: RuntimeStateEventKeys, state: runtimeState.RuntimeState) {
      const previous = RuntimeService.previousState?.[eventKey];
      const now = state[eventKey];

      // if there was nothing, and there is nothing, noop
      if (!previous?.id && !now?.id) {
        return;
      }

      // if load status changed, save new
      if (previous?.id !== now?.id) {
        storeKey(eventKey);
        return;
      }

      // maybe the event itself has changed
      if (!deepEqual(RuntimeService.previousState?.[eventKey], state[eventKey])) {
        storeKey(eventKey);
        return;
      }

      function storeKey(eventKey: RuntimeStateEventKeys) {
        batch.add(eventKey, state[eventKey]);
        // @ts-expect-error -- not sure how to type this in a sane way
        RuntimeService.previousState[eventKey] = { ...state[eventKey] };
      }
    }

    // Helper function to save the restore state
    function saveRestoreState(state: runtimeState.RuntimeState) {
      restoreService
        .save({
          playback: state.timer.playback,
          selectedEventId: state.eventNow?.id ?? null,
          startedAt: state.timer.startedAt,
          addedTime: state.timer.addedTime,
          pausedAt: state._timer.pausedAt,
          firstStart: state.runtime.actualStart,
          blockStartAt: state.currentBlock.startedAt,
        })
        .catch((_e) => {
          //we don't do anything with the error here
        });
    }

    batch.send();
    return result;
  };

  return descriptor;
}
