///|
priv struct Lexer {
  src : String
  mut index : Int32
  mut line : Int32
  mut column : Int32
}

///|
fn code(ch : Char) -> Int32 {
  ch.to_int()
}

///|
fn Lexer::ended(self : Lexer) -> Bool {
  self.index >= self.src.length()
}

///|
fn Lexer::peek(self : Lexer) -> Int32 {
  if self.ended() {
    -1
  } else {
    self.src.unsafe_get(self.index).to_int()
  }
}

///|
fn Lexer::peek_at(self : Lexer, offset : Int32) -> Int32 {
  let index = self.index + offset
  if index >= self.src.length() {
    -1
  } else {
    self.src.unsafe_get(index).to_int()
  }
}

///|
fn Lexer::advance(self : Lexer) -> Unit {
  if self.ended() {
    return
  }
  let current = self.peek()
  self.index += 1
  if current == code('\n') {
    self.line += 1
    self.column = 1
  } else {
    self.column += 1
  }
}

///|
fn is_alpha(ch : Int32) -> Bool {
  (ch >= code('a') && ch <= code('z')) ||
  (ch >= code('A') && ch <= code('Z')) ||
  ch == code('_')
}

///|
fn is_digit(ch : Int32) -> Bool {
  ch >= code('0') && ch <= code('9')
}

///|
fn is_ident_continue(ch : Int32) -> Bool {
  is_alpha(ch) || is_digit(ch)
}

///|
fn is_after_value(tokens : Array[Token]) -> Bool {
  if tokens.length() == 0 {
    return false
  }
  match tokens[tokens.length() - 1].kind {
    Num
    | Str
    | Ident
    | Type
    | RParen
    | RBrack
    | True
    | False
    | Null
    | StateVar
    | BuiltinCall => true
    _ => false
  }
}

///|
fn hex_digit(ch : Int32) -> Int32? {
  if ch >= code('0') && ch <= code('9') {
    Some(ch - code('0'))
  } else if ch >= code('a') && ch <= code('f') {
    Some(ch - code('a') + 10)
  } else if ch >= code('A') && ch <= code('F') {
    Some(ch - code('A') + 10)
  } else {
    None
  }
}

///|
fn write_code_point(buf : StringBuilder, point : Int32) -> Unit {
  if point >= 0 && point <= 0x10FFFF && !(point >= 0xD800 && point <= 0xDFFF) {
    match point.to_char() {
      Some(ch) => buf.write_char(ch)
      None => ()
    }
  }
}

///|
fn Lexer::read_hex4(self : Lexer) -> Int32? {
  if self.index + 4 > self.src.length() {
    return None
  }
  let mut value = 0
  for offset in 0..<4 {
    match hex_digit(self.peek_at(offset)) {
      Some(digit) => value = value * 16 + digit
      None => return None
    }
  }
  for _offset in 0..<4 {
    self.advance()
  }
  Some(value)
}

///|
fn Lexer::write_unicode(self : Lexer, buf : StringBuilder) -> Unit {
  match self.read_hex4() {
    None => buf.write_char('u')
    Some(high) =>
      if high >= 0xD800 &&
        high <= 0xDBFF &&
        self.peek() == code('\\') &&
        self.peek_at(1) == code('u') {
        let saved = self.index
        let saved_line = self.line
        let saved_column = self.column
        self.advance()
        self.advance()
        match self.read_hex4() {
          Some(low) if low >= 0xDC00 && low <= 0xDFFF => {
            let scalar = 0x10000 + (high - 0xD800) * 0x400 + (low - 0xDC00)
            write_code_point(buf, scalar)
          }
          _ => {
            self.index = saved
            self.line = saved_line
            self.column = saved_column
            write_code_point(buf, high)
          }
        }
      } else {
        write_code_point(buf, high)
      }
  }
}

///|
fn Lexer::write_escape(
  self : Lexer,
  buf : StringBuilder,
  escaped : Int32,
) -> Unit {
  if escaped == code('"') ||
    escaped == code('\\') ||
    escaped == code('/') ||
    escaped == code('\'') {
    write_code_point(buf, escaped)
  } else if escaped == code('b') {
    buf.write_char('\b')
  } else if escaped == code('f') {
    buf.write_char('\u{000C}')
  } else if escaped == code('n') {
    buf.write_char('\n')
  } else if escaped == code('r') {
    buf.write_char('\r')
  } else if escaped == code('t') {
    buf.write_char('\t')
  } else if escaped == code('u') {
    self.write_unicode(buf)
  } else {
    write_code_point(buf, escaped)
  }
}

///|
fn Lexer::scan_string(
  self : Lexer,
  quote : Int32,
  json_escapes : Bool,
) -> String {
  self.advance()
  let buf = StringBuilder()
  while !self.ended() {
    let current = self.peek()
    if current == code('\\') {
      self.advance()
      if self.ended() {
        break
      }
      let escaped = self.peek()
      self.advance()
      if json_escapes {
        self.write_escape(buf, escaped)
      } else if escaped == code('\'') {
        buf.write_char('\'')
      } else if escaped == code('\\') {
        buf.write_char('\\')
      } else if escaped == code('n') {
        buf.write_char('\n')
      } else if escaped == code('t') {
        buf.write_char('\t')
      } else {
        write_code_point(buf, escaped)
      }
    } else if current == quote {
      self.advance()
      break
    } else {
      let start = self.index
      while !self.ended() {
        let next = self.peek()
        if next == code('\\') || next == quote {
          break
        }
        self.advance()
      }
      buf.write_string(self.src[start:self.index].to_owned())
    }
  }
  buf.to_string()
}

///|
fn digit_value(ch : Int32) -> Double {
  Double::from_int(ch - code('0'))
}

///|
fn parse_number(text : String) -> Double {
  let length = text.length()
  let mut index = 0
  let mut sign = 1.0
  if index < length && text.unsafe_get(index).to_int() == code('-') {
    sign = -1.0
    index += 1
  }
  let mut value = 0.0
  while index < length && is_digit(text.unsafe_get(index).to_int()) {
    value = value * 10.0 + digit_value(text.unsafe_get(index).to_int())
    index += 1
  }
  if index < length && text.unsafe_get(index).to_int() == code('.') {
    index += 1
    let mut scale = 0.1
    while index < length && is_digit(text.unsafe_get(index).to_int()) {
      value = value + digit_value(text.unsafe_get(index).to_int()) * scale
      scale = scale / 10.0
      index += 1
    }
  }
  if index < length {
    let marker = text.unsafe_get(index).to_int()
    if marker == code('e') || marker == code('E') {
      index += 1
      let mut exp_sign = 1
      if index < length && text.unsafe_get(index).to_int() == code('+') {
        index += 1
      } else if index < length && text.unsafe_get(index).to_int() == code('-') {
        exp_sign = -1
        index += 1
      }
      let mut exp = 0
      let exp_start = index
      while index < length && is_digit(text.unsafe_get(index).to_int()) {
        exp = exp * 10 + (text.unsafe_get(index).to_int() - code('0'))
        index += 1
      }
      if index > exp_start {
        let mut factor = 1.0
        let mut left = exp
        while left > 0 {
          factor = factor * 10.0
          left -= 1
        }
        if exp_sign < 0 {
          value = value / factor
        } else {
          value = value * factor
        }
      }
    }
  }
  sign * value
}

///|
fn scan_ident(
  src : String,
  index : Int32,
  column : Int32,
) -> (String, Int32, Int32) {
  let length = src.length()
  let start = index
  let mut cursor = index + 1
  let mut next_column = column + 1
  while cursor < length && is_ident_continue(src.unsafe_get(cursor).to_int()) {
    cursor += 1
    next_column += 1
  }
  (src[start:cursor].to_owned(), cursor, next_column)
}

///|
fn scan_numeric(
  src : String,
  index : Int32,
  column : Int32,
) -> (String, Int32, Int32) {
  let length = src.length()
  let start = index
  let mut cursor = index
  let mut next_column = column
  if cursor < length && src.unsafe_get(cursor).to_int() == 45 {
    cursor += 1
    next_column += 1
  }
  while cursor < length && is_digit(src.unsafe_get(cursor).to_int()) {
    cursor += 1
    next_column += 1
  }
  if cursor + 1 < length &&
    src.unsafe_get(cursor).to_int() == 46 &&
    is_digit(src.unsafe_get(cursor + 1).to_int()) {
    cursor += 1
    next_column += 1
    while cursor < length && is_digit(src.unsafe_get(cursor).to_int()) {
      cursor += 1
      next_column += 1
    }
  }
  if cursor < length {
    let marker = src.unsafe_get(cursor).to_int()
    if marker == 101 || marker == 69 {
      cursor += 1
      next_column += 1
      if cursor < length {
        let sign = src.unsafe_get(cursor).to_int()
        if sign == 43 || sign == 45 {
          cursor += 1
          next_column += 1
        }
      }
      while cursor < length && is_digit(src.unsafe_get(cursor).to_int()) {
        cursor += 1
        next_column += 1
      }
    }
  }
  (src[start:cursor].to_owned(), cursor, next_column)
}

///|
/// Quotes with no escapes are one slice. Escapes and unclosed quotes use the lexer.
fn scan_quoted(
  src : String,
  index : Int32,
  line : Int32,
  column : Int32,
  quote : Int32,
  json_escapes : Bool,
) -> (String, Int32, Int32, Int32) {
  let length = src.length()
  let mut cursor = index + 1
  let mut next_line = line
  let mut next_column = column + 1
  let mut simple = true
  while cursor < length {
    let ch = src.unsafe_get(cursor).to_int()
    if ch == 92 || ch == quote {
      simple = ch == quote
      break
    }
    if ch == 10 {
      next_line += 1
      next_column = 1
    } else {
      next_column += 1
    }
    cursor += 1
  }
  if simple && cursor < length {
    next_column += 1
    (src[index + 1:cursor].to_owned(), cursor + 1, next_line, next_column)
  } else {
    let lexer = Lexer::{ src, index, line, column, }
    let text = lexer.scan_string(quote, json_escapes)
    (text, lexer.index, lexer.line, lexer.column)
  }
}

///|
fn push_token(
  tokens : Array[Token],
  kind : TokenKind,
  text : String,
  line : Int32,
  column : Int32,
) -> Unit {
  tokens.push({ kind, text, line, column, })
}

///|
fn one_char(current : Int32) -> TokenKind? {
  if current == 10 {
    Some(Newline)
  } else if current == 40 {
    Some(LParen)
  } else if current == 41 {
    Some(RParen)
  } else if current == 91 {
    Some(LBrack)
  } else if current == 93 {
    Some(RBrack)
  } else if current == 123 {
    Some(LBrace)
  } else if current == 125 {
    Some(RBrace)
  } else if current == 44 {
    Some(Comma)
  } else if current == 58 {
    Some(Colon)
  } else if current == 46 {
    Some(Dot)
  } else if current == 63 {
    Some(Question)
  } else if current == 43 {
    Some(Plus)
  } else if current == 42 {
    Some(Star)
  } else if current == 47 {
    Some(Slash)
  } else if current == 37 {
    Some(Percent)
  } else {
    None
  }
}

///|
/// Tokenize OpenUI Lang source. Newlines are tokens. Horizontal whitespace is not.
pub fn tokenize(src : String) -> Array[Token] {
  let length = src.length()
  let tokens : Array[Token] = []
  let mut index = 0
  let mut line = 1
  let mut column = 1
  while index < length {
    while index < length {
      let skipped = src.unsafe_get(index).to_int()
      if skipped == 32 || skipped == 9 || skipped == 13 {
        index += 1
        column += 1
      } else {
        break
      }
    }
    if index >= length {
      break
    }
    let current = src.unsafe_get(index).to_int()
    let tok_line = line
    let tok_column = column
    match one_char(current) {
      Some(kind) => {
        push_token(tokens, kind, "", tok_line, tok_column)
        index += 1
        if current == 10 {
          line += 1
          column = 1
        } else {
          column += 1
        }
      }
      None =>
        if current == 61 || current == 33 || current == 62 || current == 60 {
          index += 1
          column += 1
          let doubled = index < length && src.unsafe_get(index).to_int() == 61
          let kind = if current == 61 {
            if doubled {
              EqEq
            } else {
              Equals
            }
          } else if current == 33 {
            if doubled {
              NotEq
            } else {
              Not
            }
          } else if current == 62 {
            if doubled {
              GreaterEq
            } else {
              Greater
            }
          } else if doubled {
            LessEq
          } else {
            Less
          }
          if doubled {
            index += 1
            column += 1
          }
          push_token(tokens, kind, "", tok_line, tok_column)
        } else if current == 38 || current == 124 {
          let kind = if current == 38 { And } else { Or }
          push_token(tokens, kind, "", tok_line, tok_column)
          index += 1
          column += 1
          if index < length && src.unsafe_get(index).to_int() == current {
            index += 1
            column += 1
          }
        } else if current == 34 || current == 39 {
          let (text, next, next_line, next_column) = scan_quoted(
            src,
            index,
            line,
            column,
            current,
            current == 34,
          )
          push_token(tokens, Str, text, tok_line, tok_column)
          index = next
          line = next_line
          column = next_column
        } else if current == 45 &&
          (
            is_after_value(tokens) ||
            index + 1 >= length ||
            !is_digit(src.unsafe_get(index + 1).to_int())
          ) {
          push_token(tokens, Minus, "", tok_line, tok_column)
          index += 1
          column += 1
        } else if is_digit(current) ||
          (
            current == 45 &&
            index + 1 < length &&
            is_digit(src.unsafe_get(index + 1).to_int())
          ) {
          let (text, next, next_column) = scan_numeric(src, index, column)
          push_token(tokens, Num, text, tok_line, tok_column)
          index = next
          column = next_column
        } else if current == 36 &&
          index + 1 < length &&
          is_alpha(src.unsafe_get(index + 1).to_int()) {
          let (text, next, next_column) = scan_ident(src, index, column)
          push_token(tokens, StateVar, text, tok_line, tok_column)
          index = next
          column = next_column
        } else if is_alpha(current) {
          let (text, next, next_column) = scan_ident(src, index, column)
          if text == "true" {
            push_token(tokens, True, "", tok_line, tok_column)
          } else if text == "false" {
            push_token(tokens, False, "", tok_line, tok_column)
          } else if text == "null" {
            push_token(tokens, Null, "", tok_line, tok_column)
          } else if current >= 65 && current <= 90 {
            push_token(tokens, Type, text, tok_line, tok_column)
          } else {
            push_token(tokens, Ident, text, tok_line, tok_column)
          }
          index = next
          column = next_column
        } else if current == 64 &&
          index + 1 < length &&
          is_alpha(src.unsafe_get(index + 1).to_int()) {
          index += 1
          column += 1
          let (text, next, next_column) = scan_ident(src, index, column)
          push_token(tokens, BuiltinCall, text, tok_line, tok_column)
          index = next
          column = next_column
        } else {
          index += 1
          column += 1
        }
    }
  }
  tokens.push({ kind: EOF, text: "", line, column, })
  tokens
}