// @ts-check
const { containsNonBmpChars } = require('../util')
const {
  ApplyError,
  InvalidInsertionError,
  UnprocessableError,
} = require('../errors')
const ClearTrackingProps = require('../file_data/clear_tracking_props')
const TrackingProps = require('../file_data/tracking_props')

/**
 * @import { RawScanOp, RawInsertOp, RawRetainOp, RawRemoveOp, TrackingDirective } from '../types'
 *
 * @typedef {{ length: number, inputCursor: number, readonly inputLength: number}} LengthApplyContext
 */

class ScanOp {
  constructor() {
    if (this.constructor === ScanOp) {
      throw new Error('Cannot instantiate abstract class')
    }
  }

  /**
   * Applies an operation to a length
   * @param {LengthApplyContext} current
   * @returns {LengthApplyContext}
   */
  applyToLength(current) {
    throw new Error('abstract method')
  }

  /**
   * @returns {RawScanOp}
   */
  toJSON() {
    throw new Error('abstract method')
  }

  /**
   * @param {RawScanOp} raw
   * @returns {ScanOp}
   */
  static fromJSON(raw) {
    if (isRetain(raw)) {
      return RetainOp.fromJSON(raw)
    } else if (isInsert(raw)) {
      return InsertOp.fromJSON(raw)
    } else if (isRemove(raw)) {
      return RemoveOp.fromJSON(raw)
    }
    throw new UnprocessableError(`Invalid ScanOp ${JSON.stringify(raw)}`)
  }

  /**
   * Tests whether two ScanOps are equal
   * @param {ScanOp} _other
   * @returns {boolean}
   */
  equals(_other) {
    return false
  }

  /**
   * Tests whether two ScanOps can be merged into a single operation
   * @param {ScanOp} other
   * @returns
   */
  canMergeWith(other) {
    return false
  }

  /**
   * Merge two ScanOps into a single operation
   * @param {ScanOp} _other
   * @returns {void}
   */
  mergeWith(_other) {
    throw new Error('abstract method')
  }

  toString() {
    'ScanOp'
  }
}

class InsertOp extends ScanOp {
  /**
   *
   * @param {string} insertion
   * @param {TrackingProps | undefined} tracking
   * @param {string[] | undefined} commentIds
   */
  constructor(insertion, tracking = undefined, commentIds = undefined) {
    super()
    if (typeof insertion !== 'string') {
      throw new InvalidInsertionError('insertion must be a string')
    }
    if (containsNonBmpChars(insertion)) {
      throw new InvalidInsertionError('insertion contains non-BMP characters')
    }
    /** @type {string} */
    this.insertion = insertion
    /** @type {TrackingProps | undefined} */
    this.tracking = tracking
    /** @type {string[] | undefined} */
    this.commentIds = commentIds
  }

  /**
   *
   * @param {RawInsertOp} op
   * @returns {InsertOp}
   */
  static fromJSON(op) {
    if (typeof op === 'string') {
      return new InsertOp(op)
    }
    // It must be an object with an 'i' property.
    if (typeof op.i !== 'string') {
      throw new InvalidInsertionError(
        'insert operation must have a string property'
      )
    }
    return new InsertOp(
      op.i,
      op.tracking && TrackingProps.fromRaw(op.tracking),
      op.commentIds
    )
  }

  /**
   * @inheritdoc
   * @param {LengthApplyContext} current
   * @returns {LengthApplyContext}
   */
  applyToLength(current) {
    current.length += this.insertion.length
    return current
  }

  /** @inheritdoc
   * @param {ScanOp} other
   */
  equals(other) {
    if (!(other instanceof InsertOp)) {
      return false
    }
    if (this.insertion !== other.insertion) {
      return false
    }
    if (this.tracking) {
      if (!this.tracking.equals(other.tracking)) {
        return false
      }
    } else if (other.tracking) {
      return false
    }

    if (this.commentIds) {
      return (
        this.commentIds.length === other.commentIds?.length &&
        this.commentIds.every(id => other.commentIds?.includes(id))
      )
    }
    return !other.commentIds
  }

  /**
   * @param {ScanOp} other
   * @return {other is InsertOp}
   */
  canMergeWith(other) {
    if (!(other instanceof InsertOp)) {
      return false
    }
    if (this.tracking) {
      if (!other.tracking || !this.tracking.canMergeWith(other.tracking)) {
        return false
      }
    } else if (other.tracking) {
      return false
    }
    if (this.commentIds) {
      return (
        this.commentIds.length === other.commentIds?.length &&
        this.commentIds.every(id => other.commentIds?.includes(id))
      )
    }
    return !other.commentIds
  }

  /**
   * @param {ScanOp} other
   */
  mergeWith(other) {
    if (!this.canMergeWith(other)) {
      throw new Error('Cannot merge with incompatible operation')
    }
    this.insertion += other.insertion
    if (this.tracking != null && other.tracking != null) {
      this.tracking = this.tracking.mergeWith(other.tracking)
    }
    // We already have the same commentIds
  }

  /**
   * @returns {RawInsertOp}
   */
  toJSON() {
    if (!this.tracking && !this.commentIds) {
      return this.insertion
    }
    /** @type RawInsertOp */
    const obj = { i: this.insertion }
    if (this.tracking) {
      obj.tracking = this.tracking.toRaw()
    }
    if (this.commentIds) {
      obj.commentIds = this.commentIds
    }
    return obj
  }

  toString() {
    return `insert '${this.insertion}'`
  }
}

class RetainOp extends ScanOp {
  /**
   * @param {number} length
   * @param {TrackingDirective | undefined} tracking
   */
  constructor(length, tracking = undefined) {
    super()
    if (length < 0) {
      throw new Error('length must be non-negative')
    }
    /** @type {number} */
    this.length = length
    /** @type {TrackingDirective | undefined} */
    this.tracking = tracking
  }

  /**
   * @inheritdoc
   * @param {LengthApplyContext} current
   * @returns {LengthApplyContext}
   */
  applyToLength(current) {
    if (current.inputCursor + this.length > current.inputLength) {
      throw new ApplyError(
        "Operation can't retain more chars than are left in the string.",
        this.toJSON(),
        current.inputLength
      )
    }
    current.length += this.length
    current.inputCursor += this.length
    return current
  }

  /**
   *
   * @param {RawRetainOp} op
   * @returns {RetainOp}
   */
  static fromJSON(op) {
    if (typeof op === 'number') {
      return new RetainOp(op)
    }
    // It must be an object with a 'r' property.
    if (typeof op.r !== 'number') {
      throw new Error('retain operation must have a number property')
    }
    if (op.tracking) {
      const tracking =
        op.tracking.type === 'none'
          ? new ClearTrackingProps()
          : TrackingProps.fromRaw(op.tracking)
      return new RetainOp(op.r, tracking)
    }
    return new RetainOp(op.r)
  }

  /** @inheritdoc
   * @param {ScanOp} other
   */
  equals(other) {
    if (!(other instanceof RetainOp)) {
      return false
    }
    if (this.length !== other.length) {
      return false
    }
    if (this.tracking) {
      return this.tracking.equals(other.tracking)
    }
    return !other.tracking
  }

  /**
   * @param {ScanOp} other
   * @return {other is RetainOp}
   */
  canMergeWith(other) {
    if (!(other instanceof RetainOp)) {
      return false
    }
    if (this.tracking) {
      if (!other.tracking || !this.tracking.canMergeWith(other.tracking)) {
        return false
      }
    } else if (other.tracking) {
      return false
    }
    return true
  }

  /**
   * @param {ScanOp} other
   */
  mergeWith(other) {
    if (!this.canMergeWith(other)) {
      throw new Error('Cannot merge with incompatible operation')
    }
    this.length += other.length
    if (this.tracking != null && other.tracking != null) {
      this.tracking = this.tracking.mergeWith(other.tracking)
    }
  }

  /**
   * @returns {RawRetainOp}
   */
  toJSON() {
    if (!this.tracking) {
      return this.length
    }
    return { r: this.length, tracking: this.tracking.toRaw() }
  }

  toString() {
    return `retain ${this.length}`
  }
}

class RemoveOp extends ScanOp {
  /**
   * @param {number} length
   */
  constructor(length) {
    super()
    if (length < 0) {
      throw new Error('length must be non-negative')
    }
    /** @type {number} */
    this.length = length
  }

  /**
   * @inheritdoc
   * @param {LengthApplyContext} current
   * @returns {LengthApplyContext}
   */
  applyToLength(current) {
    current.inputCursor += this.length
    return current
  }

  /**
   *
   * @param {RawRemoveOp} op
   * @returns {RemoveOp}
   */
  static fromJSON(op) {
    if (typeof op !== 'number' || op > 0) {
      throw new Error('delete operation must be a negative number')
    }
    return new RemoveOp(-op)
  }

  /**
   * @inheritdoc
   * @param {ScanOp} other
   * @return {boolean}
   */
  equals(other) {
    if (!(other instanceof RemoveOp)) {
      return false
    }
    return this.length === other.length
  }

  /**
   * @param {ScanOp} other
   * @return {other is RemoveOp}
   */
  canMergeWith(other) {
    return other instanceof RemoveOp
  }

  /**
   * @param {ScanOp} other
   */
  mergeWith(other) {
    if (!this.canMergeWith(other)) {
      throw new Error('Cannot merge with incompatible operation')
    }
    this.length += other.length
  }

  /**
   * @returns {RawRemoveOp}
   */
  toJSON() {
    return -this.length
  }

  toString() {
    return `remove ${this.length}`
  }
}

/**
 * @param {RawScanOp} op
 * @returns {op is RawRetainOp}
 */
function isRetain(op) {
  return (
    (typeof op === 'number' && op > 0) ||
    (typeof op === 'object' &&
      'r' in op &&
      typeof op.r === 'number' &&
      op.r > 0)
  )
}

/**
 * @param {RawScanOp} op
 * @returns {op is RawInsertOp}
 */
function isInsert(op) {
  return (
    typeof op === 'string' ||
    (typeof op === 'object' && 'i' in op && typeof op.i === 'string')
  )
}

/**
 * @param {RawScanOp} op
 * @returns {op is RawRemoveOp}
 */
function isRemove(op) {
  return typeof op === 'number' && op < 0
}

module.exports = {
  ScanOp,
  InsertOp,
  RetainOp,
  RemoveOp,
  isRetain,
  isInsert,
  isRemove,
}
