///|
type LexerReporter = (String, String) -> Unit raise ParseFailure

///|
priv struct Lexer {
  input : String
  mut offset : Int
  catcodes : Map[String, Int]
  report_nonstrict : LexerReporter
}

///|
fn Lexer::make(
  input : String,
  report_nonstrict? : LexerReporter = (_code, _message) => (),
) -> Lexer {
  let lexer : Lexer = { input, offset: 0, catcodes: Map([]), report_nonstrict }
  lexer.set_catcode("%", 14)
  lexer.set_catcode("~", 13)
  lexer
}

///|
fn Lexer::set_catcode(self : Lexer, char : String, code : Int) -> Unit {
  self.catcodes[char] = code
}

///|
fn Lexer::catcode(self : Lexer, char : String) -> Int? {
  self.catcodes.get(char)
}

///|
fn is_space_code_unit(c : UInt16) -> Bool {
  c == ' ' || c == '\r' || c == '\n' || c == '\t'
}

///|
fn is_horizontal_space_code_unit(c : UInt16) -> Bool {
  c == ' ' || c == '\r' || c == '\t'
}

///|
fn is_ascii_letter(c : UInt16) -> Bool {
  (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c == '@'
}

///|
fn is_ascii_alphabetic(c : UInt16) -> Bool {
  (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')
}

///|
fn is_combining_diacritical_mark(c : UInt16) -> Bool {
  c >= 0x0300 && c <= 0x036f
}

///|
fn is_high_surrogate(c : UInt16) -> Bool {
  c >= 0xd800 && c <= 0xdbff
}

///|
fn is_low_surrogate(c : UInt16) -> Bool {
  c >= 0xdc00 && c <= 0xdfff
}

///|
fn is_regular_code_unit(c : UInt16) -> Bool {
  (c >= 0x0021 && c <= 0x005b) ||
  (c >= 0x005d && c <= 0x2027) ||
  (c >= 0x202a && c <= 0xd7ff) ||
  (c >= 0xf900 && c <= 0xffff)
}

///|
fn is_js_line_terminator(c : UInt16) -> Bool {
  c == '\n' || c == '\r' || c == 0x2028 || c == 0x2029
}

///|
fn token_text(input : String, start : Int, end : Int) -> String {
  input.unsafe_substring(start~, end~)
}

///|
fn token_at(input : String, start : Int, end : Int, text : String) -> Token {
  Token::make(text, loc=SourceLocation::make(input, start~, end~))
}

///|
fn combining_marks_end(input : String, offset : Int) -> Int {
  let len = input.length()
  for i = offset; i < len; i = i + 1 {
    if !is_combining_diacritical_mark(input[i]) {
      break i
    }
  } nobreak {
    len
  }
}

///|
fn whitespace_end(input : String, offset : Int) -> Int {
  let len = input.length()
  for i = offset; i < len; i = i + 1 {
    if !is_space_code_unit(input[i]) {
      break i
    }
  } nobreak {
    len
  }
}

///|
fn horizontal_space_end(input : String, offset : Int) -> Int {
  let len = input.length()
  for i = offset; i < len; i = i + 1 {
    if !is_horizontal_space_code_unit(input[i]) {
      break i
    }
  } nobreak {
    len
  }
}

///|
fn control_space_end(input : String, start : Int) -> Int? {
  let len = input.length()
  if start + 1 >= len {
    None
  } else if input[start + 1] == '\n' {
    Some(horizontal_space_end(input, start + 2))
  } else if is_horizontal_space_code_unit(input[start + 1]) {
    let spaces_end = horizontal_space_end(input, start + 1)
    let after_newline = if spaces_end < len && input[spaces_end] == '\n' {
      spaces_end + 1
    } else {
      spaces_end
    }
    Some(horizontal_space_end(input, after_newline))
  } else {
    None
  }
}

///|
fn starts_with_at(input : String, offset : Int, prefix : String) -> Bool {
  let prefix_len = prefix.length()
  offset + prefix_len <= input.length() &&
  input.unsafe_substring(start=offset, end=offset + prefix_len) == prefix
}

///|
fn verb_end(input : String, start : Int, starred : Bool) -> Int? {
  let prefix = if starred { "\\verb*" } else { "\\verb" }
  if !starts_with_at(input, start, prefix) {
    return None
  }
  let delimiter_offset = start + prefix.length()
  if delimiter_offset >= input.length() {
    return None
  }
  let delimiter = input[delimiter_offset]
  if !starred && (delimiter == '*' || is_ascii_alphabetic(delimiter)) {
    return None
  }
  for i = delimiter_offset + 1; i < input.length(); i = i + 1 {
    let c = input[i]
    if c == delimiter {
      break Some(i + 1)
    } else if is_js_line_terminator(c) {
      break None
    }
  } nobreak {
    None
  }
}

///|
fn control_word_end(input : String, start : Int) -> (Int, Int)? {
  let len = input.length()
  if start + 1 >= len || !is_ascii_letter(input[start + 1]) {
    None
  } else {
    let raw_end = for i = start + 2; i < len; i = i + 1 {
      if !is_ascii_letter(input[i]) {
        break i
      }
    } nobreak {
      len
    }
    Some((raw_end, whitespace_end(input, raw_end)))
  }
}

///|
fn next_line_start(input : String, offset : Int) -> Int? {
  for i = offset; i < input.length(); i = i + 1 {
    if input[i] == '\n' {
      break Some(i + 1)
    }
  } nobreak {
    None
  }
}

///|
fn unexpected_character(input : String, offset : Int) -> ParseFailure {
  let text = token_text(input, offset, offset + 1)
  UnexpectedCharacter(
    message="Unexpected character: '" + text + "'",
    loc=Some(SourceLocation::make(input, start=offset, end=offset + 1)),
  )
}

///|
fn Lexer::match_token(self : Lexer) -> Token raise ParseFailure {
  let input = self.input
  let len = input.length()
  let start = self.offset
  let c = input[start]
  if is_space_code_unit(c) {
    let end = whitespace_end(input, start + 1)
    self.offset = end
    token_at(input, start, end, " ")
  } else if c == '\\' {
    match control_space_end(input, start) {
      Some(end) => {
        self.offset = end
        token_at(input, start, end, "\\ ")
      }
      None =>
        match verb_end(input, start, true) {
          Some(end) => {
            self.offset = end
            token_at(input, start, end, token_text(input, start, end))
          }
          None =>
            match verb_end(input, start, false) {
              Some(end) => {
                self.offset = end
                token_at(input, start, end, token_text(input, start, end))
              }
              None =>
                match control_word_end(input, start) {
                  Some((raw_end, end)) => {
                    self.offset = end
                    token_at(
                      input,
                      start,
                      end,
                      token_text(input, start, raw_end),
                    )
                  }
                  None =>
                    if start + 1 < len &&
                      !is_high_surrogate(input[start + 1]) &&
                      !is_low_surrogate(input[start + 1]) {
                      let end = start + 2
                      self.offset = end
                      token_at(input, start, end, token_text(input, start, end))
                    } else {
                      raise unexpected_character(input, start)
                    }
                }
            }
        }
    }
  } else if is_regular_code_unit(c) {
    let end = combining_marks_end(input, start + 1)
    self.offset = end
    token_at(input, start, end, token_text(input, start, end))
  } else if is_high_surrogate(c) &&
    start + 1 < len &&
    is_low_surrogate(input[start + 1]) {
    let end = combining_marks_end(input, start + 2)
    self.offset = end
    token_at(input, start, end, token_text(input, start, end))
  } else {
    raise unexpected_character(input, start)
  }
}

///|
fn Lexer::lex(self : Lexer) -> Token raise ParseFailure {
  for ;; {
    if self.offset == self.input.length() {
      break Token::eof(self.input, self.offset)
    }
    let token = self.match_token()
    if self.catcodes.get(token.text) == Some(14) {
      match next_line_start(self.input, self.offset) {
        Some(offset) => {
          self.offset = offset
          continue
        }
        None => {
          self.offset = self.input.length()
          (self.report_nonstrict)(
            "commentAtEnd", "% comment has no terminating newline; LaTeX would fail because of commenting the end of math mode (e.g. $)",
          )
          continue
        }
      }
    } else {
      break token
    }
  }
}