///|
priv enum ScanToken {
  Literal(Char)
  Space
  Field(Char, Int, Int, Int, Array[(Char, Char)], Bool)
}

///|
fn scan_format(
  text : String,
  variables : Int,
) -> (Array[ScanToken], Int) raise TclError {
  let cs = utf16_units(text)
  let cursor = Ref(0)
  let tokens = []
  let used : Map[Int, Bool] = Map([])
  let mut mode = None
  let mut next = 0
  let mut slots = 0
  while cursor.val < cs.length() {
    let c = cs[cursor.val]
    cursor.val += 1
    if c != '%' {
      tokens.push(
        if (unicode_mask(c.to_int()) & 512) != 0 {
          Space
        } else {
          Literal(c)
        },
      )
      continue
    }
    if cursor.val < cs.length() && cs[cursor.val] == '%' {
      tokens.push(Literal('%'))
      cursor.val += 1
      continue
    }
    let discard = cursor.val < cs.length() && cs[cursor.val] == '*'
    if discard {
      cursor.val += 1
    }
    let saved = cursor.val
    let position = conversion_decimal(cs, cursor)
    let positional = !discard &&
      cursor.val < cs.length() &&
      cs[cursor.val] == '$'
    if positional {
      cursor.val += 1
    } else {
      cursor.val = saved
    }
    let slot = if discard {
      -1
    } else {
      if mode is Some(previous) && previous != positional {
        raise Invalid("mixed scan positional specifiers")
      }
      mode = Some(positional)
      let index = if positional {
        position - 1
      } else {
        let index = next
        next += 1
        index
      }
      if index < 0 || index >= 100000 || (variables > 0 && index >= variables) {
        raise Invalid("scan variable index out of range")
      }
      if used.contains(index) {
        raise Invalid("scan position assigned more than once")
      }
      used[index] = true
      slots = slots.max(index + 1)
      index
    }
    let before_width = cursor.val
    let width = conversion_decimal(cs, cursor)
    let has_width = cursor.val != before_width
    let mut bits = 32
    if cursor.val < cs.length() && cs[cursor.val] == 'h' {
      cursor.val += 1
    } else if cursor.val < cs.length() &&
      (cs[cursor.val] == 'l' || cs[cursor.val] == 'L') {
      let first = cs[cursor.val]
      bits = 64
      cursor.val += 1
      if first == 'l' && cursor.val < cs.length() && cs[cursor.val] == 'l' {
        bits = 0
        cursor.val += 1
      }
    }
    if cursor.val >= cs.length() {
      raise Invalid("incomplete scan conversion")
    }
    let kind = cs[cursor.val]
    cursor.val += 1
    if ![
        'd', 'i', 'u', 'o', 'x', 'X', 'b', 'c', 's', 'f', 'e', 'E', 'g', 'G', '[',
        'n',
      ].contains(kind) {
      raise Invalid("invalid scan conversion")
    }
    if kind == 'u' && bits == 0 {
      raise Invalid("unsigned bignum scans are invalid")
    }
    if ['c', 's', '[', 'n'].contains(kind) && bits != 32 {
      raise Invalid("invalid scan size modifier")
    }
    if kind == 'c' && has_width {
      raise Invalid("scan character width is forbidden")
    }
    let ranges = []
    let mut invert = false
    if kind == '[' {
      if cursor.val < cs.length() && cs[cursor.val] == '^' {
        invert = true
        cursor.val += 1
      }
      if cursor.val < cs.length() && cs[cursor.val] == ']' {
        ranges.push((']', ']'))
        cursor.val += 1
      }
      while cursor.val < cs.length() && cs[cursor.val] != ']' {
        let first = cs[cursor.val]
        cursor.val += 1
        if cursor.val + 1 < cs.length() &&
          cs[cursor.val] == '-' &&
          cs[cursor.val + 1] != ']' {
          ranges.push((first, cs[cursor.val + 1]))
          cursor.val += 2
        } else {
          ranges.push((first, first))
        }
      }
      if cursor.val == cs.length() {
        raise Invalid("unmatched scan character set")
      }
      cursor.val += 1
    }
    tokens.push(
      Field(
        kind,
        bits,
        if width == 0 {
          1000000
        } else {
          width
        },
        slot,
        ranges,
        invert,
      ),
    )
  }
  if variables > 0 && used.length() != variables {
    raise Invalid("scan variable count mismatch")
  }
  (tokens, slots)
}

///|
fn scan_integer_prefix(
  text : String,
  kind : Char,
) -> (Int, @bigint.BigInt, Bool) raise TclError {
  let cs = utf16_units(text)
  let n = cs.length()
  let negative = n > 0 && cs[0] == '-'
  let mut start = if n > 0 && (cs[0] == '-' || cs[0] == '+') { 1 } else { 0 }
  if start == n {
    return (0, 0N, true)
  }
  let mut base = if kind == 'x' || kind == 'X' {
    16
  } else if kind == 'o' {
    8
  } else if kind == 'b' {
    2
  } else {
    10
  }
  if kind == 'i' && cs[start] == '0' {
    base = 8
  }
  let mut end = start
  let mut value = 0N
  if cs[start] == '0' {
    end = start + 1
    if start + 2 < n {
      let marker = cs[start + 1]
      let prefixed = (
          (base == 16 || kind == 'i') && (marker == 'x' || marker == 'X')
        ) ||
        (base == 2 && (marker == 'b' || marker == 'B'))
      let proposed = if kind == 'i' { 16 } else { base }
      if prefixed &&
        hex_digit(cs[start + 2]) >= 0 &&
        hex_digit(cs[start + 2]) < proposed {
        base = proposed
        start += 2
        end = start
      }
    }
  }
  let mut i = start
  while i < n && hex_digit(cs[i]) >= 0 && hex_digit(cs[i]) < base {
    i += 1
    end = i
  }
  if end == start {
    return (0, 0N, false)
  }
  if end - start > 5000 {
    raise Invalid("scan integer size limit")
  }
  value = @strconv.parse_bigint(unit_slice(text, start, end), base~) catch {
    _ => raise Invalid("scan integer conversion")
  }
  if value.bit_length() > 16384 {
    raise Invalid("scan integer bit limit")
  }
  (end, if negative { -value } else { value }, false)
}

///|
fn scan_real_prefix(
  text : String,
  discard? : Bool = false,
) -> (Int, Double, Bool) {
  let cs = utf16_units(text)
  let n = cs.length()
  let mut i = if n > 0 && (cs[0] == '+' || cs[0] == '-') { 1 } else { 0 }
  let start = i
  let rest = unicode_case(unit_slice(text, start, n), 0)
  if rest.has_prefix("inf") {
    return (
      start + (if rest.has_prefix("infinity") { 8 } else { 3 }),
      if n > 0 && cs[0] == '-' {
        -1.0 / 0.0
      } else {
        1.0 / 0.0
      },
      false,
    )
  }
  if discard && rest.has_prefix("nan") {
    let mut end = start + 3
    if end < n && cs[end] == '(' {
      let mut i = end + 1
      while i < n && hex_digit(cs[i]) >= 0 {
        i += 1
      }
      if i > end + 1 && i - end - 1 <= 13 && i < n && cs[i] == ')' {
        end = i + 1
      }
    }
    return (end, 0.0, false)
  }
  let mut digits = 0
  while i < n && cs[i] >= '0' && cs[i] <= '9' {
    digits += 1
    i += 1
  }
  if i < n && cs[i] == '.' {
    i += 1
    while i < n && cs[i] >= '0' && cs[i] <= '9' {
      digits += 1
      i += 1
    }
  }
  if digits == 0 {
    return (0, 0.0, i == n)
  }
  let mut end = i
  if i < n && (cs[i] == 'e' || cs[i] == 'E') {
    i += 1
    if i < n && (cs[i] == '+' || cs[i] == '-') {
      i += 1
    }
    let before = i
    while i < n && cs[i] >= '0' && cs[i] <= '9' {
      i += 1
    }
    if i > before {
      end = i
    }
  }
  let value = conversion_parse_double(unit_slice(text, 0, end))
  (end, value, false)
}

///|
// Unlike UTF-16 indices, scan %n reports the byte offset in Tcl's string.
// This path measures canonical UTF-8 plus Tcl's two-byte NUL encoding.
fn scan_byte_offset(text : String, units : Int) -> Int {
  let mut bytes = 0
  for c in unit_slice(text, 0, units).to_array() {
    let cp = c.to_int()
    bytes += if cp > 65535 {
      4
    } else if cp > 0 && cp < 128 {
      1
    } else if cp < 2048 {
      2
    } else {
      3
    }
  }
  bytes
}

///|
fn Interpreter::scan_command(
  self : Interpreter,
  args : Array[String],
) -> TclValue raise TclError {
  if args.length() < 3 {
    raise Invalid("scan arity")
  }
  let variables = args.length() - 3
  let (tokens, slots) = scan_format(args[2], variables)
  let text = args[1]
  let cs = utf16_units(text)
  let values : Array[TclValue] = Array::makei(slots, _ => text_value(""))
  let mut at = 0
  let mut converted = 0
  let matched = Array::make(slots, false)
  let mut assigned = 0
  let mut eof = false
  for token in tokens {
    match token {
      Space =>
        while at < cs.length() && (unicode_mask(cs[at].to_int()) & 512) != 0 {
          at += 1
        }
      Literal(c) => {
        if at >= cs.length() {
          eof = true
          break
        }
        if cs[at] != c {
          break
        }
        at += 1
      }
      Field(kind, bits, width, slot, ranges, invert) => {
        if kind != 'c' && kind != '[' && kind != 'n' {
          while at < cs.length() && (unicode_mask(cs[at].to_int()) & 512) != 0 {
            at += 1
          }
        }
        if at >= cs.length() && kind != 'n' {
          eof = true
          break
        }
        let value = if kind == 'n' {
          number_value(Small(scan_byte_offset(text, at)))
        } else if kind == 'c' {
          let first = cs[at].to_int()
          at += 1
          if first >= 55296 &&
            first <= 56319 &&
            at < cs.length() &&
            cs[at].to_int() >= 56320 &&
            cs[at].to_int() <= 57343 {
            let cp = 65536 + (first - 55296) * 1024 + cs[at].to_int() - 56320
            at += 1
            number_value(Small(cp))
          } else {
            number_value(Small(first))
          }
        } else if kind == 's' || kind == '[' {
          let start = at
          let end = (at + width).min(cs.length())
          while at < end {
            let matches = if kind == 's' {
              (unicode_mask(cs[at].to_int()) & 512) == 0
            } else {
              ranges
              .iter()
              .any(pair => {
                (cs[at] >= pair.0 && cs[at] <= pair.1) ||
                (cs[at] >= pair.1 && cs[at] <= pair.0)
              }) !=
              invert
            }
            if !matches {
              break
            }
            at += 1
          }
          if at == start {
            break
          }
          text_value(unit_slice(text, start, at))
        } else if ['f', 'e', 'E', 'g', 'G'].contains(kind) {
          let (length, real, exhausted) = scan_real_prefix(
            unit_slice(text, at, (at + width).min(cs.length())),
            discard=slot < 0,
          )
          if length == 0 {
            eof = exhausted
            break
          }
          at += length
          if slot < 0 {
            text_value("")
          } else {
            number_value(Real(real))
          }
        } else {
          let (length, raw, exhausted) = scan_integer_prefix(
            unit_slice(text, at, (at + width).min(cs.length())),
            kind,
          )
          if length == 0 {
            eof = exhausted
            break
          }
          at += length
          let value = if bits == 0 {
            raw
          } else {
            let maximum = (1N << bits) - 1N
            let limited = if raw > maximum {
              maximum >> 1
            } else if raw < -maximum {
              -(1N << (bits - 1))
            } else {
              raw
            }
            conversion_wrap(limited, bits, kind != 'u')
          }
          number_value(Whole(value))
        }
        converted += 1
        if slot >= 0 {
          values[slot] = value
          matched[slot] = true
          assigned += 1
        }
      }
    }
  }
  if variables > 0 {
    for i in 0..