// @ts-check
"use strict";

const { SourceNode } = require("source-map-generator");
const GrammarLocation = require("../grammar-location.js");

/**
 * @typedef {(string|SourceNode)[]} SourceArray
 */

/** Utility class that helps generating code for C-like languages. */
class Stack {
  /**
   * Constructs the helper for tracking variable slots of the stack virtual machine
   *
   * @param {string} ruleName The name of rule that will be used in error messages
   * @param {string} varName The prefix for generated names of variables
   * @param {string} type The type of the variables. For JavaScript there are `var` or `let`
   * @param {number[]} bytecode Bytecode for error messages
   */
  constructor(ruleName, varName, type, bytecode) {
    /** Last used variable in the stack. */
    this.sp             = -1;
    /** Maximum stack size. */
    this.maxSp          = -1;
    this.varName        = varName;
    this.ruleName       = ruleName;
    this.type           = type;
    this.bytecode       = bytecode;
    /**
     * Map from stack index, to label targetting that index
     * @type {Record<number,{label:string,location:import("../peg.js").LocationRange}>}
     */
    this.labels         = {};
    /**
     *  Stack of in-flight source mappings
     * @type {[SourceArray, number, PEG.LocationRange][]}
     */
    this.sourceMapStack = [];
  }

  /**
   * Returns name of the variable at the index `i`.
   *
   * @param {number} i Index for which name must be generated
   * @return {string} Generated name
   *
   * @throws {RangeError} If `i < 0`, which means a stack underflow (there are more `pop`s than `push`es)
   */
  name(i) {
    if (i < 0) {
      throw new RangeError(
        `Rule '${this.ruleName}': The variable stack underflow: attempt to use a variable '${this.varName}<x>' at an index ${i}.\nBytecode: ${this.bytecode}`
      );
    }

    return this.varName + i;
  }

  /**
   *
   * @param {PEG.LocationRange} location
   * @param {SourceArray} chunks
   * @param {string} [name]
   * @returns
   */
  static sourceNode(location, chunks, name) {
    const start = GrammarLocation.offsetStart(location);
    return new SourceNode(
      start.line,
      start.column ? start.column - 1 : null,
      String(location.source),
      chunks,
      name
    );
  }

  /**
   * Assigns `exprCode` to the new variable in the stack, returns generated code.
   * As the result, the size of a stack increases on 1.
   *
   * @param {string} exprCode Any expression code that must be assigned to the new variable in the stack
   * @return {string|SourceNode} Assignment code
   */
  push(exprCode) {
    if (++this.sp > this.maxSp) { this.maxSp = this.sp; }

    const label = this.labels[this.sp];
    const code = [this.name(this.sp), " = ", exprCode, ";"];
    if (label) {
      if (this.sourceMapStack.length) {
        const sourceNode = Stack.sourceNode(
          label.location,
          code.splice(0, 2),
          label.label
        );
        const { parts, location } = this.sourceMapPopInternal();
        const newLoc = (location.start.offset < label.location.end.offset)
          ? {
              start: label.location.end,
              end: location.end,
              source: location.source,
            }
          : location;

        const outerNode = Stack.sourceNode(
          newLoc,
          code.concat("\n")
        );
        this.sourceMapStack.push([parts, parts.length + 1, location]);
        return new SourceNode(
          null,
          null,
          label.location.source,
          [sourceNode, outerNode]
        );
      } else {
        return Stack.sourceNode(
          label.location,
          code.concat("\n")
        );
      }
    }
    return code.join("");
  }

  /**
   * @overload
   * @param {undefined} [n]
   * @return {string}
   */
  /**
   * @overload
   * @param {number} n
   * @return {string[]}
   */
  /**
   * Returns name or `n` names of the variable(s) from the top of the stack.
   *
   * @param {number} [n] Quantity of variables, which need to be removed from the stack
   * @returns {string[]|string} Generated name(s). If n is defined then it returns an
   *                            array of length `n`
   *
   * @throws {RangeError} If the stack underflow (there are more `pop`s than `push`es)
   */
  pop(n) {
    if (n !== undefined) {
      this.sp -= n;

      return Array.from({ length: n }, (v, i) => this.name(this.sp + 1 + i));
    }

    return this.name(this.sp--);
  }

  /**
   * Returns name of the first free variable. The same as `index(0)`.
   *
   * @return {string} Generated name
   *
   * @throws {RangeError} If the stack is empty (there was no `push`'s yet)
   */
  top() { return this.name(this.sp); }

  /**
   * Returns name of the variable at index `i`.
   *
   * @param {number} i Index of the variable from top of the stack
   * @return {string} Generated name
   *
   * @throws {RangeError} If `i < 0` or more than the stack size
   */
  index(i) {
    if (i < 0) {
      throw new RangeError(
        `Rule '${this.ruleName}': The variable stack overflow: attempt to get a variable at a negative index ${i}.\nBytecode: ${this.bytecode}`
      );
    }

    return this.name(this.sp - i);
  }

  /**
   * Returns variable name that contains result (bottom of the stack).
   *
   * @return {string} Generated name
   *
   * @throws {RangeError} If the stack is empty (there was no `push`es yet)
   */
  result() {
    if (this.maxSp < 0) {
      throw new RangeError(
        `Rule '${this.ruleName}': The variable stack is empty, can't get the result.\nBytecode: ${this.bytecode}`
      );
    }

    return this.name(0);
  }

  /**
   * Returns defines of all used variables.
   *
   * @return {string} Generated define variable expression with the type `this.type`.
   *         If the stack is empty, returns empty string
   */
  defines() {
    if (this.maxSp < 0) {
      return "";
    }

    return this.type + " " + Array.from({ length: this.maxSp + 1 }, (v, i) => this.name(i)).join(", ") + ";";
  }

  /**
   * Checks that code in the `generateIf` and `generateElse` move the stack pointer in the same way.
   *
   * @template T
   * @param {number} pos Opcode number for error messages
   * @param {() => T} generateIf First function that works with this stack
   * @param {(() => T)|null} [generateElse] Second function that works with this stack
   * @return {T[]}
   *
   * @throws {Error} If `generateElse` is defined and the stack pointer moved differently in the
   *         `generateIf` and `generateElse`
   */
  checkedIf(pos, generateIf, generateElse) {
    const baseSp = this.sp;

    const ifResult = generateIf();

    if (!generateElse) {
      return [ifResult];
    }
    const thenSp = this.sp;

    this.sp = baseSp;
    const elseResult = generateElse();

    if (thenSp !== this.sp) {
      throw new Error(
        "Rule '" + this.ruleName + "', position " + pos + ": "
        + "Branches of a condition can't move the stack pointer differently "
        + "(before: " + baseSp + ", after then: " + thenSp + ", after else: " + this.sp + "). "
        + "Bytecode: " + this.bytecode
      );
    }
    return [ifResult, elseResult];
  }

  /**
   * Checks that code in the `generateBody` do not move stack pointer.
   *
   * @template T
   * @param {number} pos Opcode number for error messages
   * @param {() => T} generateBody Function that works with this stack
   * @return {T}
   *
   * @throws {Error} If `generateBody` move the stack pointer (if it contains unbalanced `push`es and `pop`s)
   */
  checkedLoop(pos, generateBody) {
    const baseSp = this.sp;

    const result = generateBody();

    if (baseSp !== this.sp) {
      throw new Error(
        "Rule '" + this.ruleName + "', position " + pos + ": "
        + "Body of a loop can't move the stack pointer "
        + "(before: " + baseSp + ", after: " + this.sp + "). "
        + "Bytecode: " + this.bytecode
      );
    }
    return result;
  }

  /**
   *
   * @param {SourceArray} parts
   * @param {PEG.LocationRange} location
   */
  sourceMapPush(parts, location) {
    if (this.sourceMapStack.length) {
      const top = this.sourceMapStack[this.sourceMapStack.length - 1];
      // If the current top of stack starts at the same location as
      // the about to be pushed item, we should update its start location to
      // be past the new one. Otherwise any code it generates will
      // get allocated to the inner node.
      if (top[2].start.offset === location.start.offset
          && top[2].end.offset > location.end.offset) {
        top[2] = {
          start: location.end,
          end: top[2].end,
          source: top[2].source,
        };
      }
    }
    this.sourceMapStack.push([
      parts,
      parts.length,
      location,
    ]);
  }

  /**
   * @returns {{parts:SourceArray,location:PEG.LocationRange}}
   */
  sourceMapPopInternal() {
    const elt = this.sourceMapStack.pop();
    if (!elt) {
      throw new RangeError(
        `Rule '${this.ruleName}': Attempting to pop an empty source map stack.\nBytecode: ${this.bytecode}`
      );
    }
    const [
      parts,
      index,
      location,
    ] = elt;
    const chunks = parts.splice(index).map(
      chunk => (chunk instanceof SourceNode
        ? chunk
        : chunk + "\n"
      )
    );
    if (chunks.length) {
      const start = GrammarLocation.offsetStart(location);
      parts.push(new SourceNode(
        start.line,
        start.column - 1,
        String(location.source),
        chunks
      ));
    }
    return { parts, location };
  }

  /**
   * @param {number} [offset]
   * @returns {[SourceArray, number, PEG.LocationRange]|undefined}
   */
  sourceMapPop(offset) {
    const { location } = this.sourceMapPopInternal();
    if (this.sourceMapStack.length
      && location.end.offset
      < this.sourceMapStack[this.sourceMapStack.length - 1][2].end.offset) {
      const { parts, location: outer } = this.sourceMapPopInternal();
      const newLoc = (outer.start.offset < location.end.offset)
        ? {
            start: location.end,
            end: outer.end,
            source: outer.source,
          }
        : outer;

      this.sourceMapStack.push([
        parts,
        parts.length + (offset || 0),
        newLoc,
      ]);
    }
    return undefined;
  }
}

module.exports = Stack;
