import { Timer } from './Timer.js';
import { Server } from 'socket.io';
import {
  DAY_TO_MS,
  getSelectionByRoll,
  replacePlaceholder,
  updateRoll,
} from './classUtils.js';
import { OSCIntegration } from './integrations/Osc.js';
import { HTTPIntegration } from './integrations/Http.js';
import { cleanURL } from '../utils/url.js';
import getRandomName from '../utils/getRandomName.js';
import { stringFromMillis } from 'ontime-utils/time.js';
import { generateId } from 'ontime-utils/generate_id.js';

/*
 * Class EventTimer adds functions specific to APP
 * @extends Timer
 */

export class EventTimer extends Timer {
  /**
   * Instantiates an event timer object
   * @param {object} httpServer
   * @param {object} timerConfig
   * @param {object} [oscConfig]
   * @param {object} [httpConfig]
   */
  constructor(httpServer, timerConfig, oscConfig, httpConfig) {
    // call super constructor
    super();

    this.cycleState = {
      /* idle: before it is initialised */
      idle: 'idle',
      /* onLoad: when a new event is loaded */
      onLoad: 'onLoad',
      /* armed: when a new event is loaded but hasn't started */
      armed: 'armed',
      onStart: 'onStart',
      /* update: every update call cycle (1 x second) */
      onUpdate: 'onUpdate',
      onPause: 'onPause',
      onStop: 'onStop',
      onFinish: 'onFinish',
    };
    this.ontimeCycle = 'idle';
    this.prevCycle = null;

    // OSC Object
    this.osc = null;

    // HTTP Client Object
    this.http = null;

    this._numClients = 0;
    this._interval = null;

    this.presenter = {
      text: '',
      visible: false,
    };
    this.public = {
      text: '',
      visible: false,
    };
    this.lower = {
      text: '',
      visible: false,
    };

    // call general title reset
    this._resetSelection();

    this.numEvents = 0;
    this._eventlist = null;
    this.onAir = false;

    // initialise socketIO server
    this.messageStack = [];
    this.MAX_MESSAGES = 100;
    this._clientNames = {};
    this.io = new Server(httpServer, {
      cors: {
        origin: '*',
        methods: 'GET,HEAD,PUT,PATCH,POST,DELETE',
        preflightContinue: false,
        optionsSuccessStatus: 204,
      },
    });

    // set recurrent emits
    this._interval = setInterval(
      () => this.runCycle(),
      timerConfig?.refresh || 1000
    );

    // listen to new connections
    this._listenToConnections();

    if (oscConfig != null) {
      this._initOscClient(oscConfig);
    }

    if (httpConfig != null) {
      this._initHTTPClient(httpConfig);
    }
  }

  /**
   * @description Shutdown process
   */
  shutdown() {
    clearInterval(this._interval);
    this.info('SERVER', 'Shutting down ontime');
    if (this.io != null) {
      this.info('TX', '... Closing socket server');
      this.io.close();
    }
    if (this.osc != null) {
      this.info('TX', '... Closing OSC Client');
      this.osc.shutdown();
    }
    if (this.http != null) {
      this.info('TX', '... Closing HTTP Client');
      this.http.shutdown();
    }
  }

  /**
   * Initialises OSC Integration object
   * @param {object} oscConfig
   * @private
   */
  _initOscClient(oscConfig) {
    this.osc = new OSCIntegration();
    const r = this.osc.init(oscConfig);
    r.success ? this.info('TX', r.message) : this.error('TX', r.message);
  }

  /**
   * Initialises HTTP Integration object
   * @param {object} httpConfig
   * @private
   */
  _initHTTPClient(httpConfig) {
    this.info('TX', `Initialise HTTP Client on port`);
    this.http = new HTTPIntegration();
    this.http.init(httpConfig);
    this.httpMessages = httpConfig.messages;
  }

  /**
   * Sends time object over websockets
   */
  broadcastTimer() {
    // through websockets
    this.io.emit('timer', this.getTimeObject());
  }

  /**
   * Broadcasts complete object state
   */
  broadcastState() {
    this.broadcastTimer();
    this.io.emit('playstate', this.state);
    this.io.emit('selected', {
      id: this.selectedEventId,
      index: this.selectedEventIndex,
      total: this.numEvents,
    });
    this.io.emit('selected-id', this.selectedEventId);
    this.io.emit('next-id', this.nextEventId);
    this.io.emit('numevents', this.numEvents);
    this.io.emit('publicselected-id', this.selectedPublicEventId);
    this.io.emit('publicnext-id', this.nextPublicEventId);
    this.io.emit('titles', this.titles);
    this.io.emit('publictitles', this.titlesPublic);
    this.io.emit('onAir', this.onAir);
  }

  /**
   * Broadcast given message
   * @param {string} address - socket io address
   * @param {any} payload - message body
   */
  broadcastThis(address, payload) {
    this.io.emit(address, payload);
  }

  /**
   * @description Interface for triggering playback actions
   * @param {string} action - state to be triggered
   * @returns {boolean} Whether action was called
   */
  trigger(action) {
    // Todo: reply should come from status change
    let reply = true;
    switch (action) {
      case 'start':
        if (this.numEvents === 0 || this.numEvents == null) return false;
        // Call action and force update
        this.info('PLAYBACK', 'Play Mode Start');
        this.start();
        break;
      case 'pause':
        if (this.numEvents === 0 || this.numEvents == null) return false;
        // Call action and force update
        this.info('PLAYBACK', 'Play Mode Pause');
        this.pause();
        break;
      case 'stop':
        if (this.numEvents === 0 || this.numEvents == null) return false;
        // Call action and force update
        this.info('PLAYBACK', 'Play Mode Stop');
        this.stop();
        break;
      case 'roll':
        if (this.numEvents === 0 || this.numEvents == null) return false;
        // Call action and force update
        this.info('PLAYBACK', 'Play Mode Roll');
        this.roll();
        break;
      case 'previous':
        if (this.numEvents === 0 || this.numEvents == null) return false;
        // Call action and force update
        this.info('PLAYBACK', 'Play Mode Previous');
        this.previous();
        break;
      case 'next':
        if (this.numEvents === 0 || this.numEvents == null) return false;
        // Call action and force update
        this.info('PLAYBACK', 'Play Mode Next');
        this.next();
        break;
      case 'unload':
        if (this.numEvents === 0 || this.numEvents == null) return false;
        // Call action and force update
        this.info('PLAYBACK', 'Events unloaded');
        this.unload();
        break;
      case 'reload':
        if (this.numEvents === 0 || this.numEvents == null) return false;
        // Call action and force update
        this.info('PLAYBACK', 'Reloaded event');
        this.reload();
        break;
      case 'onAir':
        // Call action
        this.info('PLAYBACK', 'Going On Air');
        this.setonAir(true);
        break;
      case 'offAir':
        // Call action and force update
        this.info('PLAYBACK', 'Going Off Air');
        this.setonAir(false);
        break;
      default:
        // Error, disable flag
        this.error('RX', `Unhandled action triggered ${action}`);
        reply = false;
        break;
    }

    // update state
    this.runCycle();
    return reply;
  }

  /**
   * @description State machine checks what actions need to
   * happen at every app cycle
   */
  runCycle() {
    const h = this.httpMessages?.messages;
    let httpMessage = null;

    switch (this.ontimeCycle) {
      case 'idle':
        break;
      case 'armed':
        // if we come from roll, see if we can start
        if (this.state === 'roll') {
          this.update();
        }
        break;
      case 'onLoad':
        // broadcast change
        this.broadcastState();

        // Todo: wrap in reusable function
        // check integrations - http
        if (h?.onLoad?.enabled) {
          if (h?.onLoad?.url != null || h?.onLoad?.url !== '') {
            httpMessage = h?.onLoad?.url;
          }
        }

        // update lifecycle: armed
        this.ontimeCycle = this.cycleState.armed;
        break;
      case 'onStart':
        // broadcast current state
        this.broadcastState();
        // send OSC if there is something running
        // _finish at is only set when an event is loaded
        if (this._finishAt > 0) {
          this.sendOsc(this.osc.implemented.play);
        }

        // check integrations - http
        if (h?.onLoad?.enabled) {
          if (h?.onLoad?.url != null || h?.onStart?.url !== '') {
            httpMessage = h?.onStart?.url;
          }
        }

        // update lifecycle: onUpdate
        this.ontimeCycle = this.cycleState.onUpdate;
        break;
      case 'onUpdate':
        // call update
        this.update();
        // broadcast current state
        this.broadcastTimer();
        // through OSC, only if running
        if (this.state === 'start' || this.state === 'roll') {
          if (this.current != null && this.secondaryTimer == null) {
            this.sendOsc(this.osc.implemented.time, this.timeTag);
            this.sendOsc(
              this.osc.implemented.overtime,
              this.current > 0 ? 0 : 1
            );
            this.sendOsc(
              this.osc.implemented.title,
              this.titles?.titleNow || ''
            );
          }
        }

        // check integrations - http
        if (h?.onLoad?.enabled) {
          if (h?.onLoad?.url != null || h?.onUpdate?.url !== '') {
            httpMessage = h?.onUpdate?.url;
          }
        }

        break;
      case 'onPause':
        // broadcast current state
        this.broadcastState();
        // send OSC
        this.sendOsc(this.osc.implemented.pause);

        // check integrations - http
        if (h?.onLoad?.enabled) {
          if (h?.onLoad?.url != null || h?.onPause?.url !== '') {
            httpMessage = h?.onPause?.url;
          }
        }

        // update lifecycle: armed
        this.ontimeCycle = this.cycleState.armed;

        break;
      case 'onStop':
        // broadcast change
        this.broadcastState();

        // send OSC if something was actually stopped
        if (this.prevCycle === this.cycleState.onUpdate) {
          this.sendOsc(this.osc.implemented.stop);
        }

        // check integrations - http
        if (h?.onLoad?.enabled) {
          if (h?.onLoad?.url != null || h?.onStop?.url !== '') {
            httpMessage = h?.onStop?.url;
          }
        }

        // update lifecycle: idle
        this.ontimeCycle = this.cycleState.idle;
        break;
      case 'onFinish':
        // broadcast change
        this.broadcastState();
        // finished an event
        this.sendOsc(this.osc.implemented.finished);

        // check integrations - http
        if (h?.onLoad?.enabled) {
          if (h?.onLoad?.url != null || h?.onFinish?.url !== '') {
            httpMessage = h?.onFinish?.url;
          }
        }

        // update lifecycle: onUpdate
        this.ontimeCycle = this.cycleState.onUpdate;
        break;
      default:
        this.error('SERVER', `Unhandled cycle: ${this.ontimeCycle}`);
    }

    // send http message if any
    if (httpMessage != null) {
      const v = {
        $timer: this.timeTag,
        $title: this.titles.titleNow,
        $presenter: this.titles.presenterNow,
        $subtitle: this.titles.subtitleNow,
        '$next-title': this.titles.titleNext,
        '$next-presenter': this.titles.presenterNext,
        '$next-subtitle': this.titles.subtitleNext,
      };
      const m = cleanURL(replacePlaceholder(httpMessage, v));
      this.http.send(m);
    }

    // update
    this.update();

    // reset cycle
    this.prevCycle = this.ontimeCycle;
  }

  update() {
    // if there is nothing selected, update clock
    const now = this._getCurrentTime();

    // if we are not updating, send the timers
    if (this.ontimeCycle !== this.cycleState.onUpdate) {
      this.clock = now;
      this.broadcastThis('timer', {
        clock: now,
        running: Timer.toSeconds(this.current),
        secondary: Timer.toSeconds(this.secondaryTimer),
        durationSeconds: Timer.toSeconds(this.duration),
        expectedFinish: this._getExpectedFinish(),
        startedAt: this._startedAt,
      });
    }

    // Have we skipped onStart?
    if (this.state === 'start' || this.state === 'roll') {
      if (this.ontimeCycle === this.cycleState.armed) {
        // update lifecycle: onStart
        this.ontimeCycle = this.cycleState.onStart;
        this.runCycle();
      }
    }

    // update default functions
    super.update();

    if (this._finishedFlag) {
      // update lifecycle: onFinish and call cycle

      this.ontimeCycle = this.cycleState.onFinish;
      this._finishedFlag = false;
      this.runCycle();
    }

    // only implement roll here, rest implemented in super
    if (this.state === 'roll') {
      const u = {
        selectedEventId: this.selectedEventId,
        current: this.current,
        // safeguard on midnight rollover
        _finishAt:
          this._finishAt >= this._startedAt
            ? this._finishAt
            : this._finishAt + DAY_TO_MS,
        clock: this.clock,
        secondaryTimer: this.secondaryTimer,
        _secondaryTarget: this._secondaryTarget,
      };

      const { updatedTimer, updatedSecondaryTimer, doRollLoad, isFinished } =
        updateRoll(u);

      this.current = updatedTimer;
      this.secondaryTimer = updatedSecondaryTimer;

      if (isFinished) {
        // update lifecycle: onFinish
        this.ontimeCycle = this.cycleState.onFinish;
        this.runCycle();
      }

      if (doRollLoad) {
        this.rollLoad();
      }
    }
  }

  /**
   * Set titles and broadcast change
   * @param {string} action
   * @param {any} payload
   * @private
   */
  _setTitles(action, payload) {
    switch (action) {
      /*******************************************/
      // Presenter message
      case 'set-presenter-text':
        this.presenter.text = payload;
        this.broadcastThis('messages-presenter', this.presenter);
        break;
      case 'set-presenter-visible':
        this.presenter.visible = payload;
        this.broadcastThis('messages-presenter', this.presenter);
        break;

      /*******************************************/
      // Public message
      case 'set-public-text':
        this.public.text = payload;
        this.broadcastThis('messages-public', this.public);
        break;
      case 'set-public-visible':
        this.public.visible = payload;
        this.broadcastThis('messages-public', this.public);
        break;

      /*******************************************/
      // Lower third message
      case 'set-lower-text':
        this.lower.text = payload;
        this.broadcastThis('messages-lower', this.lower);
        break;
      case 'set-lower-visible':
        this.lower.visible = payload;
        this.broadcastThis('messages-lower', this.lower);
        break;

      default:
        break;
    }
  }

  /**
   * Handle socket io connections
   * @private
   */
  _listenToConnections() {
    this.io.on('connection', (socket) => {
      /*******************************/
      /***  HANDLE NEW CONNECTION  ***/
      /***  ---------------------  ***/
      /*******************************/
      // keep track of connections
      this._numClients++;
      this._clientNames[socket.id] = getRandomName();
      const m = `${this._numClients} Clients with new connection: ${
        this._clientNames[socket.id]
      }`;
      this.info('CLIENT', m);

      // send state
      socket.emit('timer', this.getTimeObject());
      socket.emit('playstate', this.state);
      socket.emit('selected-id', this.selectedEventId);
      socket.emit('next-id', this.nextEventId);
      socket.emit('publicselected-id', this.selectedPublicEventId);
      socket.emit('publicnext-id', this.nextPublicEventId);

      /********************************/
      /***  HANDLE DISCONNECT USER  ***/
      /***  ----------------------  ***/
      /********************************/
      socket.on('disconnect', () => {
        this._numClients--;
        const m = `${this._numClients} Clients with disconnection: ${
          this._clientNames[socket.id]
        }`;
        delete this._clientNames[socket.id];
        this.info('CLIENT', m);
      });

      /***************************************/
      /***  TIMER STATE GETTERS / SETTERS  ***/
      /***  -----------------------------  ***/
      /***************************************/

      /*******************************************/
      // general playback state
      socket.on('get-state', () => {
        socket.emit('timer', this.getTimeObject());
        socket.emit('playstate', this.state);
        socket.emit('selected-id', this.selectedEventId);
        socket.emit('next-id', this.nextEventId);
        socket.emit('publicselected-id', this.selectedPublicEventId);
        socket.emit('publicnext-id', this.this.nextPublicEventId);
      });

      /*******************************************/
      // timer
      socket.on('get-current', () => {
        socket.emit('current', this.getCurrentInSeconds());
      });

      socket.on('get-timer', () => {
        socket.emit('timer', this.getTimeObject());
      });

      socket.on('increment-timer', (data) => {
        if (isNaN(parseInt(data))) return;
        if (data < -5 || data > 5) return;
        this.increment(data * 1000 * 60);
      });

      /*******************************************/
      // playstate
      socket.on('set-playstate', (data) => {
        this.trigger(data);
      });

      socket.on('get-playstate', () => {
        socket.emit('playstate', this.state);
      });

      socket.on('set-onAir', (data) => {
        this.onAir = data;
        this.broadcastThis('onAir', this.onAir);
      });

      socket.on('get-onAir', () => {
        socket.emit('onAir', this.onAir);
      });

      /*******************************************/
      // selection data
      socket.on('get-selected', () => {
        socket.emit('selected', {
          id: this.selectedEventId,
          index: this.selectedEventIndex,
          total: this.numEvents,
        });
      });

      socket.on('get-selected-id', () => {
        socket.emit('selected-id', this.selectedEventId);
      });

      socket.on('get-numevents', () => {
        socket.emit('numevents', this.numEvents);
      });

      socket.on('get-next-id', () => {
        socket.emit('next-id', this.nextEventId);
      });

      socket.on('get-publicselected-id', () => {
        socket.emit('publicselected-id', this.selectedPublicEventId);
      });

      socket.on('get-publicnext-id', () => {
        socket.emit('publicnext-id', this.nextPublicEventId);
      });

      // title data
      socket.on('get-titles', () => {
        socket.emit('titles', this.titles);
      });

      // title data
      socket.on('get-publictitles', () => {
        socket.emit('publictitles', this.titlesPublic);
      });

      /***********************************/
      /***  MESSAGE GETTERS / SETTERS  ***/
      /***  -------------------------  ***/
      /***********************************/

      /*******************************************/

      // Messages
      socket.on('get-messages', () => {
        this.broadcastThis('messages-presenter', this.presenter);
        this.broadcastThis('messages-public', this.public);
        this.broadcastThis('messages-lower', this.lower);
      });

      // Presenter message
      socket.on('set-presenter-text', (data) => {
        this._setTitles('set-presenter-text', data);
      });

      socket.on('set-presenter-visible', (data) => {
        this._setTitles('set-presenter-visible', data);
      });

      socket.on('get-presenter', () => {
        this.broadcastThis('messages-presenter', this.presenter);
      });
      /*******************************************/
      // Public message
      socket.on('set-public-text', (data) => {
        this._setTitles('set-public-text', data);
      });

      socket.on('set-public-visible', (data) => {
        this._setTitles('set-public-visible', data);
      });

      socket.on('get-public', () => {
        socket.emit('messages-public', this.public);
      });

      /*******************************************/
      // Lower third message
      socket.on('set-lower-text', (data) => {
        this._setTitles('set-lower-text', data);
      });

      socket.on('set-lower-visible', (data) => {
        this._setTitles('set-lower-visible', data);
      });

      socket.on('get-lower', () => {
        socket.emit('messages-lower', this.lower);
      });
    });
  }

  /**
   * Deletes running event list from object
   */
  clearEventList() {
    // unload events
    this.unload();

    // set general
    this._eventlist = [];
    this.numEvents = 0;

    // update lifecycle: onStop
    this.ontimeCycle = this.cycleState.onStop;

    // update clients
    this.broadcastThis('numevents', this.numEvents);
  }

  /**
   * Adds an event list to object
   * @param {array} eventlist
   */
  setupWithEventList(eventlist) {
    if (!Array.isArray(eventlist) || eventlist.length < 1) return;

    // filter only events
    const events = eventlist.filter((e) => e.type === 'event');
    const numEvents = events.length;

    // set general
    this._eventlist = events;
    this.numEvents = numEvents;

    // list may contain no events
    if (numEvents < 1) return;

    // load first event
    this.loadEvent(0);

    // update clients
    this.broadcastState();

    // run cycle
    this.runCycle();
  }

  /**
   * Updates event list in object
   * @param {array} eventlist
   */
  updateEventList(eventlist) {
    // filter only events
    const events = eventlist.filter((e) => e.type === 'event');
    const numEvents = events.length;

    // is this the first event
    let first = this.numEvents === 0;

    // set general
    this._eventlist = events;
    this.numEvents = numEvents;

    // list may be empty
    if (numEvents < 1) {
      this.unload();
      return;
    }

    // auto load if is the only event
    if (first) {
      this.loadEvent(0);
    } else if (this.selectedEventId != null) {
      // handle reload selected
      // Look for event (order might have changed)
      const eventIndex = this._eventlist.findIndex(
        (e) => e.id === this.selectedEventId
      );

      // Maybe is missing
      if (eventIndex === -1) {
        this._resetTimers();
        this._resetSelection();
        return;
      }

      // Reload data if running
      const type = this._startedAt != null ? 'reload' : 'load';
      this.loadEvent(eventIndex, type);
    }

    // update clients
    this.broadcastState();

    // run cycle
    this.runCycle();
  }

  /**
   * Updates a single id in the object list
   * @param {string} id
   * @param {object} entry - new event object
   */
  updateSingleEvent(id, entry) {
    // find object in events
    const eventIndex = this._eventlist.findIndex((e) => e.id === id);
    if (eventIndex === -1) return;

    // update event in memory
    const e = this._eventlist[eventIndex];
    this._eventlist[eventIndex] = { ...e, ...entry };

    try {
      // check if entry is running
      if (e.id === this.selectedEventId) {
        // handle reload selected
        // Reload data if running
        let type =
          this.selectedEventId === id && this._startedAt != null
            ? 'reload'
            : 'load';
        this.loadEvent(this.selectedEventIndex, type);
      } else if (e.id === this.nextEventId) {
        // roll needs to recalculate
        if (this.state === 'roll') {
          this.rollLoad();
        }
      }

      // load titles
      if ('title' in e || 'subtitle' in e || 'presenter' in e) {
        // TODO: should be more selective on the need to load titles
        this._loadTitlesNext();
        this._loadTitlesNow();
      }
    } catch (error) {
      this.error('SERVER', error);
    }

    // update clients
    this.broadcastState();

    // run cycle
    this.runCycle();
  }

  /**
   * Deleted an event from the list by its id
   * @param {string} eventId
   */
  deleteId(eventId) {
    // find object in events
    const eventIndex = this._eventlist.findIndex((e) => e.id === eventId);
    if (eventIndex === -1) return;

    // delete event and update count
    this._eventlist.splice(eventIndex, 1);
    this.numEvents = this._eventlist.length;

    // reload data if necessary
    if (eventId === this.selectedEventId) {
      this.unload();
      return;
    }

    // update selected event index
    this.selectedEventIndex = this._eventlist.findIndex(
      (e) => e.id === this.selectedEventId
    );

    // reload titles if necessary
    if (eventId === this.nextEventId || eventId === this.nextPublicEventId) {
      this._loadTitlesNext();
    } else if (eventId === this.selectedPublicEventId) {
      this._loadTitlesNow();
    }

    // update clients
    this.broadcastState();

    // run cycle
    this.runCycle();
  }

  /**
   * @description loads an event with a given Id
   * @param {string} eventId - ID of event in eventlist
   */
  loadEventById(eventId) {
    const eventIndex = this._eventlist.findIndex((e) => e.id === eventId);

    if (eventIndex === -1) return;
    this.pause();
    this.loadEvent(eventIndex, 'load');
    // run cycle
    this.runCycle();
  }

  /**
   * @description loads an event with a given index
   * @param {number} eventIndex - Index of event in eventlist
   */
  loadEventByIndex(eventIndex) {
    if (eventIndex === -1 || eventIndex > this.numEvents) return;
    this.pause();
    this.loadEvent(eventIndex, 'load');
    // run cycle
    this.runCycle();
  }

  /**
   * Loads a given event by index
   * @param {object} eventIndex
   * @param {string} [type='load'] - 'load' or 'reload', whether we are keeping running time
   */
  loadEvent(eventIndex, type = 'load') {
    const e = this._eventlist[eventIndex];
    if (e == null) return;

    const start = e.timeStart == null || e.timeStart === '' ? 0 : e.timeStart;
    let end = e.timeEnd == null || e.timeEnd === '' ? 0 : e.timeEnd;
    // in case the end is earlier than start, we assume is the day after
    if (end < start) end += DAY_TO_MS;

    // time stuff changes on whether we keep the running clock
    if (type === 'load') {
      this._resetTimers();

      this.duration = end - start;
      this.current = this.duration;
      this.selectedEventIndex = eventIndex;
      this.selectedEventId = e.id;
    } else if (type === 'reload') {
      const now = this._getCurrentTime();
      const elapsed = this.getElapsed();

      this.duration = end - start;
      this.selectedEventIndex = eventIndex;
      this._finishAt = now + (this.duration - elapsed);
    }

    // load current titles
    this._loadTitlesNow();

    // look for event after
    this._loadTitlesNext();

    // update lifecycle: onLoad
    this.ontimeCycle = this.cycleState.onLoad;
  }

  _loadTitlesNow() {
    const e = this._eventlist[this.selectedEventIndex];
    if (e == null) return;

    // private title is always current
    // check if current is also public
    if (e.isPublic) {
      this._loadThisTitles(e, 'now');
    } else {
      this._loadThisTitles(e, 'now-private');

      // assume there is no public event
      this.titlesPublic.titleNow = null;
      this.titlesPublic.subtitleNow = null;
      this.titlesPublic.presenterNow = null;
      this.selectedPublicEventId = null;

      // if there is nothing before, return
      if (this.selectedEventIndex === 0) return;

      // iterate backwards to find it
      for (let i = this.selectedEventIndex; i >= 0; i--) {
        if (
          this._eventlist[i].type === 'event' &&
          this._eventlist[i].isPublic
        ) {
          this._loadThisTitles(this._eventlist[i], 'now-public');
          break;
        }
      }
    }
  }

  _loadThisTitles(e, type) {
    if (e == null) return;

    switch (type) {
      // now, load to both public and private
      case 'now':
        // public
        this.titlesPublic.titleNow = e.title;
        this.titlesPublic.subtitleNow = e.subtitle;
        this.titlesPublic.presenterNow = e.presenter;
        this.selectedPublicEventId = e.id;

        // private
        this.titles.titleNow = e.title;
        this.titles.subtitleNow = e.subtitle;
        this.titles.presenterNow = e.presenter;
        this.titles.noteNow = e.note;
        this.selectedEventId = e.id;

        break;
      case 'now-public':
        this.titlesPublic.titleNow = e.title;
        this.titlesPublic.subtitleNow = e.subtitle;
        this.titlesPublic.presenterNow = e.presenter;
        this.selectedPublicEventId = e.id;
        break;
      case 'now-private':
        this.titles.titleNow = e.title;
        this.titles.subtitleNow = e.subtitle;
        this.titles.presenterNow = e.presenter;
        this.titles.noteNow = e.note;
        this.selectedEventId = e.id;
        break;

      // next, load to both public and private
      case 'next':
        // public
        this.titlesPublic.titleNext = e.title;
        this.titlesPublic.subtitleNext = e.subtitle;
        this.titlesPublic.presenterNext = e.presenter;
        this.nextPublicEventId = e.id;

        // private
        this.titles.titleNext = e.title;
        this.titles.subtitleNext = e.subtitle;
        this.titles.presenterNext = e.presenter;
        this.titles.noteNext = e.note;
        this.nextEventId = e.id;
        break;
      case 'next-public':
        this.titlesPublic.titleNext = e.title;
        this.titlesPublic.subtitleNext = e.subtitle;
        this.titlesPublic.presenterNext = e.presenter;
        this.nextPublicEventId = e.id;
        break;
      case 'next-private':
        this.titles.titleNext = e.title;
        this.titles.subtitleNext = e.subtitle;
        this.titles.presenterNext = e.presenter;
        this.titles.noteNext = e.note;
        this.nextEventId = e.id;
        break;

      default:
        break;
    }
  }

  _loadTitlesNext() {
    // maybe there is nothing to load
    if (this.selectedEventIndex == null) return;

    // assume there is no next event
    this.titles.titleNext = null;
    this.titles.subtitleNext = null;
    this.titles.presenterNext = null;
    this.titles.noteNext = null;
    this.nextEventId = null;

    this.titlesPublic.titleNext = null;
    this.titlesPublic.subtitleNext = null;
    this.titlesPublic.presenterNext = null;
    this.nextPublicEventId = null;

    if (this.selectedEventIndex < this.numEvents - 1) {
      let nextPublic = false;
      let nextPrivate = false;

      for (let i = this.selectedEventIndex + 1; i < this.numEvents; i++) {
        // check that is the right type
        if (this._eventlist[i].type === 'event') {
          // if we have not set private
          if (!nextPrivate) {
            this._loadThisTitles(this._eventlist[i], 'next-private');
            nextPrivate = true;
          }

          // if event is public
          if (this._eventlist[i].isPublic) {
            this._loadThisTitles(this._eventlist[i], 'next-public');
            nextPublic = true;
          }
        }

        // Stop if both are set
        if (nextPublic && nextPrivate) break;
      }
    }
  }

  _resetSelection() {
    this.titles = {
      titleNow: null,
      subtitleNow: null,
      presenterNow: null,
      noteNow: null,
      titleNext: null,
      subtitleNext: null,
      presenterNext: null,
      noteNext: null,
    };

    this.titlesPublic = {
      titleNow: null,
      subtitleNow: null,
      presenterNow: null,
      titleNext: null,
      subtitleNext: null,
      presenterNext: null,
    };

    this.selectedEventIndex = null;
    this.selectedEventId = null;
    this.nextEventId = null;
    this.selectedPublicEventId = null;
    this.nextPublicEventId = null;
  }

  /**
   * @description Set onAir property of timer
   * @param {boolean} onAir - whether flag is active
   */
  setonAir(onAir) {
    this.onAir = onAir;
    // broadcast change
    this.broadcastThis('onAir', onAir);
  }

  start() {
    // do we need to change
    if (this.state === 'start') return;

    // if there is nothing selected, no nothing
    if (this.selectedEventId == null) return;

    // call super
    super.start();

    // update lifecycle: onStart
    this.ontimeCycle = this.cycleState.onStart;
  }

  pause() {
    // do we need to change
    if (this.state === 'pause') return;

    // if there is nothing selected, no nothing
    if (this.selectedEventId == null) return;

    // call super
    super.pause();

    // update lifecycle: onPause
    this.ontimeCycle = this.cycleState.onPause;
  }

  stop() {
    // do we need to change
    if (this.state === 'stop') return;

    // call super
    super.stop();

    // update lifecycle: onPause
    this.ontimeCycle = this.cycleState.onStop;
  }

  increment(amount) {
    // call super
    super.increment(amount);

    // run cycle
    this.runCycle();
  }

  rollLoad() {
    const now = this._getCurrentTime();
    let prevLoaded = this.selectedEventId;

    // maybe roll has already been loaded
    if (this.secondaryTimer === null) {
      this._resetTimers(true);
      this._resetSelection();
    }

    const {
      nowIndex,
      nowId,
      publicIndex,
      nextIndex,
      publicNextIndex,
      timers,
      timeToNext,
    } = getSelectionByRoll(this._eventlist, now);

    // nothing to play, unload
    if (nowIndex === null && nextIndex === null) {
      this.unload();
      this.warning('SERVER', 'Roll: no events found');
      return;
    }

    // there is something running, load
    if (nowIndex !== null) {
      // clear secondary timers
      this.secondaryTimer = null;
      this._secondaryTarget = null;

      // set timers
      this._startedAt = timers._startedAt;
      this._finishAt = timers._finishAt;
      this.duration = timers.duration;
      this.current = timers.current;

      // set selection
      this.selectedEventId = nowId;
      this.selectedEventIndex = nowIndex;
    }

    // found something to run next
    if (nextIndex != null) {
      // Set running timers
      if (nowIndex === null) {
        // only warn the first time
        if (this.secondaryTimer === null) {
          this.info('SERVER', 'Roll: waiting for event start');
        }

        // reset running timer
        // ??? should this not have been reset?
        this.current = null;

        // timer counts to next event
        this.secondaryTimer = timeToNext;
        this._secondaryTarget = this._eventlist[nextIndex].timeStart;
      }

      // TITLES: Load next private
      this._loadThisTitles(this._eventlist[nextIndex], 'next-private');
    }

    // TITLES: Load next public
    if (publicNextIndex !== null) {
      this._loadThisTitles(this._eventlist[publicNextIndex], 'next-public');
    }

    // TITLES: Load now private
    if (nowIndex !== null) {
      this._loadThisTitles(this._eventlist[nowIndex], 'now-private');
    }
    // TITLES: Load now public
    if (publicIndex !== null) {
      this._loadThisTitles(this._eventlist[publicIndex], 'now-public');
    }

    if (prevLoaded !== this.selectedEventId) {
      // update lifecycle: onLoad
      this.ontimeCycle = this.cycleState.onLoad;
      // ensure we go through onLoad cycle
      this.runCycle();
    }
  }

  roll() {
    // do we need to change
    if (this.state === 'roll') return;

    if (this.numEvents === 0 || this.numEvents == null) return;

    // set state
    this.state = 'roll';

    // update lifecycle: armed
    this.ontimeCycle = this.cycleState.armed;

    // load into event
    this.rollLoad();
  }

  previous() {
    // check that we have events to run
    if (this.numEvents < 1) return;

    // maybe this is the first event?
    if (this.selectedEventIndex === 0) return;

    // if there is no event running, go to first
    if (this.selectedEventIndex == null) {
      this.loadEvent(0);
      return;
    }

    // send OSC
    this.sendOsc(this.osc.implemented.previous);

    // change playstate
    this.pause();

    const gotoEvent =
      this.selectedEventIndex > 0 ? this.selectedEventIndex - 1 : 0;

    if (gotoEvent === this.selectedEventIndex) return;
    this.loadEvent(gotoEvent);
  }

  next() {
    // check that we have events to run
    if (this.numEvents < 1) return;

    // maybe this is the last event?
    if (this.selectedEventIndex === this.numEvents - 1) return;

    // if there is no event running, go to first
    if (this.selectedEventIndex == null) {
      this.loadEvent(0);
      return;
    }

    // send OSC
    this.sendOsc(this.osc.implemented.next);

    // change playstate
    this.pause();

    const gotoEvent =
      this.selectedEventIndex < this.numEvents - 1
        ? this.selectedEventIndex + 1
        : this.numEvents - 1;

    if (gotoEvent === this.selectedEventIndex) return;
    this.loadEvent(gotoEvent);
  }

  unload() {
    // reset timer
    this._resetTimers(true);

    // reset selected
    this._resetSelection();

    // broadcast state
    this.broadcastState();

    // reset playstate
    this.stop();
  }

  reload() {
    if (this.numEvents === 0 || this.numEvents == null) return;

    // change playstate
    this.pause();

    // send OSC
    this.sendOsc(this.osc.implemented.reload);

    // reload data
    this.loadEvent(this.selectedEventIndex);
  }

  /****************************************************************************/
  /**
   * Logger logic
   * -------------
   *
   * This should be separate of event timer, left here for convenience
   *
   */

  /**
   * Utility method, sends message and pushes into stack
   * @param {string} level
   * @param {string} origin
   * @param {string} text
   */
  _push(level, origin, text) {
    const m = {
      id: generateId(),
      level,
      origin,
      text,
      time: stringFromMillis(this._getCurrentTime()),
    };

    this.messageStack.unshift(m);
    this.io.emit('logger', m);

    if (process.env.NODE_ENV !== 'prod') {
      console.log(`[${m.level}] \t ${m.origin} \t ${m.text}`);
    }

    if (this.messageStack.length > this.MAX_MESSAGES) {
      this.messageStack.pop();
    }
  }

  /**
   * Sends a message with level LOG
   * @param {string} origin
   * @param {string} text
   */
  info(origin, text) {
    this._push('INFO', origin, text);
  }

  /**
   * Sends a message with level WARN
   * @param {string} origin
   * @param {string} text
   */
  warning(origin, text) {
    this._push('WARN', origin, text);
  }

  /**
   * Sends a message with level ERROR
   * @param {string} origin
   * @param {string} text
   */
  error(origin, text) {
    this._push('ERROR', origin, text);
  }

  /****************************************************************************/
  /**
   * Integrations
   * -------------
   *
   * Code related to integrations
   *
   */

  /**
   * Calls OSC send message and resolves reply to logger
   * @param {string} message
   * @param {any} [payload]
   */
  async sendOsc(message, payload = undefined) {
    // Todo: add disabled osc check
    const reply = await this.osc.send(message, payload);
    if (!reply.success) {
      this.error('TX', reply.message);
    }
  }
}
