import _ from 'underscore';
import { ge as greaterEqual, gt as greaterThan, lt as lowerThan } from 'binary-search-bounds';

export enum AutomationTargetType {
  UNDEFINED = 0,
  VOLUME = 1,
  PAN = 2,
  AUDIO_PLUGIN = 3,
}

/**
 * The target of an automation.
 *
 * i.e. Volume, Pan, a param of an audio plugin, etc.
 */
export class AutomationTarget {
  private type: AutomationTargetType;
  private pluginInstanceId?: string;
  private paramId?: string;

  /**
   *
   * @param type Type of the automation target.
   * @param pluginInstanceId The instance id of the plugin, required if type is AUDIO_PLUGIN.
   * @param paramId The paramId of the plugin automation param, required if type is AUDIO_PLUGIN.
   */
  constructor(type: AutomationTargetType, pluginInstanceId?: string, paramId?: string) {
    this.type = type;
    this.pluginInstanceId = pluginInstanceId;
    this.paramId = paramId;
  }

  /** Gets the type of the target. */
  getType() {
    return this.type;
  }

  setType(type: AutomationTargetType) {
    this.type = type;
  }

  /**
   * The local id of the target track plugin.
   *
   * Available if type is `AUDIO_PLUGIN`.
   */
  getPluginInstanceId() {
    return this.pluginInstanceId;
  }

  /**
   *
   * @param pluginInstanceId The instance id of the track plugin that can be retrieved from `AudioPlugin.getInstanceId`.
   */
  setPluginInstanceId(pluginInstanceId?: string) {
    this.pluginInstanceId = pluginInstanceId;
  }

  /**
   * The paramId of the plugin automation param.
   *
   * Available if type is `AUDIO_PLUGIN`.
   */
  getParamId() {
    return this.paramId;
  }

  setParamId(paramId?: string) {
    this.paramId = paramId;
  }

  equals(target: AutomationTarget) {
    return AutomationTarget.areAutomationTargetsEqual(
      this.getType(),
      target.getType(),
      this.getPluginInstanceId(),
      target.getPluginInstanceId(),
      this.getParamId(),
      target.getParamId(),
    );
  }

  clone() {
    return new AutomationTarget(this.type, this.pluginInstanceId, this.paramId);
  }

  /** Gets a unique id that identifies this target type. */
  toTfAutomationTargetId() {
    return AutomationTarget.encodeAutomationTarget(this.type, this.pluginInstanceId, this.paramId);
  }

  static fromTfAutomationTargetId(tfAutomationTargetId: string) {
    return AutomationTarget.decodeAutomationTarget(tfAutomationTargetId);
  }

  static encodeAutomationTarget(
    targetType: AutomationTargetType,
    pluginInstanceId?: string,
    paramId?: string,
  ) {
    if (targetType === AutomationTargetType.AUDIO_PLUGIN) {
      return `${targetType}^^${pluginInstanceId}^^${paramId}`;
    }
    return `${targetType}`;
  }

  static decodeAutomationTarget(encodedTarget: string) {
    const parts = encodedTarget.split('^^');
    if (parts.length === 0) {
      throw new Error(`Invalid automation target id: ${encodedTarget}`);
    }
    const type = Number(parts[0]);
    if (parts.length > 2) {
      return new AutomationTarget(type, parts[1], parts[2]);
    }
    return new AutomationTarget(type);
  }

  static areAutomationTargetsEqual(
    targetType1: AutomationTargetType,
    targetType2: AutomationTargetType,
    pluginInstanceId1?: string,
    pluginInstanceId2?: string,
    paramId1?: string,
    paramId2?: string,
  ) {
    return (
      AutomationTarget.encodeAutomationTarget(targetType1, pluginInstanceId1, paramId1) ===
      AutomationTarget.encodeAutomationTarget(targetType2, pluginInstanceId2, paramId2)
    );
  }
}

/** A single point in an automation curve. */
export interface AutomationPoint {
  /** An int32. */
  id: number;
  tick: number;
  value: number;
}

/** The points and settings of an automation param. */
export class AutomationValue {
  private points: AutomationPoint[] = [];
  private disabled = false;
  private nextPointIdInternal = 1;

  getDisabled() {
    return this.disabled;
  }

  setDisabled(isDisabled: boolean) {
    this.disabled = isDisabled;
  }

  getPoints() {
    return this.points;
  }

  getPointsInRange(startTick: number, endTick: number) {
    return AutomationValue.getPointsInRangeImpl(this.points, startTick, endTick);
  }

  private static getPointsInRangeImpl(
    points: AutomationPoint[],
    startTick: number,
    endTick: number,
  ) {
    const startIndex = greaterEqual(
      points,
      { tick: startTick } as AutomationPoint,
      (a, b) => a.tick - b.tick,
    );
    const results = [];
    for (let i = startIndex; i < points.length; i += 1) {
      const point = points[i];
      if (point.tick > endTick) {
        break;
      }
      results.push(point);
    }
    return results;
  }

  /**
   *
   * @param overwrite Whether to overwrite the points at the insert tick.
   */
  addPoint(tick: number, value: number, overwrite = false) {
    const newPoint: AutomationPoint = {
      tick,
      value: Math.max(0, Math.min(1, value)),
      id: this.getNextPointIdInternal(),
    };
    AutomationValue.orderedInsertPointInternal(this.points, newPoint, overwrite);
    return newPoint;
  }

  /**
   * Remove points that match the given ids.
   * @param pointIds
   */
  removePoints(pointIds: number[]) {
    const idSet = new Set<number>(pointIds);
    for (let i = this.points.length - 1; i >= 0; i -= 1) {
      const point = this.points[i];
      if (idSet.has(point.id)) {
        this.points.splice(i, 1);
      }
    }
  }

  /**
   * Removes all points within the given time range.
   * @param startTick Inclusive
   * @param endTick Inclusive
   */
  removePointsInRange(startTick: number, endTick: number) {
    const startIndex = greaterEqual(
      this.points,
      { tick: startTick } as AutomationPoint,
      (a, b) => a.tick - b.tick,
    );
    if (startIndex >= this.points.length) {
      return;
    }
    let endIndex = startIndex;
    while (endIndex + 1 < this.points.length && this.points[endIndex + 1].tick <= endTick) {
      endIndex += 1;
    }
    this.points.splice(startIndex, endIndex - startIndex + 1);
  }

  movePointsInRange(
    startTick: number,
    endTick: number,
    offsetTick: number,
    offsetValue: number,
    overwriteValuesInDragArea = true,
  ) {
    const points = this.getPointsInRange(startTick, endTick);
    this.movePoints(
      points.map(point => point.id),
      offsetTick,
      offsetValue,
      overwriteValuesInDragArea,
    );
  }

  moveAllPoints(offsetTick: number, offsetValue: number, overwriteValuesInDragArea = true) {
    this.movePoints(
      this.points.map(point => point.id),
      offsetTick,
      offsetValue,
      overwriteValuesInDragArea,
    );
  }

  /**
   *
   * @param pointIds
   * @param offsetTick
   * @param offsetValue
   * @param overwriteValuesInDragArea If true, all values in between the moved points' old and new indexes will be removed.
   */
  movePoints(
    pointIds: number[],
    offsetTick: number,
    offsetValue: number,
    overwriteValuesInDragArea = true,
  ) {
    if (pointIds.length === 0) {
      return;
    }
    const pointIdSet = new Set<number>(pointIds);
    let dragAreaLeftIndex: number | undefined = undefined;
    let dragAreaRightIndex: number | undefined = undefined;
    const selectedPoints = [];
    for (let i = 0; i < this.points.length; i += 1) {
      const point = this.points[i];
      if (!pointIdSet.has(point.id)) {
        continue;
      }
      selectedPoints.push(point);
      if (dragAreaLeftIndex === undefined) {
        dragAreaLeftIndex = i;
      }
      dragAreaRightIndex = i;
    }
    if (dragAreaLeftIndex === undefined || dragAreaRightIndex === undefined) {
      // None of the given points are not in the automation.
      return;
    }
    if (overwriteValuesInDragArea) {
      // Remove values in drag area.
      if (offsetTick < 0) {
        // Move left, remove values to the left.
        const selectedPointsLeftAfterMove = Math.max(
          0,
          this.points[dragAreaLeftIndex].tick + offsetTick,
        );
        const startRemoveIndex = greaterThan(
          this.points,
          { tick: selectedPointsLeftAfterMove } as AutomationPoint,
          (a, b) => a.tick - b.tick,
        );
        if (startRemoveIndex < dragAreaLeftIndex) {
          this.points.splice(startRemoveIndex, dragAreaLeftIndex - startRemoveIndex);
        }
      } else if (offsetTick > 0) {
        // Move right, remove values to the right.
        const selectedPointsRightAfterMove = this.points[dragAreaRightIndex].tick + offsetTick;
        const endRemoveIndex = lowerThan(
          this.points,
          { tick: selectedPointsRightAfterMove } as AutomationPoint,
          (a, b) => a.tick - b.tick,
        );
        if (endRemoveIndex > dragAreaRightIndex) {
          this.points.splice(dragAreaRightIndex + 1, endRemoveIndex - dragAreaRightIndex);
        }
      }
    }
    for (const point of selectedPoints) {
      point.tick = Math.max(0, point.tick + offsetTick);
      point.value = Math.max(0, Math.min(1, point.value + offsetValue));
    }
    // Maintain the order of points.
    if (Math.abs(offsetTick) > 0) {
      this.points.sort((a, b) => a.tick - b.tick);
    }
  }

  clone() {
    const newAutomationValue = new AutomationValue();
    newAutomationValue.setDisabled(this.disabled);
    for (const point of this.points) {
      newAutomationValue.addPoint(point.tick, point.value, /* overwrite= */ false);
    }
    return newAutomationValue;
  }

  private getNextPointIdInternal() {
    const pointId = this.nextPointIdInternal;
    if (this.nextPointIdInternal >= /* 2^31 -1 */ 2147483647) {
      this.nextPointIdInternal = 1;
    } else {
      this.nextPointIdInternal += 1;
    }

    return pointId;
  }

  private static orderedInsertPointInternal(
    points: AutomationPoint[],
    newPoint: AutomationPoint,
    overwrite = false,
  ) {
    const insertIndex = greaterEqual(
      points,
      newPoint,
      (a: AutomationPoint, b: AutomationPoint) => a.tick - b.tick,
    );

    while (overwrite && points[insertIndex] && points[insertIndex].tick === newPoint.tick) {
      // Remove the points at the insert tick.
      points.splice(insertIndex, 1);
    }

    points.splice(insertIndex, 0, newPoint);
  }
}

/**
 * All automation data of one entity (such as a track).
 *
 * Each `AutomationData` consists of several automation targets(`AutomationTarget`) and
 * the values(`AutomationValue`) store in unique targets.
 *
 * Note that there can be duplicate targets, but targets of the same type write to
 * and read from the same automation value.
 *
 * For example, there can be multiple Volume targets, each Volume target corresponds to
 * the same automation value.
 */
export class AutomationData {
  private targets: AutomationTarget[] = [];

  private targetValues: { [tfAutomationTargetId: string]: AutomationValue | undefined } = {};

  /** All automation targets specified by the user. */
  getAutomationTargets() {
    return this.targets;
  }

  /** Values of each unique automation target. */
  getAutomationTargetValues() {
    return this.targetValues;
  }

  /**
   *
   * @param tfAutomationTargetId The targetId that can be retrieved from `AutomationTarget.prototype.toTfAutomationTargetId` or `AutomationTarget.encodeAutomationTarget`.
   * @returns The automation value of the given target if exists, otherwise creates a new one and returns it.
   */
  getOrCreateAutomationValueById(tfAutomationTargetId: string): AutomationValue {
    if (!this.targetValues[tfAutomationTargetId]) {
      this.targetValues[tfAutomationTargetId] = new AutomationValue();
    }
    return this.targetValues[tfAutomationTargetId] as AutomationValue;
  }

  /**
   *
   * Gets or creates the automation points and settings of an automation target.
   * @param tfAutomationTargetId The targetId that can be retrieved from `AutomationTarget.prototype.toTfAutomationTargetId` or `AutomationTarget.encodeAutomationTarget`.
   */
  getAutomationValueById(tfAutomationTargetId: string) {
    return this.targetValues[tfAutomationTargetId];
  }

  getAutomationValueByTarget(target: AutomationTarget) {
    const tfAutomationTargetId = target.toTfAutomationTargetId();
    return this.getAutomationValueById(tfAutomationTargetId);
  }

  /** Adds an automation target, if there was no such target, creates the automation value. */
  addAutomation(target: AutomationTarget, index = 0) {
    if (!_.isNumber(index)) {
      index = 0;
    }
    this.targets.splice(index, 0, target);

    const tfAutomationTargetId = target.toTfAutomationTargetId();
    if (!this.getAutomationValueById(tfAutomationTargetId)) {
      this.targetValues[tfAutomationTargetId] = new AutomationValue();
    }
  }

  /** Removes all automation targets of the given type and its automation value. */
  removeAutomation(target: AutomationTarget) {
    // Remove targets.
    for (let i = this.targets.length - 1; i >= 0; i -= 1) {
      const existingTarget = this.targets[i];
      if (existingTarget.equals(target)) {
        this.targets.splice(i, 1);
      }
    }

    // Remove values.
    const tfAutomationTargetId = target.toTfAutomationTargetId();
    delete this.targetValues[tfAutomationTargetId];
  }

  /** Remove all automations associated with a certain plugin. */
  removeAutomationOfPlugin(pluginInstanceId: string) {
    for (let i = this.targets.length - 1; i >= 0; i -= 1) {
      const automationTarget = this.targets[i];
      if (automationTarget.getPluginInstanceId() === pluginInstanceId) {
        this.removeAutomation(automationTarget);
      }
    }
    for (const tfAutomationTargetId of _.keys(this.targetValues)) {
      const automationTarget = AutomationTarget.decodeAutomationTarget(tfAutomationTargetId);
      if (automationTarget.getPluginInstanceId() === pluginInstanceId) {
        this.removeAutomation(automationTarget);
      }
    }
  }

  /**
   *
   * @param startTick Inclusive
   * @param endTick Inclusive
   */
  removeAllPointsWithinRange(startTick: number, endTick: number) {
    for (const tfAutomationTargetId of _.keys(this.targetValues)) {
      const automationValue = this.targetValues[tfAutomationTargetId] as AutomationValue;
      automationValue.removePointsInRange(startTick, endTick);
    }
  }

  /**
   *
   * @param startTick Inclusive
   * @param endTick Inclusive
   */
  moveAllPointsWithinRange(
    startTick: number,
    endTick: number,
    offsetTick: number,
    offsetValue: number,
  ) {
    for (const tfAutomationTargetId of _.keys(this.targetValues)) {
      const automationValue = this.targetValues[tfAutomationTargetId] as AutomationValue;
      automationValue.movePointsInRange(
        startTick,
        endTick,
        offsetTick,
        offsetValue,
        /* overwriteValuesInDragArea= */ false,
      );
    }
  }

  /** Creates a clone of this automation data. */
  clone() {
    const newAutomationData = new AutomationData();
    for (const target of this.targets) {
      newAutomationData.addAutomation(target.clone());
    }
    for (const tfAutomationTargetId of _.keys(this.targetValues)) {
      const targetValue = this.targetValues[tfAutomationTargetId] as AutomationValue;
      newAutomationData.targetValues[tfAutomationTargetId] = targetValue.clone();
    }
    return newAutomationData;
  }
}
