import { Note } from './types';

class Synthesizer {
  audioContext: AudioContext;
  notes: Note[];
  position: number;
  tempo: number;
  tempoOffset: number;
  notesOn: { [key: number]: () => void };
  eventTimeout: any;
  lastHandledTime: number;
  destroyed: boolean;

  summer: GainNode;
  compressor: DynamicsCompressorNode;

  constructor() {
    this.audioContext = new AudioContext();
    this.compressor = this.audioContext.createDynamicsCompressor();

    this.summer = this.audioContext.createGain();
    this.summer.gain.setValueAtTime(0.5, this.audioContext.currentTime);
    this.summer.connect(this.compressor);
    this.compressor.connect(this.audioContext.destination);
    this.notes = [];
    this.position = 0;
    this.tempo = 120;
    this.tempoOffset = 0;

    this.eventTimeout = null;
    this.lastHandledTime = 0;
    this.notesOn = {};
    this.destroyed = false;
  }

  clearTimeout() {
    if (this.eventTimeout !== null) {
      window.clearTimeout(this.eventTimeout);
      this.eventTimeout = null;
    }
  }

  handleTimeout() {
    if (this.destroyed) {
      return;
    }
    this.eventTimeout = null;

    const now = this.audioContext.currentTime;
    const dt = Math.max(now - this.lastHandledTime, 0.001);
    this.lastHandledTime = now;
    const lastPosition = this.position;
    const tempo = this.tempo + this.tempoOffset;
    this.position += (dt / 60) * tempo;

    let nextPosition = Infinity;
    for (let note of this.notes) {
      if (
        note.onBeat >= lastPosition &&
        note.onBeat < this.position &&
        !this.notesOn[note.note]
      ) {
        console.log(note.onBeat);
        this.playNote(note.note);
      }
      if (note.offBeat >= lastPosition && note.offBeat < this.position) {
        const stop = this.notesOn[note.note];
        stop && stop();
      }
      if (note.onBeat >= this.position) {
        nextPosition = Math.min(nextPosition, note.onBeat);
      }
      if (note.offBeat >= this.position && this.notesOn[note.note]) {
        nextPosition = Math.min(nextPosition, note.offBeat);
      }
    }

    if (nextPosition === Infinity) {
      return;
    } else if (nextPosition === this.position) {
      nextPosition += 1.0e-6;
    }

    const dtNext = ((nextPosition - this.position) / tempo) * 60;
    this.eventTimeout = window.setTimeout(
      () => this.handleTimeout(),
      dtNext * 1000
    );
  }

  stop() {
    this.clearTimeout();
    for (const stop of Object.values(this.notesOn)) {
      stop();
    }
  }

  isPlaying() {
    return this.eventTimeout !== null;
  }

  reset() {
    if (this.isPlaying()) {
      this.stop();
      this.play();
    }
  }

  setNotes(newNotes: Note[], autoOffset = false, noTicks = false) {
    const minNote = Math.min(...newNotes.map((n) => n.note));
    const maxNote = Math.max(...newNotes.map((n) => n.note));
    let offset = 0;
    if (autoOffset && (minNote > 0) && maxNote) {
      while (minNote + offset < 48 && maxNote + offset < 64) {
        offset += 12;
      }

      while (minNote + offset >= 48 && maxNote + offset >= 64) {
        offset -= 12;
      }
    }

    const newNotesWithTicks = [...newNotes];

    if (!noTicks) {
      const maxTime = Math.max(...newNotes.map((n) => n.offBeat));

      for (let i = 0; i < maxTime; i++) {
        newNotesWithTicks.push({
          note: i % 4 === 0 ? -1 : -2,
          onBeat: i,
          offBeat: i + 1,
          onVelocity: 127,
          offVelocity: 127,
          sourceTrack: -1,
        });
      }
    }

    this.notes = newNotesWithTicks.map((n) => ({ ...n, n: n.note + offset }));
    this.reset();
  }

  setPosition(newPosition: number) {
    this.position = newPosition;
    this.reset();
  }

  setTempo(newTempo: number) {
    this.tempo = newTempo;
    this.reset();
  }

  setTempoOffset(newTempoOffset: number) {
    this.tempoOffset = newTempoOffset;
    this.handleTimeout();
  }

  play() {
    if (this.isPlaying() || this.destroyed) {
      return;
    }
    this.audioContext.resume();
    this.lastHandledTime = this.audioContext.currentTime;
    this.handleTimeout();
  }

  playTick(startFreq: number, osc: OscillatorNode, gainNode: GainNode) {
    osc.type = "sine";
    const now = this.audioContext.currentTime;
    gainNode.gain.setValueAtTime(2, now);
    gainNode.gain.linearRampToValueAtTime(1.0e-20, now + 0.02);
    osc.frequency.setValueAtTime(startFreq, now);
    osc.frequency.exponentialRampToValueAtTime(startFreq * 0.6, now + 0.02);
    osc.start(now);
  }

  playNote(note: number) {
    if (this.notesOn[note] || this.destroyed) {
      return;
    }
    const osc = this.audioContext.createOscillator();
    const gainNode = this.audioContext.createGain();
    const stopSecs = 0.01;
    const zeroAmp = 1.0e-20;
    osc.connect(gainNode);
    gainNode.connect(this.summer);
    if (note === -1) {
      this.playTick(1500, osc, gainNode);
    } else if (note === -2) {
      this.playTick(750, osc, gainNode);
    } else {
      const startSecs = 0;
      const sustainAmp = 0.8;
      const freq = 440.0 * Math.pow(2.0, (note - 69) / 12);
      osc.type = "sawtooth";
      const now = this.audioContext.currentTime;
      gainNode.gain.setValueAtTime(zeroAmp, now);
      osc.frequency.value = freq;
      gainNode.gain.exponentialRampToValueAtTime(sustainAmp, now + startSecs);
      osc.start(now);
    }

    this.notesOn[note] = () => {
      delete this.notesOn[note];
      const now = this.audioContext.currentTime;
      gainNode.gain.cancelAndHoldAtTime(now);
      gainNode.gain.setValueAtTime(gainNode.gain.value, now);
      gainNode.gain.exponentialRampToValueAtTime(zeroAmp, now + stopSecs);
      setTimeout(() => {
        osc.stop();
        osc.disconnect();
        gainNode.disconnect();
      }, 2000 * stopSecs);
    };
  }

  destroy() {
    this.stop();
    this.destroyed = true;
  }
}

export default Synthesizer;
