///|
priv enum TokenKind {
  Word(String)
  Number(Int)
  Quoted(String)
  KeywordSpec
  KeywordFeature
  KeywordGoal
  KeywordIn
  KeywordCost
  KeywordLocked
  KeywordAnd
  KeywordOr
  KeywordNot
  KeywordPresent
  KeywordTrue
  KeywordFalse
  Assign
  EqualEqual
  BangEqual
  LessToken
  LessEqualToken
  GreaterToken
  GreaterEqualToken
  LeftBracket
  RightBracket
  LeftParen
  RightParen
  Comma
  Newline
  End
}

///|
priv struct Token {
  kind : TokenKind
  span : Span
}

///|
fn token_name(kind : TokenKind) -> String {
  match kind {
    Word(value) => "identifier '\{value}'"
    Number(value) => "integer '\{value}'"
    Quoted(_) => "text value"
    KeywordSpec => "'spec'"
    KeywordFeature => "'feature'"
    KeywordGoal => "'goal'"
    KeywordIn => "'in'"
    KeywordCost => "'cost'"
    KeywordLocked => "'locked'"
    KeywordAnd => "'and'"
    KeywordOr => "'or'"
    KeywordNot => "'not'"
    KeywordPresent => "'present'"
    KeywordTrue => "'true'"
    KeywordFalse => "'false'"
    Assign => "'='"
    EqualEqual => "'=='"
    BangEqual => "'!='"
    LessToken => "'<'"
    LessEqualToken => "'<='"
    GreaterToken => "'>'"
    GreaterEqualToken => "'>='"
    LeftBracket => "'['"
    RightBracket => "']'"
    LeftParen => "'('"
    RightParen => "')'"
    Comma => "','"
    Newline => "end of line"
    End => "end of input"
  }
}

///|
fn lex(source : String) -> Result[Array[Token], Diagnostic] {
  let chars = source.to_array()
  let tokens : Array[Token] = []
  let mut index = 0
  let mut line = 1
  let mut column = 1
  while index < chars.length() {
    let ch = chars[index]
    if ch == ' ' || ch == '\t' || ch == '\r' {
      index = index + 1
      column = column + 1
      continue
    }
    if ch == '\n' {
      tokens.push({ kind: Newline, span: { offset: index, line, column } })
      index = index + 1
      line = line + 1
      column = 1
      continue
    }
    if ch == '#' {
      while index < chars.length() && chars[index] != '\n' {
        index = index + 1
        column = column + 1
      }
      continue
    }
    let start = Span::{ offset: index, line, column }
    if is_identifier_start(ch) {
      let begin = index
      while index < chars.length() && is_identifier_continue(chars[index]) {
        index = index + 1
        column = column + 1
      }
      let text = String::from_array(chars[begin:index])
      tokens.push({ kind: keyword_or_word(text), span: start })
      continue
    }
    if is_ascii_digit(ch) ||
      (
        ch == '-' &&
        index + 1 < chars.length() &&
        is_ascii_digit(chars[index + 1])
      ) {
      let negative = ch == '-'
      if negative {
        index = index + 1
        column = column + 1
      }
      let mut value = 0
      while index < chars.length() && is_ascii_digit(chars[index]) {
        let digit = chars[index].to_int() - '0'.to_int()
        let final_digit_limit = if negative { 8 } else { 7 }
        if value > 214748364 ||
          (value == 214748364 && digit > final_digit_limit) {
          return Err({
            code: "E_INTEGER_RANGE",
            message: "integer is outside the supported 32-bit range",
            span: start,
          })
        }
        value = value * 10 + digit
        index = index + 1
        column = column + 1
      }
      tokens.push({
        kind: Number(if negative { -value } else { value }),
        span: start,
      })
      continue
    }
    if ch == '"' {
      index = index + 1
      column = column + 1
      let text = StringBuilder::new()
      let mut closed = false
      while index < chars.length() {
        let current = chars[index]
        if current == '"' {
          index = index + 1
          column = column + 1
          closed = true
          break
        }
        if current == '\n' {
          return Err({
            code: "E_STRING_NEWLINE",
            message: "text values cannot span lines",
            span: start,
          })
        }
        if current == '\\' {
          if index + 1 >= chars.length() {
            return Err({
              code: "E_STRING_ESCAPE",
              message: "unfinished escape sequence",
              span: start,
            })
          }
          let escaped = chars[index + 1]
          match escaped {
            'n' => text.write_char('\n')
            'r' => text.write_char('\r')
            't' => text.write_char('\t')
            '\\' => text.write_char('\\')
            '"' => text.write_char('"')
            _ =>
              return Err({
                code: "E_STRING_ESCAPE",
                message: "unsupported escape '\\\{escaped}'",
                span: { offset: index, line, column },
              })
          }
          index = index + 2
          column = column + 2
        } else {
          text.write_char(current)
          index = index + 1
          column = column + 1
        }
      }
      if !closed {
        return Err({
          code: "E_STRING_UNCLOSED",
          message: "unterminated text value",
          span: start,
        })
      }
      tokens.push({ kind: Quoted(text.to_string()), span: start })
      continue
    }
    match ch {
      '=' =>
        if index + 1 < chars.length() && chars[index + 1] == '=' {
          tokens.push({ kind: EqualEqual, span: start })
          index = index + 2
          column = column + 2
        } else {
          tokens.push({ kind: Assign, span: start })
          index = index + 1
          column = column + 1
        }
      '!' =>
        if index + 1 < chars.length() && chars[index + 1] == '=' {
          tokens.push({ kind: BangEqual, span: start })
          index = index + 2
          column = column + 2
        } else {
          return Err({
            code: "E_UNEXPECTED_CHAR",
            message: "expected '=' after '!'",
            span: start,
          })
        }
      '<' =>
        if index + 1 < chars.length() && chars[index + 1] == '=' {
          tokens.push({ kind: LessEqualToken, span: start })
          index = index + 2
          column = column + 2
        } else {
          tokens.push({ kind: LessToken, span: start })
          index = index + 1
          column = column + 1
        }
      '>' =>
        if index + 1 < chars.length() && chars[index + 1] == '=' {
          tokens.push({ kind: GreaterEqualToken, span: start })
          index = index + 2
          column = column + 2
        } else {
          tokens.push({ kind: GreaterToken, span: start })
          index = index + 1
          column = column + 1
        }
      '[' => {
        tokens.push({ kind: LeftBracket, span: start })
        index = index + 1
        column = column + 1
      }
      ']' => {
        tokens.push({ kind: RightBracket, span: start })
        index = index + 1
        column = column + 1
      }
      '(' => {
        tokens.push({ kind: LeftParen, span: start })
        index = index + 1
        column = column + 1
      }
      ')' => {
        tokens.push({ kind: RightParen, span: start })
        index = index + 1
        column = column + 1
      }
      ',' => {
        tokens.push({ kind: Comma, span: start })
        index = index + 1
        column = column + 1
      }
      _ =>
        return Err({
          code: "E_UNEXPECTED_CHAR",
          message: "unexpected character '\{ch}'",
          span: start,
        })
    }
  }
  tokens.push({ kind: End, span: { offset: chars.length(), line, column } })
  Ok(tokens)
}

///|
fn is_ascii_digit(ch : Char) -> Bool {
  ch >= '0' && ch <= '9'
}

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

///|
fn is_identifier_continue(ch : Char) -> Bool {
  is_identifier_start(ch) || is_ascii_digit(ch) || ch == '-'
}

///|
fn keyword_or_word(text : String) -> TokenKind {
  match text {
    "spec" => KeywordSpec
    "feature" => KeywordFeature
    "goal" => KeywordGoal
    "in" => KeywordIn
    "cost" => KeywordCost
    "locked" => KeywordLocked
    "and" => KeywordAnd
    "or" => KeywordOr
    "not" => KeywordNot
    "present" => KeywordPresent
    "true" => KeywordTrue
    "false" => KeywordFalse
    _ => Word(text)
  }
}