import * as WebAudioApiErrors from '../modules/Errors.mjs';
import { EffectBase } from './EffectBase.mjs';

/**
 * Class representing a Pitch Shift effect.
 * 
 * A Pitch Shift performs a permanent shift in frequency between an incoming and
 * outgoing audio signal.
 * 
 * @extends EffectBase
 */
export class PitchShift extends EffectBase {

   // Effect-specific private variables
   /** @type {GainNode} */
   #inputNode; #outputNode;
   /** @type {GainNode} */
   #modGain1Node; #modGain2Node;
   /** @type {AudioBuffer} */
   #shiftDownBuffer; #shiftUpBuffer;
   /** @type {GainNode} */
   #mod1GainNode; #mod2GainNode; #mod3GainNode; #mod4GainNode;
   /** @type {AudioBufferSourceNode} */
   #mod1Node; #mod2Node; #mod3Node; #mod4Node;
   /** @type {AudioBufferSourceNode} */
   #fade1Node; #fade2Node;
   /** @type {number} */
   #shiftValue;

   // Parameter limits
   static bufferTime = 0.250;
   static delayTime = 0.250;
   static fadeTime = 0.125;
   static minShift = -1200;
   static maxShift = 700;

   /**
    * Constructs a new {@link PitchShift} effect object.
    */
   constructor(audioContext) {
      super(audioContext);

      // Required audio nodes
      this.#inputNode = new GainNode(audioContext);
      this.#outputNode = new GainNode(audioContext);
      this.#mod1GainNode = new GainNode(audioContext, { gain: 1 });
      this.#mod2GainNode = new GainNode(audioContext, { gain: 1 });
      this.#mod3GainNode = new GainNode(audioContext, { gain: 0 });
      this.#mod4GainNode = new GainNode(audioContext, { gain: 0 });
      this.#modGain1Node = new GainNode(audioContext, { gain: 1 });
      this.#modGain2Node = new GainNode(audioContext, { gain: 1 });
      const delay1 = new DelayNode(audioContext, { maxDelayTime: 1 });
      const delay2 = new DelayNode(audioContext, { maxDelayTime: 1 });

      // Delay modulation
      const length1 = PitchShift.bufferTime * audioContext.sampleRate;
      const length = length1 + ((PitchShift.bufferTime - 2*PitchShift.fadeTime) * audioContext.sampleRate);
      this.#shiftDownBuffer = audioContext.createBuffer(1, length, audioContext.sampleRate);
      {
         const p = this.#shiftDownBuffer.getChannelData(0);
         for (let i = 0; i < length1; ++i)
            p[i] = i / length1;
         for (let i = length1; i < length; ++i)
            p[i] = 0;
      }
      this.#shiftUpBuffer = audioContext.createBuffer(1, length, audioContext.sampleRate);
      {
         const p = this.#shiftUpBuffer.getChannelData(0);
         for (let i = 0; i < length1; ++i)
            p[i] = (length1 - i) / length;
         for (let i = length1; i < length; ++i)
            p[i] = 0;
      }      
      this.#mod1Node = new AudioBufferSourceNode(audioContext, { buffer: this.#shiftDownBuffer, loop: true });
      this.#mod2Node = new AudioBufferSourceNode(audioContext, { buffer: this.#shiftDownBuffer, loop: true });
      this.#mod3Node = new AudioBufferSourceNode(audioContext, { buffer: this.#shiftUpBuffer, loop: true });
      this.#mod4Node = new AudioBufferSourceNode(audioContext, { buffer: this.#shiftUpBuffer, loop: true });

      // Delay amount for changing pitch
      this.#mod1Node.connect(this.#mod1GainNode);
      this.#mod2Node.connect(this.#mod2GainNode);
      this.#mod3Node.connect(this.#mod3GainNode);
      this.#mod4Node.connect(this.#mod4GainNode);
      this.#mod1GainNode.connect(this.#modGain1Node);
      this.#mod2GainNode.connect(this.#modGain2Node);
      this.#mod3GainNode.connect(this.#modGain1Node);
      this.#mod4GainNode.connect(this.#modGain2Node);
      this.#modGain1Node.connect(delay1.delayTime);
      this.#modGain2Node.connect(delay2.delayTime);

      // Crossfading
      const fadeBuffer = audioContext.createBuffer(1, length, audioContext.sampleRate);
      {
         const p = fadeBuffer.getChannelData(0), fadeLength = PitchShift.fadeTime * audioContext.sampleRate;
         const fadeIndex1 = fadeLength, fadeIndex2 = length1 - fadeLength;
         for (let i = 0; i < length1; ++i)
            p[i] = (i < fadeIndex1) ? Math.sqrt(i / fadeLength) :
               ((i >= fadeIndex2) ? Math.sqrt(1 - (i - fadeIndex2) / fadeLength) : 1);
         for (let i = length1; i < length; ++i)
            p[i] = 0;
      }
      this.#fade1Node = new AudioBufferSourceNode(audioContext, { buffer: fadeBuffer, loop: true });
      this.#fade2Node = new AudioBufferSourceNode(audioContext, { buffer: fadeBuffer, loop: true });
      const mix1 = new GainNode(audioContext, { gain: 0 });
      const mix2 = new GainNode(audioContext, { gain: 0 });
      this.#fade1Node.connect(mix1.gain);    
      this.#fade2Node.connect(mix2.gain);
         
      // Connect processing graph
      this.#inputNode.connect(delay1);
      this.#inputNode.connect(delay2);
      delay1.connect(mix1);
      delay2.connect(mix2);
      mix1.connect(this.#outputNode);
      mix2.connect(this.#outputNode);
   }

   /**
    * Returns a list of all available parameters for manipulation in the `effectOptions` parameter
    * of the {@link EffectBase#update update()} function for this {@link Effect}.
    * 
    * @returns {EffectParameter[]} List of effect-specific parameters for use in the effect's {@link EffectBase#update update()} function
    * @see {@link EffectParameter}
    */
   static getParameters() {
      return [
         { name: 'shift', type: 'number', validValues: [PitchShift.minShift, PitchShift.maxShift], defaultValue: 0 }
      ];
   }

   async load() {
      const t = this.audioContext.currentTime + 0.050;
      const t2 = t + PitchShift.bufferTime - PitchShift.fadeTime;
      this.#modGain1Node.gain.value = 0;
      this.#modGain2Node.gain.value = 0;
      this.#mod1Node.start(t);
      this.#mod2Node.start(t2);
      this.#mod3Node.start(t);
      this.#mod4Node.start(t2);
      this.#fade1Node.start(t);
      this.#fade2Node.start(t2);
      this.#shiftValue = 0;
   }

   // Private update function for internal use only by Doppler effect
   async updatePrivate(shift, updateTime, timeWeights, duration) {
      const finalGain = 0.5 * PitchShift.delayTime * Math.abs(shift) / 1200;
      for (let i = 0; i < timeWeights.length; ++i)
         timeWeights[i] *= finalGain;
      this.#mod1GainNode.gain.cancelScheduledValues(updateTime);
      this.#mod2GainNode.gain.cancelScheduledValues(updateTime);
      this.#mod3GainNode.gain.cancelScheduledValues(updateTime);
      this.#mod4GainNode.gain.cancelScheduledValues(updateTime);
      if (shift > 0) {
         this.#mod1GainNode.gain.setTargetAtTime(0, updateTime, 0.01);
         this.#mod2GainNode.gain.setTargetAtTime(0, updateTime, 0.01);
         this.#mod3GainNode.gain.setTargetAtTime(1, updateTime, 0.01);
         this.#mod4GainNode.gain.setTargetAtTime(1, updateTime, 0.01);
      } else {
         this.#mod1GainNode.gain.setTargetAtTime(1, updateTime, 0.01);
         this.#mod2GainNode.gain.setTargetAtTime(1, updateTime, 0.01);
         this.#mod3GainNode.gain.setTargetAtTime(0, updateTime, 0.01);
         this.#mod4GainNode.gain.setTargetAtTime(0, updateTime, 0.01);
      }
      this.#modGain1Node.gain.cancelScheduledValues(updateTime);
      this.#modGain2Node.gain.cancelScheduledValues(updateTime);
      this.#modGain1Node.gain.setValueCurveAtTime(timeWeights, updateTime, duration);
      this.#modGain2Node.gain.setValueCurveAtTime(timeWeights, updateTime, duration);
      return true;
   }

   /* eslint no-empty-pattern: "off" */
   /**
    * Updates the {@link PitchShift} effect according to the specified parameters at the
    * specified time.
    * 
    * Note that the `updateTime` parameter can be omitted to immediately cause the requested
    * changes to take effect.
    * 
    * @param {number} shift - Frequency shift in cents between [-1200, 1200]
    * @param {number} [updateTime] - Global API time at which to update the effect
    * @param {number} [timeConstant] - Time constant defining an exponential approach to the target
    * @returns {Promise<boolean>} Whether the effect update was successfully applied
    */
   async update({ shift }, updateTime, timeConstant) {
      if (shift == null)
         throw new WebAudioApiErrors.WebAudioValueError('Cannot update the PitchShift effect without at least one of the following parameters: "shift"');
      else if (shift < PitchShift.minShift)
         throw new WebAudioApiErrors.WebAudioValueError(`Shift value cannot be less than ${PitchShift.minShift}`);
      else if (shift > PitchShift.maxShift)
         throw new WebAudioApiErrors.WebAudioValueError(`Shift value cannot be greater than ${PitchShift.maxShift}`);
      const timeToUpdate = (updateTime == null) ? this.audioContext.currentTime : updateTime;
      const timeConstantTarget = (timeConstant == null) ? 0.0 : timeConstant;
      this.#mod1GainNode.gain.cancelScheduledValues(timeToUpdate);
      this.#mod2GainNode.gain.cancelScheduledValues(timeToUpdate);
      this.#mod3GainNode.gain.cancelScheduledValues(timeToUpdate);
      this.#mod4GainNode.gain.cancelScheduledValues(timeToUpdate);
      if (shift > 0) {
         this.#mod1GainNode.gain.setTargetAtTime(0, timeToUpdate, 0.01);
         this.#mod2GainNode.gain.setTargetAtTime(0, timeToUpdate, 0.01);
         this.#mod3GainNode.gain.setTargetAtTime(1, timeToUpdate, 0.01);
         this.#mod4GainNode.gain.setTargetAtTime(1, timeToUpdate, 0.01);
      } else {
         this.#mod1GainNode.gain.setTargetAtTime(1, timeToUpdate, 0.01);
         this.#mod2GainNode.gain.setTargetAtTime(1, timeToUpdate, 0.01);
         this.#mod3GainNode.gain.setTargetAtTime(0, timeToUpdate, 0.01);
         this.#mod4GainNode.gain.setTargetAtTime(0, timeToUpdate, 0.01);
      }
      this.#shiftValue = shift;
      this.#modGain1Node.gain.cancelScheduledValues(timeToUpdate);
      this.#modGain2Node.gain.cancelScheduledValues(timeToUpdate);
      this.#modGain1Node.gain.setTargetAtTime(0.5 * PitchShift.delayTime * Math.abs(shift) / 1200, timeToUpdate, timeConstantTarget);
      this.#modGain2Node.gain.setTargetAtTime(0.5 * PitchShift.delayTime * Math.abs(shift) / 1200, timeToUpdate, timeConstantTarget);
      return true;
   }

   currentParameterValues() {
      return {
         shift: this.#shiftValue
      };
   }

   getInputNode() {
      return this.#inputNode;
   }

   getOutputNode() {
      return this.#outputNode;
   }
}