// Copyright 2019 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

import { Token, TokenType } from "./token.js";

export default class Lexer {
  /**
   * @param {String} input The input string to tokenize.
   */
  constructor(input) {
    this.input_ = input;
    this.len_ = input.length;
    this.cur_pos_ = 0;
    this.cur_line_ = 1;

    this.num_regex_ = /^[0-9]+$/;
    this.alpha_regex_ = /^[a-zA-Z_]+$/;
    this.op_regex_ = /^Op[A-Z][^\s]*$/;
    this.hex_regex_ = /^[0-9a-fA-F]$/;
  }

  /**
   * Parses the next token from the input stream.
   * @return {Token} the next token.
   */
  next() {
    this.skipWhitespace();
    this.skipComments();

    if (this.cur_pos_ >= this.len_)
      return new Token(TokenType.kEOF, this.cur_line_);

    let n = this.tryHexInteger();
    if (n !== undefined)
      return n;

    n = this.tryFloat();
    if (n !== undefined)
      return n;

    n = this.tryInteger();
    if (n !== undefined)
      return n;

    n = this.tryString();
    if (n !== undefined)
      return n;

    n = this.tryOp();
    if (n !== undefined)
      return n;

    n = this.tryPunctuation();
    if (n !== undefined)
      return n;

    n = this.tryResultId();
    if (n !== undefined)
      return n;

    n = this.tryIdent();
    if (n !== undefined)
      return n;

    return new Token(TokenType.kError, this.cur_line_, "Failed to match token");
  }

  is(str) {
    if (this.len_ <= this.cur_pos_ + (str.length - 1))
      return false;

    for (let i = 0; i < str.length; ++i) {
      if (this.input_[this.cur_pos_ + i] !== str[i])
        return false;
    }

    return true;
  }

  isNum(ch) {
    return ch.match(this.num_regex_);
  }

  isAlpha(ch) {
    return ch.match(this.alpha_regex_);
  }

  isAlphaNum(ch) {
    return this.isNum(ch) || this.isAlpha(ch);
  }

  isHex(char) {
    return char.match(this.hex_regex_);
  }

  isCurWhitespace() {
    return this.is(" ") || this.is("\t") || this.is("\r") || this.is("\n");
  }

  skipWhitespace() {
    for(;;) {
      let cur_pos = this.cur_pos_;
      while (this.cur_pos_ < this.len_ &&
          this.isCurWhitespace()) {
        if (this.is("\n"))
          this.cur_line_ += 1;

        this.cur_pos_ += 1;
      }

      this.skipComments();

      // Cursor didn't move so no whitespace matched.
      if (cur_pos === this.cur_pos_)
        break;
    }
  }

  skipComments() {
    if (!this.is(";"))
      return;

    while (this.cur_pos_ < this.len_ && !this.is("\n"))
      this.cur_pos_ += 1;
  }

  /**
   * Attempt to parse the next part of the input as a float.
   * @return {Token|undefined} returns a Token if a float is matched,
   *                           undefined otherwise.
   */
  tryFloat() {
    let start = this.cur_pos_;
    let end = start;

    if (this.cur_pos_ >= this.len_)
      return undefined;
    if (this.input_[end] === "-")
      end += 1;

    while (end < this.len_ && this.isNum(this.input_[end]))
      end += 1;

    // Must have a "." in a float
    if (end >= this.len_ || this.input_[end] !== ".")
      return undefined;

    end += 1;
    while (end < this.len_ && this.isNum(this.input_[end]))
      end += 1;

    let substr = this.input_.substr(start, end - start);
    if (substr === "." || substr === "-.")
      return undefined;

    this.cur_pos_ = end;

    return new Token(TokenType.kFloatLiteral, this.cur_line_, parseFloat(substr));
  }

  /**
   * Attempt to parse a hex encoded integer.
   * @return {Token|undefined} returns a Token if a Hex number is matched,
   *                           undefined otherwise.
   */
  tryHexInteger() {
    let start = this.cur_pos_;
    let end = start;

    if (this.cur_pos_ >= this.len_)
      return undefined;
    if (end + 2 >= this.len_ || this.input_[end] !== "0" ||
        this.input_[end + 1] !== "x") {
      return undefined;
    }

    end += 2;

    while (end < this.len_ && this.isHex(this.input_[end]))
      end += 1;

    this.cur_pos_ = end;

    let val = parseInt(this.input_.substr(start, end - start), 16);
    return new Token(TokenType.kIntegerLiteral, this.cur_line_, val);
  }

  /**
   * Attempt to parse an encoded integer.
   * @return {Token|undefined} returns a Token if a number is matched,
   *                           undefined otherwise.
   */
  tryInteger() {
    let start = this.cur_pos_;
    let end = start;

    if (this.cur_pos_ >= this.len_)
      return undefined;
    if (this.input_[end] === "-")
      end += 1;

    if (end >= this.len_ || !this.isNum(this.input_[end]))
      return undefined;

    while (end < this.len_ && this.isNum(this.input_[end]))
      end += 1;

    this.cur_pos_ = end;

    let val = parseInt(this.input_.substr(start, end - start), 10);
    return new Token(TokenType.kIntegerLiteral, this.cur_line_, val);
  }

  /**
   * Attempt to parse a result id.
   * @return {Token|undefined} returns a Token if a result id is matched,
   *                           undefined otherwise.
   */
  tryResultId() {
    let start = this.cur_pos_;
    if (start >= this.len_)
      return undefined;
    if (!this.is("%"))
      return undefined;

    start += 1;
    this.cur_pos_ += 1;
    while (this.cur_pos_ < this.len_ &&
        (this.isAlphaNum(this.input_[this.cur_pos_]) || this.is("_"))) {
      this.cur_pos_ += 1;
    }

    let ident = this.input_.substr(start, this.cur_pos_ - start);
    let value = undefined;
    if (ident.match(this.num_regex_))
      value = parseInt(ident, 10);

    return new Token(TokenType.kResultId, this.cur_line_, {
      name: ident,
      val: value
    });
  }

  /**
   * Attempt to parse an identifier.
   * @return {Token|undefined} returns a Token if an identifier is matched,
   *                           undefined otherwise.
   */
  tryIdent() {
    let start = this.cur_pos_;
    if (start >= this.len_)
      return undefined;

    while (this.cur_pos_ < this.len_ &&
        (this.isAlphaNum(this.input_[this.cur_pos_]) || this.is("_"))) {
      this.cur_pos_ += 1;
    }

    let ident = this.input_.substr(start, this.cur_pos_ - start);
    return new Token(TokenType.kIdentifier, this.cur_line_, ident);
  }

  /**
   * Attempt to parse an Op command.
   * @return {Token|undefined} returns a Token if an Op command is matched,
   *                           undefined otherwise.
   */
  tryOp() {
    let start = this.cur_pos_;
    if (this.cur_pos_ >= this.len_ || (this.cur_pos_ + 1 >= this.len_))
      return undefined;

    if (this.input_[this.cur_pos_] !== "O" ||
        this.input_[this.cur_pos_ + 1] !== "p") {
      return undefined;
    }

    while (this.cur_pos_ < this.len_ &&
        !this.isCurWhitespace()) {
      this.cur_pos_ += 1;
    }

    return new Token(TokenType.kOp, this.cur_line_, {
      name: this.input_.substr(start, this.cur_pos_ - start)
    });
  }

  /**
   * Attempts to match punctuation strings against the input
   * @return {Token|undefined} Returns the Token for the punctuation or
   *                           undefined if no matches found.
   */
  tryPunctuation() {
    let type = undefined;
    if (this.is("="))
      type = TokenType.kEqual;
    else if (this.is("|"))
      type = TokenType.kPipe;

    if (type === undefined)
      return undefined;

    this.cur_pos_ += type.length;
    return new Token(type, this.cur_line_, type);
  }

  /**
   * Attempts to match strings against the input
   * @return {Token|undefined} Returns the Token for the string or undefined
   *                           if no match found.
   */
  tryString() {
    let start = this.cur_pos_;

    // Must have at least 2 chars for a string.
    if (this.cur_pos_ >= this.len_ || (this.cur_pos_ + 1 >= this.len_))
      return undefined;
    if (!this.is("\""))
      return undefined;

    this.cur_pos_ += 1;
    let str = "";
    while (this.cur_pos_ <= this.len_) {
      if (this.is("\""))
        break;

      if (this.is("\\")) {
        this.cur_pos_ += 1;
        if (this.cur_pos_ >= this.len_)
          return undefined;

        if (this.is("\\")) {
          str += "\\";
        } else if (this.is("\"")) {
          str += '"';
        } else {
          str += this.input_[this.cur_pos_];
        }
      } else {
        str += this.input_[this.cur_pos_];
      }
      this.cur_pos_ += 1;
    }

    if (this.cur_pos_ >= this.len_)
      return undefined;

    this.cur_pos_ += 1;

    return new Token(TokenType.kStringLiteral, this.cur_line_, str);
  }
}