// Tokenizer for the W1 SQL subset. Keywords are case-insensitive;
// identifiers (bare or double-quoted, with "" escape), numbers (int32 /
// int64 / double with exponent), single-quoted strings (with '' escape),
// DATE 'yyyy-mm-dd' literals and line comments.

///|
priv enum Tok {
  Ident(String)
  IntLit(Int32)
  Int64Lit(Int64)
  DoubleLit(Double)
  StrLit(String)
  BoolLit(Bool)
  LParen
  RParen
  Comma
  Dot
  Star
  Plus
  Minus
  Slash
  Percent
  Eq
  Neq
  PipePipe
  Lt
  Le
  Gt
  Ge
  KwSelect
  KwWith
  KwFrom
  KwWhere
  KwAs
  KwAnd
  KwOr
  KwNot
  KwBetween
  KwDate
  KwGroup
  KwBy
  KwHaving
  KwOrder
  KwAsc
  KwDesc
  KwLimit
  KwCase
  KwWhen
  KwThen
  KwElse
  KwEnd
  KwLike
  KwIn
  KwDistinct
  KwJoin
  KwInner
  KwLeft
  KwOuter
  KwCross
  KwOn
  KwIs
  KwNull
  KwExtract
  KwYear
  KwMonth
  KwDay
  KwInterval
  KwOver
  KwPartition
  Semi
  Eof
} derive(Eq)

///|
priv struct Lexer {
  chars : Array[Char]
  mut pos : Int32
}

///|
fn Lexer::peek(self : Lexer) -> Char {
  self.chars[self.pos]
}

///|
fn lex_sql(sql : String) -> Array[Tok] raise @types.SqlError {
  let chars : Array[Char] = []
  for ch in sql {
    chars.push(ch)
  }
  let lx : Lexer = { chars, pos: 0, }
  let toks : Array[Tok] = []
  let n = chars.length()
  while lx.pos < n {
    let ch = lx.peek()
    if ch == ' ' || ch == '\t' || ch == '\n' || ch == '\r' {
      lx.pos += 1
      continue
    }
    if ch == '-' && lx.pos + 1 < n && chars[lx.pos + 1] == '-' {
      while lx.pos < n && chars[lx.pos] != '\n' {
        lx.pos += 1
      }
      continue
    }
    if is_letter(ch) || ch == '_' {
      let word = lx.lex_word()
      toks.push(keyword_or_ident(word))
      continue
    }
    if ch.to_int() >= 48 && ch.to_int() <= 57 {
      toks.push(lx.lex_number())
      continue
    }
    if ch == '\'' {
      toks.push(lx.lex_string())
      continue
    }
    if ch == '"' {
      toks.push(lx.lex_quoted_ident())
      continue
    }
    match ch {
      '(' => {
        toks.push(LParen)
        lx.pos += 1
      }
      ')' => {
        toks.push(RParen)
        lx.pos += 1
      }
      ',' => {
        toks.push(Comma)
        lx.pos += 1
      }
      ';' => {
        toks.push(Semi)
        lx.pos += 1
      }
      '.' => {
        toks.push(Dot)
        lx.pos += 1
      }
      '*' => {
        toks.push(Star)
        lx.pos += 1
      }
      '+' => {
        toks.push(Plus)
        lx.pos += 1
      }
      '-' => {
        toks.push(Minus)
        lx.pos += 1
      }
      '/' => {
        toks.push(Slash)
        lx.pos += 1
      }
      '%' => {
        toks.push(Percent)
        lx.pos += 1
      }
      '<' => {
        lx.pos += 1
        if lx.pos < n && chars[lx.pos] == '>' {
          toks.push(Neq)
          lx.pos += 1
        } else if lx.pos < n && chars[lx.pos] == '=' {
          toks.push(Le)
          lx.pos += 1
        } else {
          toks.push(Lt)
        }
      }
      '>' => {
        lx.pos += 1
        if lx.pos < n && chars[lx.pos] == '=' {
          toks.push(Ge)
          lx.pos += 1
        } else {
          toks.push(Gt)
        }
      }
      '=' => {
        toks.push(Eq)
        lx.pos += 1
      }
      '|' =>
        if lx.pos + 1 < n && chars[lx.pos + 1] == '|' {
          toks.push(PipePipe)
          lx.pos += 2
        } else {
          raise @types.SqlError::Parse("unexpected '|' (expected ||)")
        }
      '!' => {
        lx.pos += 1
        if lx.pos < n && chars[lx.pos] == '=' {
          toks.push(Neq)
          lx.pos += 1
        } else {
          raise @types.SqlError::Parse("unexpected '!' (expected <>)")
        }
      }
      _ =>
        raise @types.SqlError::Parse(
          "unexpected character '\{ch}' at position \{lx.pos}",
        )
    }
  }
  toks.push(Eof)
  toks
}

///|
fn is_letter(ch : Char) -> Bool {
  let v = ch.to_int()
  (v >= 65 && v <= 90) || (v >= 97 && v <= 122)
}

///|
fn is_digit_ch(ch : Char) -> Bool {
  let v = ch.to_int()
  v >= 48 && v <= 57
}

///|
fn Lexer::lex_word(self : Lexer) -> String {
  let sb = StringBuilder()
  let n = self.chars.length()
  while self.pos < n {
    let ch = self.chars[self.pos]
    if is_letter(ch) || ch == '_' || is_digit_ch(ch) {
      sb.write_char(ch)
      self.pos += 1
    } else {
      break
    }
  }
  sb.to_string()
}

///|
fn ascii_lower(word : String) -> String {
  let sb = StringBuilder()
  for ch in word {
    let v = ch.to_int()
    if v >= 65 && v <= 90 {
      sb.write_char((v + 32).to_char().unwrap())
    } else {
      sb.write_char(ch)
    }
  }
  sb.to_string()
}

///|
fn keyword_or_ident(word : String) -> Tok {
  match ascii_lower(word) {
    "select" => KwSelect
    "with" => KwWith
    "from" => KwFrom
    "where" => KwWhere
    "as" => KwAs
    "and" => KwAnd
    "or" => KwOr
    "not" => KwNot
    "between" => KwBetween
    "date" => KwDate
    "group" => KwGroup
    "by" => KwBy
    "having" => KwHaving
    "order" => KwOrder
    "asc" => KwAsc
    "desc" => KwDesc
    "limit" => KwLimit
    "case" => KwCase
    "when" => KwWhen
    "then" => KwThen
    "else" => KwElse
    "end" => KwEnd
    "like" => KwLike
    "in" => KwIn
    "distinct" => KwDistinct
    "true" => BoolLit(true)
    "false" => BoolLit(false)
    "join" => KwJoin
    "inner" => KwInner
    "left" => KwLeft
    "outer" => KwOuter
    "cross" => KwCross
    "on" => KwOn
    "is" => KwIs
    "null" => KwNull
    "extract" => KwExtract
    "interval" => KwInterval
    "year" => KwYear
    "month" => KwMonth
    "day" => KwDay
    "over" => KwOver
    "partition" => KwPartition
    _ => Ident(word)
  }
}

///|
fn Lexer::lex_number(self : Lexer) -> Tok raise @types.SqlError {
  let sb = StringBuilder()
  let n = self.chars.length()
  let mut is_double = false
  while self.pos < n {
    let ch = self.chars[self.pos]
    let v = ch.to_int()
    if v >= 48 && v <= 57 {
      sb.write_char(ch)
      self.pos += 1
    } else if ch == '.' {
      is_double = true
      sb.write_char(ch)
      self.pos += 1
    } else if ch == 'e' || ch == 'E' {
      is_double = true
      sb.write_char(ch)
      self.pos += 1
      if self.pos < n &&
        (self.chars[self.pos] == '-' || self.chars[self.pos] == '+') {
        sb.write_char(self.chars[self.pos])
        self.pos += 1
      }
    } else {
      break
    }
  }
  let text = sb.to_string()
  if is_double {
    match @types.parse_f64(text) {
      Some(v) => DoubleLit(v)
      None => raise @types.SqlError::Parse("bad number literal \"\{text}\"")
    }
  } else {
    // int32 first, then int64 so big integers keep exact values, then
    // float64 as the last resort (DuckDB-style literal widening)
    match @types.parse_i32(text) {
      Some(v) => IntLit(v)
      None =>
        match @types.parse_i64(text) {
          Some(v) => Int64Lit(v)
          None =>
            match @types.parse_f64(text) {
              Some(v) => DoubleLit(v)
              None =>
                raise @types.SqlError::Parse("bad integer literal \"\{text}\"")
            }
        }
    }
  }
}

///|
fn Lexer::lex_quoted_ident(self : Lexer) -> Tok raise @types.SqlError {
  // assumes chars[self.pos] == '"'; a quoted identifier never matches a
  // keyword, so "day", "left", "select" all resolve as plain names
  self.pos += 1
  let sb = StringBuilder()
  let n = self.chars.length()
  let mut closed = false
  while self.pos < n {
    let ch = self.chars[self.pos]
    if ch == '"' {
      if self.pos + 1 < n && self.chars[self.pos + 1] == '"' {
        sb.write_char('"') // "" escape
        self.pos += 2
      } else {
        self.pos += 1
        closed = true
        break
      }
    } else {
      sb.write_char(ch)
      self.pos += 1
    }
  }
  if !closed {
    raise @types.SqlError::Parse("unterminated quoted identifier")
  }
  Ident(sb.to_string())
}

///|
fn Lexer::lex_string(self : Lexer) -> Tok raise @types.SqlError {
  // assumes chars[self.pos] == '\''
  self.pos += 1
  let sb = StringBuilder()
  let n = self.chars.length()
  let mut closed = false
  while self.pos < n {
    let ch = self.chars[self.pos]
    if ch == '\'' {
      if self.pos + 1 < n && self.chars[self.pos + 1] == '\'' {
        sb.write_char('\'') // '' escape
        self.pos += 2
      } else {
        self.pos += 1
        closed = true
        break
      }
    } else {
      sb.write_char(ch)
      self.pos += 1
    }
  }
  if !closed {
    raise @types.SqlError::Parse("unterminated string literal")
  }
  StrLit(sb.to_string())
}