import {
  EVENT_PLAY_PAUSE,
  EVENT_ZOOM,
  FRAMERATE_SAMPLE_DURATION,
} from './config.js';
import {Display} from './display.js';
import {Options} from './options.js';
import {Overlay} from './overlay.js';
import {Panning} from './panning.js';
import {Pointer} from './pointer.js';
import {Select} from './select.js';
import {simOptions} from './sim-options.js';
import {initializeTools} from './sim-tools.js';
import {System} from './system.js';
import {Zoom} from './zoom.js';

export class Sim {
  info = {};
  rawTime = undefined;
  time = undefined;
  timeScale = undefined;
  nextZoom = undefined;
  playing = true;
  recentFrames = [];
  frameRate = 0;

  system = undefined;
  display = undefined;
  overlay = undefined;
  pointer = undefined;
  zoom = undefined;
  panning = undefined;
  select = undefined;

  toolbarGroups = {};
  toolbars = {};

  isCurrentMode = () => undefined;
  getCurrentMode = () => undefined;
  setCurrentMode = () => undefined;
  getOption = () => undefined;
  setOption = () => undefined;
  onModeEnter = () => undefined;
  onModeLeave = () => undefined;

  constructor(divId) {
    this.divId = divId;
    const div = document.getElementById(this.divId);
    this.div = div;

    const url = new URL(window.location.href);
    if (url.host === 'gravity.dev.laddhoffman.com') {
      document.title += ' [Dev]';
    } else if (url.host === 'gravity.local') {
      document.title += ' [Local]';
    }

    this.options = new Options(this, simOptions);
    this.display = new Display(this);
    this.system = new System(this);
    this.overlay = new Overlay(this);
    this.pointer = new Pointer(this);
    this.zoom = new Zoom(this);
    this.panning = new Panning(this);
    this.select = new Select(this);

    initializeTools(this);

    // Initiate main loop
    this.rawTime = document.timeline.currentTime;
    this.time = 0;
    requestAnimationFrame(t => this.frame(t));
  }

  // Main loop
  frame(currentTime) {
    const early = this.markFrame(currentTime);
    if (early) {
      // Slow down :)
      requestAnimationFrame(t => this.frame(t));
      return;
    }
    this.timeScale = this.getOption('param.timeScale');
    const elapsedTime = (currentTime - this.rawTime) * this.timeScale;
    this.rawTime = currentTime;
    if (this.playing) {
      this.time += elapsedTime;
    }
    if (this.getOption('debug.currentMode')) {
      this.info['Mode'] = this.getCurrentMode();
    }
    if (this.getOption('debug.frameRate')) {
      this.info['Frame Rate'] = this.frameRate.toPrecision(3);
    }
    this.zoom.frame(elapsedTime);
    this.panning.frame(elapsedTime);
    this.pointer.frame(elapsedTime);
    // The display.frame() wipes out the canvas, so all
    // main canvas drawing routines must come after the next line.
    this.display.frame(elapsedTime);
    this.select.frame(elapsedTime);
    // The system.frame() renders objects
    this.system.frame(elapsedTime);
    this.overlay.frame(elapsedTime);
    for (const group in this.toolbarGroups) {
      this.toolbarGroups[group].frame(elapsedTime);
    }
    // Schedule our next iteration
    // TODO: Consider waiting until the next frame is likely to execute,
    // in order to aim closer to the target frame rate
    requestAnimationFrame(t => this.frame(t));
  }

  // Record frame timestamp information
  markFrame(t) {
    const {recentFrames: rfs} = this;
    if (!rfs.length) {
      rfs.push(t);
      return;
    }
    let oldest = rfs[0];
    let duration = t - oldest;
    const count = rfs.length + 1;
    const frameRate = 1000 * count / duration;
    const targetFrameRate = this.getOption('display.targetFrameRate');
    if (frameRate > targetFrameRate + 1) {
      return true;
    }
    this.frameRate = frameRate;
    rfs.push(t);
    while (duration >= FRAMERATE_SAMPLE_DURATION) {
      rfs.shift();
      oldest = rfs[0];
      duration = t - oldest;
    }
    return false;
  }

  // toJSON is used to export the state of the simulation
  toJSON() {
    return {
      dateSaved: new Date().toISOString(),
      system: this.system.toJSON(),
      panning: this.panning.toJSON(),
      display: this.display.toJSON(),
      time: this.time,
      timeScale: this.timeScale,
      currentMode: this.getCurrentMode(),
    };
  }

  // fromJSON is used to import the state of the simulation
  fromJSON(state) {
    this.pause();
    this.system.fromJSON(state.system);
    this.panning.fromJSON(state.panning);
    this.display.fromJSON(state.display);
    this.time = state.time;
    this.timeScale = state.timeScale;
    this.setCurrentMode(state.currentMode);
  }

  // velocity should be in Sim coordinate scale
  scheduleZoom({x, y}, factor, velocity) {
    this.zoom.scheduleZoom({x, y}, factor, velocity);
  }

  // Transform display coordinates to simulator coordinates using scale and viewOrigin
  screenToSim(x, y) {
    return this.display.screenToSim(x, y);
  }

  // Start the simulation if it's not already running
  play() {
    this.playing = true;

    if (this.panning.paused) {
      this.panning.paused = false;
    }

    const e = new CustomEvent(EVENT_PLAY_PAUSE, {detail: {playing: this.playing}});
    this.div.dispatchEvent(e);
  }

  // Pause the simulation if it's currently running
  pause() {
    this.playing = false;

    if (this.panning?.velocity) {
      this.panning.paused = true;
    }

    const e = new CustomEvent(EVENT_PLAY_PAUSE, {detail: {playing: this.playing}});
    this.div.dispatchEvent(e);
  }

  // Generate the display content representing the current scale of the simulation
  getScaleDisplay() {
    const scale = 2 ** Math.abs(this.display.scalePower);
    const scaleText = this.display.scalePower >= 0 ? `${scale}` : `1/${scale}`;
    return `${scaleText} (${this.display.scalePower})`;
  }

  // cb: () => undefined
  onZoom(cb) {
    this.div.addEventListener(EVENT_ZOOM, () => {
      cb();
    });
  }

}
