///|
/// Strip spaces and tabs from both ends (plus a trailing CR, so Windows
/// line endings survive a `split("\n")`).
pub fn trim_spaces(text : StringView) -> String {
  let mut start = 0
  let end = text.length()
  while start < end {
    let ch = text[start].to_int()
    if ch == 0x20 || ch == 0x09 {
      start += 1
    } else {
      break
    }
  }
  let mut stop = end
  while stop > start {
    let ch = text[stop - 1].to_int()
    if ch == 0x20 || ch == 0x09 || ch == 0x0D {
      stop -= 1
    } else {
      break
    }
  }
  if start == 0 && stop == end {
    text.to_owned()
  } else if start >= stop {
    ""
  } else {
    text[start:stop].to_owned()
  }
}

///|
/// Strict non-negative integer: every character must be an ASCII digit,
/// empty input rejected. Returns `None` instead of a partial number.
pub fn parse_int(text : String) -> Int? {
  if text.is_empty() {
    return None
  }
  let mut value = 0
  for i in 0.. 0x39 {
      return None
    }
    value = value * 10 + (ch - 0x30)
  }
  Some(value)
}

///|
/// Text of the first double-quoted segment in a line, or `""` when the
/// line quotes nothing. Built for `key = "value"` manifest lines.
pub fn quoted_value(line : String) -> String {
  let mut start = -1
  let mut stop = line.length()
  for i, ch in line {
    if ch == '"' {
      if start < 0 {
        start = i
      } else {
        stop = i
        break
      }
    }
  }
  if start >= 0 && stop > start {
    line[start + 1:stop].to_owned()
  } else {
    ""
  }
}

///|
const HEX_DIGITS : String = "0123456789ABCDEF"

///|
fn hex_pair(value : Int) -> String {
  let high = value >> 4
  let low = value & 15
  HEX_DIGITS[high:high + 1].to_owned() + HEX_DIGITS[low:low + 1].to_owned()
}

///|
/// Escape a string for embedding inside a JSON double-quoted literal:
/// quotes, backslashes and the C/JSON control escapes; other control
/// characters become `\u00XX`.
pub fn json_escape(text : String) -> String {
  let buf = StringBuilder()
  for c in text {
    match c {
      '"' => buf.write_string("\\\"")
      '\\' => buf.write_string("\\\\")
      '\n' => buf.write_string("\\n")
      '\r' => buf.write_string("\\r")
      '\t' => buf.write_string("\\t")
      _ =>
        if c.to_int() < 0x20 {
          buf.write_string("\\u00" + hex_pair(c.to_int()))
        } else {
          buf.write_char(c)
        }
    }
  }
  buf.to_string()
}

///|
/// A JSON string literal: the text, escaped and wrapped in quotes.
pub fn jstr(text : String) -> String {
  "\"" + json_escape(text) + "\""
}

///|
/// Build a JSON object from already-encoded value fragments, so an optional
/// key simply does not appear instead of turning into a null the platform
/// may reject. Keys are quoted by `jstr`; values must arrive pre-encoded
/// (e.g. `jstr(...)` for strings, raw digits for numbers).
pub fn jobj(fields : Array[(String, String)]) -> String {
  let buf = StringBuilder()
  buf.write_char('{')
  let mut first = true
  for pair in fields {
    let (key, value) = pair
    if !first {
      buf.write_char(',')
    }
    first = false
    buf.write_string(jstr(key))
    buf.write_char(':')
    buf.write_string(value)
  }
  buf.write_char('}')
  buf.to_string()
}

///|
/// Build a JSON array from already-encoded item fragments.
pub fn jarr(items : Array[String]) -> String {
  let buf = StringBuilder()
  buf.write_char('[')
  let mut first = true
  for item in items {
    if !first {
      buf.write_char(',')
    }
    first = false
    buf.write_string(item)
  }
  buf.write_char(']')
  buf.to_string()
}

///|
fn is_unreserved(value : Int) -> Bool {
  (value >= 48 && value <= 57) ||
  (value >= 65 && value <= 90) ||
  (value >= 97 && value <= 122) ||
  value == 45 ||
  value == 46 ||
  value == 95 ||
  value == 126
}

///|
/// Percent-encode a value for a URL query string or a form body: everything
/// outside the RFC 3986 unreserved set becomes `%XX` over UTF-8 bytes.
pub fn url_encode(text : String) -> String {
  let buf = StringBuilder()
  for byte in @utf8.encode(text) {
    let value = byte.to_int()
    if is_unreserved(value) {
      // Unreserved bytes are ASCII, so decoding one byte back gives the char.
      buf.write_string(@utf8.decode_lossy(Bytes::from_array([byte])))
    } else {
      buf.write_char('%')
      buf.write_string(hex_pair(value))
    }
  }
  buf.to_string()
}

///|
/// True when the text is empty or made only of spaces/tabs.
pub fn is_blank(text : String) -> Bool {
  for c in text {
    let value = c.to_int()
    if value != 0x20 && value != 0x09 {
      return false
    }
  }
  true
}

///|
/// Drop `prefix` from the start of `text`; `None` when it is not there.
pub fn remove_prefix(text : String, prefix : String) -> String? {
  if prefix.is_empty() || !text.has_prefix(prefix) {
    None
  } else {
    Some(text[prefix.length():].to_owned())
  }
}

///|
/// Drop `suffix` from the end of `text`; `None` when it is not there.
pub fn remove_suffix(text : String, suffix : String) -> String? {
  if suffix.is_empty() || !text.has_suffix(suffix) {
    None
  } else {
    Some(text[:text.length() - suffix.length()].to_owned())
  }
}

///|
/// Pad `text` with `pad` (default `"0"`) on the left up to `target_len`.
/// Longer text is returned unchanged; an empty `pad` returns `text`.
pub fn pad_start(
  text : String,
  target_len : Int,
  pad? : String = "0",
) -> String {
  if text.length() >= target_len || pad.is_empty() {
    return text
  }
  let fill = pad.repeat(
    (target_len - text.length() + pad.length() - 1) / pad.length(),
  )
  fill[:target_len - text.length()].to_owned() + text
}

///|
/// Pad `text` with `pad` (default `" "`) on the right up to `target_len`.
pub fn pad_end(text : String, target_len : Int, pad? : String = " ") -> String {
  if text.length() >= target_len || pad.is_empty() {
    return text
  }
  let fill = pad.repeat(
    (target_len - text.length() + pad.length() - 1) / pad.length(),
  )
  text + fill[:target_len - text.length()].to_owned()
}

///|
/// Lowercase a Latin string (A-Z only; other chars unchanged).
pub fn to_lower(text : String) -> String {
  let buf = StringBuilder()
  for c in text {
    let value = c.to_int()
    if value >= 65 && value <= 90 {
      buf.write_char((value + 32).unsafe_to_char())
    } else {
      buf.write_char(c)
    }
  }
  buf.to_string()
}

///|
/// Uppercase a Latin string (a-z only; other chars unchanged).
pub fn to_upper(text : String) -> String {
  let buf = StringBuilder()
  for c in text {
    let value = c.to_int()
    if value >= 97 && value <= 122 {
      buf.write_char((value - 32).unsafe_to_char())
    } else {
      buf.write_char(c)
    }
  }
  buf.to_string()
}

///|
/// Split `text` on `sep` into owned strings. `sep` must be non-empty.
pub fn split(text : String, sep : String) -> Array[String] {
  let parts : Array[String] = []
  if sep.is_empty() {
    return parts
  }
  let mut start = 0
  let mut index = 0
  let limit = text.length() - sep.length()
  while index <= limit {
    if text[index:index + sep.length()].to_owned() == sep {
      parts.push(text[start:index].to_owned())
      index += sep.length()
      start = index
    } else {
      index += 1
    }
  }
  parts.push(text[start:].to_owned())
  parts
}

///|
/// Split into consecutive chunks of at most `size` items. A non-positive
/// `size` yields an empty array.
pub fn[T] chunk(items : Array[T], size : Int) -> Array[Array[T]] {
  let chunks : Array[Array[T]] = []
  if size <= 0 {
    return chunks
  }
  let mut current : Array[T] = []
  for item in items {
    current.push(item)
    if current.length() == size {
      chunks.push(current)
      current = []
    }
  }
  if !current.is_empty() {
    chunks.push(current)
  }
  chunks
}

///|
/// First occurrence of each value, order preserved.
pub fn[T : Eq] uniq(items : Array[T]) -> Array[T] {
  let seen : Array[T] = []
  let result : Array[T] = []
  for item in items {
    let mut found = false
    for s in seen {
      if s == item {
        found = true
        break
      }
    }
    if !found {
      seen.push(item)
      result.push(item)
    }
  }
  result
}

///|
/// Integers from `start` up to (excluding) `end` by `step` (default 1).
/// A non-positive `step` yields an empty array.
pub fn range(start : Int, end : Int, step? : Int = 1) -> Array[Int] {
  let values : Array[Int] = []
  if step <= 0 {
    return values
  }
  let mut current = start
  while current < end {
    values.push(current)
    current += step
  }
  values
}

///|
/// Clamp `n` into `[lo, hi]`.
pub fn clamp_int(n : Int, lo : Int, hi : Int) -> Int {
  if n < lo {
    lo
  } else if n > hi {
    hi
  } else {
    n
  }
}

///|
/// Bytes as uppercase hex (`"4142"`).
pub fn hex_encode(bytes : Bytes) -> String {
  let buf = StringBuilder()
  for byte in bytes {
    buf.write_string(hex_pair(byte.to_int()))
  }
  buf.to_string()
}

///|
/// Decode an even-length uppercase/lowercase hex string into bytes;
/// `None` on odd length or any non-hex character.
pub fn hex_decode(text : String) -> Bytes? {
  if text.length() % 2 != 0 {
    return None
  }
  let nibbles : Array[Int] = []
  for c in text {
    let value = c.to_int()
    let digit = if value >= 48 && value <= 57 {
      value - 48
    } else if value >= 65 && value <= 70 {
      value - 55
    } else if value >= 97 && value <= 102 {
      value - 87
    } else {
      return None
    }
    nibbles.push(digit)
  }
  let bytes : Array[Byte] = []
  let mut index = 0
  while index < nibbles.length() {
    bytes.push((nibbles[index] * 16 + nibbles[index + 1]).to_byte())
    index += 2
  }
  Some(Bytes::from_array(bytes))
}