///|
/// Create a schema that validates JSON strings.
pub fn string(
  required_error? : String = "",
  invalid_type_error? : String = "",
) -> Schema {
  {
    schema_type: StringType,
    rules: [],
    description: "",
    required_error,
    invalid_type_error,
    name: "",
    brand: "",
  }
}

///|
fn schema_min_check(s : Schema, n : Int) -> (Json) -> Bool {
  match inner_type(s.schema_type) {
    StringType =>
      fn(json) {
        match json {
          String(s) => s.length() >= n
          _ => false
        }
      }
    NumberType =>
      fn(json) {
        match json {
          Number(v, ..) => v >= n.to_double()
          _ => false
        }
      }
    ArrayType(_) =>
      fn(json) {
        match json {
          Array(arr) => arr.length() >= n
          _ => false
        }
      }
    _ => fn(_) { false }
  }
}

///|
fn schema_min_msg(s : Schema, n : Int) -> String {
  match inner_type(s.schema_type) {
    StringType => "String must contain at least \{n} character(s)"
    NumberType => "Value must be >= \{n}"
    ArrayType(_) => "Array must contain at least \{n} item(s)"
    _ => abort("min() is only valid for string, number, or array schemas")
  }
}

///|
/// Require the string length to be at least `n`.
pub fn Schema::min(self : Schema, n : Int, msg? : String = "") -> Schema {
  let check = schema_min_check(self, n)
  let message = if msg.is_empty() { schema_min_msg(self, n) } else { msg }
  let annotation = match inner_type(self.schema_type) {
    StringType => Json::object({ "minLength": Json::number(n.to_double()) })
    NumberType => Json::object({ "minimum": Json::number(n.to_double()) })
    ArrayType(_) => Json::object({ "minItems": Json::number(n.to_double()) })
    _ => Json::null()
  }
  append_rule_with_annotation(self, check, message, annotation)
}

///|
fn schema_max_check(s : Schema, n : Int) -> (Json) -> Bool {
  match inner_type(s.schema_type) {
    StringType =>
      fn(json) {
        match json {
          String(s) => s.length() <= n
          _ => false
        }
      }
    NumberType =>
      fn(json) {
        match json {
          Number(v, ..) => v <= n.to_double()
          _ => false
        }
      }
    ArrayType(_) =>
      fn(json) {
        match json {
          Array(arr) => arr.length() <= n
          _ => false
        }
      }
    _ => fn(_) { false }
  }
}

///|
fn schema_max_msg(s : Schema, n : Int) -> String {
  match inner_type(s.schema_type) {
    StringType => "String must contain at most \{n} character(s)"
    NumberType => "Value must be <= \{n}"
    ArrayType(_) => "Array must contain at most \{n} item(s)"
    _ => abort("max() is only valid for string, number, or array schemas")
  }
}

///|
/// Require the string length to be at most `n`.
pub fn Schema::max(self : Schema, n : Int, msg? : String = "") -> Schema {
  let check = schema_max_check(self, n)
  let message = if msg.is_empty() { schema_max_msg(self, n) } else { msg }
  let annotation = match inner_type(self.schema_type) {
    StringType => Json::object({ "maxLength": Json::number(n.to_double()) })
    NumberType => Json::object({ "maximum": Json::number(n.to_double()) })
    ArrayType(_) => Json::object({ "maxItems": Json::number(n.to_double()) })
    _ => Json::null()
  }
  append_rule_with_annotation(self, check, message, annotation)
}

///|
fn find_unquoted_at(chars : Array[Char]) -> Int {
  let mut i = 0
  let n = chars.length()
  while i < n {
    if chars[i] == '"' {
      // Skip quoted content
      i = i + 1
      while i < n && chars[i] != '"' {
        i = i + 1
      }
      if i < n {
        i = i + 1 // skip closing quote
      }
    } else if chars[i] == '@' {
      return i
    } else {
      i = i + 1
    }
  }
  -1
}

///|
fn chars_to_string(chars : Array[Char], start : Int, end : Int) -> String {
  let mut result = ""
  for i = start; i < end; i = i + 1 {
    result = result + chars[i].to_string()
  }
  result
}

///|
fn is_valid_email(s : String) -> Bool {
  let chars = s.to_array()
  let n = chars.length()
  if n < 3 {
    return false
  }
  // Find @, respecting quoted local parts
  let at_pos = find_unquoted_at(chars)
  if at_pos <= 0 || at_pos >= n - 1 {
    return false
  }
  // Validate local part
  // Quoted local: starts with " and ends with " at at_pos-1
  if chars[0] == '"' {
    if chars[at_pos - 1] != '"' {
      return false
    }
    // Content between quotes is valid (any char except unescaped quote)
  } else {
    // Unquoted local: alphanumeric, dots, +, -, _
    if chars[0] == '.' || chars[at_pos - 1] == '.' {
      return false
    }
    for i = 0; i < at_pos; i = i + 1 {
      let c = chars[i]
      let valid = (c >= 'a' && c <= 'z') ||
        (c >= 'A' && c <= 'Z') ||
        (c >= '0' && c <= '9') ||
        c == '.' ||
        c == '+' ||
        c == '-' ||
        c == '_'
      if !valid {
        return false
      }
    }
  }
  // Validate domain part
  if chars[at_pos + 1] == '.' || chars[n - 1] == '.' {
    return false
  }
  // No additional @ in domain
  for i = at_pos + 1; i < n; i = i + 1 {
    if chars[i] == '@' {
      return false
    }
  }
  // Check for IP literal domain: [IPv6:...] or [192.168.1.1]
  if chars[at_pos + 1] == '[' {
    if chars[n - 1] != ']' {
      return false
    }
    // Check for IPv6: prefix
    if n - at_pos >= 10 &&
      chars[at_pos + 2] == 'I' &&
      chars[at_pos + 3] == 'P' &&
      chars[at_pos + 4] == 'v' &&
      chars[at_pos + 5] == '6' &&
      chars[at_pos + 6] == ':' {
      let ipv6_str = chars_to_string(chars, at_pos + 7, n - 1)
      return is_valid_ipv6(ipv6_str)
    }
    let ipv4_str = chars_to_string(chars, at_pos + 2, n - 1)
    return is_valid_ipv4(ipv4_str)
  }
  // Regular domain: find dots, validate last segment (TLD) >= 2 chars
  let mut last_dot = -1
  for i = at_pos + 1; i < n; i = i + 1 {
    if chars[i] == '.' {
      last_dot = i
    }
  }
  if last_dot < 0 {
    return false // no dot in domain
  }
  // TLD must be at least 2 chars
  let tld_len = n - 1 - last_dot
  if tld_len < 2 {
    return false
  }
  true
}

///|
/// Require the string to be a valid email.
pub fn Schema::email(self : Schema, msg? : String = "") -> Schema {
  match inner_type(self.schema_type) {
    StringType => ()
    _ => abort("email() is only valid for string schemas")
  }
  let message = if msg.is_empty() {
    "String must be a valid email address"
  } else {
    msg
  }
  append_rule_with_annotation(
    self,
    fn(json) {
      match json {
        String(s) => is_valid_email(s)
        _ => false
      }
    },
    message,
    Json::object({ "format": Json::string("email") }),
  )
}

///|
fn is_valid_url(s : String) -> Bool {
  let chars = s.to_array()
  let n = chars.length()
  if n < 8 {
    return false // minimum: x://x.xx
  }
  // Find ://
  if n < 4 {
    return false
  }
  // Scheme: must be http or https
  let scheme_end = if chars[0] == 'h' &&
    chars[1] == 't' &&
    chars[2] == 't' &&
    chars[3] == 'p' {
    if n > 5 && chars[4] == 's' {
      if n > 7 && chars[5] == ':' && chars[6] == '/' && chars[7] == '/' {
        8
      } else {
        return false
      }
    } else if chars[4] == ':' && chars[5] == '/' && chars[6] == '/' {
      7
    } else {
      return false
    }
  } else {
    return false
  }
  // Host part: between // and / or : or ? or # or end
  let mut host_end = n
  let mut port_start = -1
  let mut path_start = -1
  let mut query_start = -1
  let mut fragment_start = -1
  for i = scheme_end; i < n; i = i + 1 {
    if chars[i] == ':' && port_start < 0 {
      port_start = i
      host_end = i
    } else if chars[i] == '/' && path_start < 0 && port_start < 0 {
      path_start = i
      host_end = i
    } else if chars[i] == '/' && path_start < 0 && port_start >= 0 {
      path_start = i
    } else if chars[i] == '?' && query_start < 0 {
      query_start = i
      if host_end == n {
        host_end = i
      }
      if path_start < 0 {
        path_start = i
      }
    } else if chars[i] == '#' && fragment_start < 0 {
      fragment_start = i
      if host_end == n {
        host_end = i
      }
      if path_start < 0 {
        path_start = i
      }
      if query_start < 0 {
        query_start = i
      }
    }
  }
  if host_end == n {
    host_end = if port_start >= 0 { port_start } else { n }
  }
  // Validate host
  if host_end <= scheme_end {
    return false
  }
  let host_part = chars_to_string(chars, scheme_end, host_end)
  if host_part == "localhost" || is_valid_ipv4(host_part) {
    // valid
  } else {
    // Domain: at least one dot, no leading/trailing dot
    let host_chars = host_part.to_array()
    let hn = host_chars.length()
    if hn < 1 || host_chars[0] == '.' || host_chars[hn - 1] == '.' {
      return false
    }
    let mut has_dot = false
    for c in host_chars {
      let valid = (c >= 'a' && c <= 'z') ||
        (c >= 'A' && c <= 'Z') ||
        (c >= '0' && c <= '9') ||
        c == '.' ||
        c == '-'
      if !valid {
        return false
      }
      if c == '.' {
        has_dot = true
      }
    }
    if !has_dot {
      return false
    }
  }
  // Validate port
  if port_start >= 0 {
    let mut port_end = if path_start >= 0 { path_start } else { n }
    if query_start >= 0 && query_start < port_end {
      port_end = query_start
    }
    if fragment_start >= 0 && fragment_start < port_end {
      port_end = fragment_start
    }
    let port_str = chars_to_string(chars, port_start + 1, port_end)
    if port_str.is_empty() {
      return false
    }
    let mut port_val = 0
    for i = 0; i < port_str.length(); i = i + 1 {
      let c = port_str[i]
      if c < '0' || c > '9' {
        return false
      }
      port_val = port_val * 10 + (c.to_int() - 48)
    }
    if port_val < 1 || port_val > 65535 {
      return false
    }
  }
  // Path is already structurally valid (/anything)
  // Query is structurally valid (?anything)
  // Fragment is structurally valid (#anything)
  true
}

///|
/// Require the string to be a valid URL with http:// or https:// scheme.
pub fn Schema::url(self : Schema, msg? : String = "") -> Schema {
  match inner_type(self.schema_type) {
    StringType => ()
    _ => abort("url() is only valid for string schemas")
  }
  let message = if msg.is_empty() { "String must be a valid URL" } else { msg }
  append_rule_with_annotation(
    self,
    fn(json) {
      match json {
        String(s) => is_valid_url(s)
        _ => false
      }
    },
    message,
    Json::object({ "format": Json::string("uri") }),
  )
}

///|
/// Require the string to match the given regex pattern.
pub fn Schema::regex(
  self : Schema,
  pattern : String,
  msg? : String = "",
) -> Schema {
  match inner_type(self.schema_type) {
    StringType => ()
    _ => abort("regex() is only valid for string schemas")
  }
  let message = if msg.is_empty() {
    "String must match pattern: \{pattern}"
  } else {
    msg
  }
  let re = @regexp.compile(pattern) catch {
    _ => abort("Invalid regex pattern: \{pattern}")
  }
  append_rule_with_annotation(
    self,
    fn(json) {
      match json {
        String(s) => re.execute(s).matched()
        _ => false
      }
    },
    message,
    Json::object({ "pattern": Json::string(pattern) }),
  )
}

///|
/// Require the string to start with the given prefix.
pub fn Schema::startsWith(
  self : Schema,
  prefix : String,
  msg? : String = "",
) -> Schema {
  match inner_type(self.schema_type) {
    StringType => ()
    _ => abort("startsWith() is only valid for string schemas")
  }
  let message = if msg.is_empty() {
    "String must start with \"\{prefix}\""
  } else {
    msg
  }
  append_rule_with_annotation(
    self,
    fn(json) {
      match json {
        String(s) => s.has_prefix(prefix)
        _ => false
      }
    },
    message,
    Json::object({ "pattern": Json::string("^" + prefix) }),
  )
}

///|
/// Require the string to end with the given suffix.
pub fn Schema::endsWith(
  self : Schema,
  suffix : String,
  msg? : String = "",
) -> Schema {
  match inner_type(self.schema_type) {
    StringType => ()
    _ => abort("endsWith() is only valid for string schemas")
  }
  let message = if msg.is_empty() {
    "String must end with \"\{suffix}\""
  } else {
    msg
  }
  append_rule_with_annotation(
    self,
    fn(json) {
      match json {
        String(s) => s.has_suffix(suffix)
        _ => false
      }
    },
    message,
    Json::object({ "pattern": Json::string(suffix + "$") }),
  )
}

///|
/// Require the string to contain the given substring.
pub fn Schema::includes(
  self : Schema,
  substring : String,
  msg? : String = "",
) -> Schema {
  match inner_type(self.schema_type) {
    StringType => ()
    _ => abort("includes() is only valid for string schemas")
  }
  let message = if msg.is_empty() {
    "String must include \"\{substring}\""
  } else {
    msg
  }
  append_rule(
    self,
    fn(json) {
      match json {
        String(s) => s.contains(substring)
        _ => false
      }
    },
    message,
  )
}

///|
fn is_hex_digit(c : Char) -> Bool {
  (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')
}

///|
/// Require the string to be a valid UUID v4.
/// Format: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx
pub fn Schema::uuid(self : Schema, msg? : String = "") -> Schema {
  match inner_type(self.schema_type) {
    StringType => ()
    _ => abort("uuid() is only valid for string schemas")
  }
  let message = if msg.is_empty() {
    "String must be a valid UUID v4"
  } else {
    msg
  }
  append_rule_with_annotation(
    self,
    fn(json) {
      match json {
        String(s) => {
          let chars = s.to_array()
          if chars.length() != 36 {
            return false
          }
          // Dashes at positions 8, 13, 18, 23
          if chars[8] != '-' ||
            chars[13] != '-' ||
            chars[18] != '-' ||
            chars[23] != '-' {
            return false
          }
          // Version digit at position 14 must be '4'
          if chars[14] != '4' {
            return false
          }
          // Variant at position 19 must be 8/9/a/b/A/B
          if chars[19] != '8' &&
            chars[19] != '9' &&
            chars[19] != 'a' &&
            chars[19] != 'b' &&
            chars[19] != 'A' &&
            chars[19] != 'B' {
            return false
          }
          // All other chars must be hex digits
          let positions = [
            0, 1, 2, 3, 4, 5, 6, 7, 9, 10, 11, 12, 15, 16, 17, 20, 21, 22, 24, 25,
            26, 27, 28, 29, 30, 31, 32, 33, 34, 35,
          ]
          for i in positions {
            if !is_hex_digit(chars[i]) {
              return false
            }
          }
          true
        }
        _ => false
      }
    },
    message,
    Json::object({ "format": Json::string("uuid") }),
  )
}

///|
/// Require the string to be a valid CUID (25 chars, starts with 'c', alphanumeric).
pub fn Schema::cuid(self : Schema, msg? : String = "") -> Schema {
  match inner_type(self.schema_type) {
    StringType => ()
    _ => abort("cuid() is only valid for string schemas")
  }
  let message = if msg.is_empty() { "String must be a valid CUID" } else { msg }
  append_rule_with_annotation(
    self,
    fn(json) {
      match json {
        String(s) => {
          let chars = s.to_array()
          if chars.length() != 25 {
            return false
          }
          if chars[0] != 'c' {
            return false
          }
          for i = 1; i < 25; i = i + 1 {
            let c = chars[i]
            if !((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9')) {
              return false
            }
          }
          true
        }
        _ => false
      }
    },
    message,
    Json::object({ "format": Json::string("cuid") }),
  )
}

///|
fn is_leap_year(year : Int) -> Bool {
  year % 400 == 0 || (year % 4 == 0 && year % 100 != 0)
}

///|

///|
fn is_valid_iso8601(s : String) -> Bool {
  let chars = s.to_array()
  let n = chars.length()
  if n < 10 {
    return false
  }
  // Validate YYYY-MM-DD structure
  if chars[4] != '-' || chars[7] != '-' {
    return false
  }
  for i = 0; i < 10; i = i + 1 {
    if i == 4 || i == 7 {
      continue
    }
    if chars[i] < '0' || chars[i] > '9' {
      return false
    }
  }
  let year = (chars[0].to_int() - 48) * 1000 +
    (chars[1].to_int() - 48) * 100 +
    (chars[2].to_int() - 48) * 10 +
    (chars[3].to_int() - 48)
  let month = (chars[5].to_int() - 48) * 10 + (chars[6].to_int() - 48)
  let day = (chars[8].to_int() - 48) * 10 + (chars[9].to_int() - 48)
  if year < 1 || month < 1 || month > 12 || day < 1 {
    return false
  }
  let days_in_month = match month {
    1 | 3 | 5 | 7 | 8 | 10 | 12 => 31
    4 | 6 | 9 | 11 => 30
    2 => if is_leap_year(year) { 29 } else { 28 }
    _ => 0
  }
  if day > days_in_month {
    return false
  }
  if n == 10 {
    return true
  }
  if chars[10] != 'T' {
    return false
  }
  if n < 16 {
    return false
  }
  // Find timezone marker: 'Z' or +/- starting from position 19 (after HH:mm:ss)
  let mut tz_start = n
  if chars[n - 1] == 'Z' {
    tz_start = n - 1
  } else {
    for i = 19; i < n; i = i + 1 {
      if chars[i] == '+' || chars[i] == '-' {
        tz_start = i
        break
      }
    }
  }
  // Validate time part: chars[11..tz_start)
  let time_len = tz_start - 11
  if time_len != 5 && time_len != 8 {
    return false
  }
  // Check HH:mm or HH:mm:ss
  for i = 11; i < tz_start; i = i + 1 {
    if i == 13 || i == 16 {
      if chars[i] != ':' {
        return false
      }
    } else if chars[i] < '0' || chars[i] > '9' {
      return false
    }
  }
  let hour = (chars[11].to_int() - 48) * 10 + (chars[12].to_int() - 48)
  let minute = (chars[14].to_int() - 48) * 10 + (chars[15].to_int() - 48)
  if hour < 0 || hour > 23 || minute < 0 || minute > 59 {
    return false
  }
  if time_len == 8 {
    let second = (chars[17].to_int() - 48) * 10 + (chars[18].to_int() - 48)
    if second < 0 || second > 59 {
      return false
    }
  }
  true
}

///|
/// Require the string to be a valid ISO 8601 datetime.
pub fn Schema::datetime(self : Schema, msg? : String = "") -> Schema {
  match inner_type(self.schema_type) {
    StringType => ()
    _ => abort("datetime() is only valid for string schemas")
  }
  let message = if msg.is_empty() {
    "String must be a valid ISO 8601 datetime"
  } else {
    msg
  }
  append_rule_with_annotation(
    self,
    fn(json) {
      match json {
        String(s) => is_valid_iso8601(s)
        _ => false
      }
    },
    message,
    Json::object({ "format": Json::string("date-time") }),
  )
}

///|
fn is_valid_ipv4(s : String) -> Bool {
  let chars = s.to_array()
  let n = chars.length()
  if n < 7 || n > 15 {
    return false
  }
  // Count dots
  let mut dot_count = 0
  for c in chars {
    if c == '.' {
      dot_count = dot_count + 1
    }
  }
  if dot_count != 3 {
    return false
  }
  // Parse 4 segments
  let mut seg_start = 0
  for seg = 0; seg < 4; seg = seg + 1 {
    // Find end of this segment
    let mut seg_end = n
    for i = seg_start; i < n; i = i + 1 {
      if chars[i] == '.' {
        seg_end = i
        break
      }
    }
    let len = seg_end - seg_start
    if len < 1 || len > 3 {
      return false
    }
    // No leading zeros
    if len > 1 && chars[seg_start] == '0' {
      return false
    }
    // Parse digits
    let mut val = 0
    for i = seg_start; i < seg_end; i = i + 1 {
      if chars[i] < '0' || chars[i] > '9' {
        return false
      }
      val = val * 10 + (chars[i].to_int() - 48)
    }
    if val > 255 {
      return false
    }
    seg_start = seg_end + 1
  }
  true
}

///|
fn is_valid_ipv6(s : String) -> Bool {
  let chars = s.to_array()
  let n = chars.length()
  if n < 2 {
    return false
  }
  // Count colons and detect :: (treat :: as one separator)
  let mut colon_count = 0
  let mut double_colon = false
  let mut i = 0
  while i < n {
    if chars[i] == ':' {
      if i + 1 < n && chars[i + 1] == ':' {
        double_colon = true
        i = i + 2 // skip both ::
      } else {
        colon_count = colon_count + 1
        i = i + 1
      }
    } else {
      if !is_hex_digit(chars[i]) {
        return false
      }
      i = i + 1
    }
  }
  if double_colon && colon_count > 6 {
    return false
  }
  if !double_colon && colon_count != 7 {
    return false
  }
  // Validate each hex group between colons
  i = 0
  let mut group_len = 0
  let mut groups = 0
  while i < n {
    if chars[i] == ':' {
      if i + 1 < n && chars[i + 1] == ':' {
        // :: counts as group separator
        group_len = 0
        i = i + 2 // skip both ::
      } else {
        if group_len > 0 || groups > 0 {
          groups = groups + 1
        }
        group_len = 0
        i = i + 1
      }
    } else {
      group_len = group_len + 1
      if group_len > 4 {
        return false
      }
      i = i + 1
    }
  }
  if group_len > 0 {
    groups = groups + 1
  }
  if double_colon {
    groups <= 8
  } else {
    groups == 8
  }
}

///|
/// Require the string to be a valid IPv4 address.
pub fn Schema::ipv4(self : Schema, msg? : String = "") -> Schema {
  match inner_type(self.schema_type) {
    StringType => ()
    _ => abort("ipv4() is only valid for string schemas")
  }
  let message = if msg.is_empty() {
    "String must be a valid IPv4 address"
  } else {
    msg
  }
  append_rule_with_annotation(
    self,
    fn(json) {
      match json {
        String(s) => is_valid_ipv4(s)
        _ => false
      }
    },
    message,
    Json::object({ "format": Json::string("ipv4") }),
  )
}

///|
/// Require the string to be a valid IPv6 address.
pub fn Schema::ipv6(self : Schema, msg? : String = "") -> Schema {
  match inner_type(self.schema_type) {
    StringType => ()
    _ => abort("ipv6() is only valid for string schemas")
  }
  let message = if msg.is_empty() {
    "String must be a valid IPv6 address"
  } else {
    msg
  }
  append_rule_with_annotation(
    self,
    fn(json) {
      match json {
        String(s) => is_valid_ipv6(s)
        _ => false
      }
    },
    message,
    Json::object({ "format": Json::string("ipv6") }),
  )
}

///|
/// Require the string to be a valid IPv4 or IPv6 address.
pub fn Schema::ip(self : Schema, msg? : String = "") -> Schema {
  match inner_type(self.schema_type) {
    StringType => ()
    _ => abort("ip() is only valid for string schemas")
  }
  let message = if msg.is_empty() {
    "String must be a valid IP address"
  } else {
    msg
  }
  append_rule(
    self,
    fn(json) {
      match json {
        String(s) => is_valid_ipv4(s) || is_valid_ipv6(s)
        _ => false
      }
    },
    message,
  )
}

///|
fn schema_length_check(s : Schema, n : Int) -> (Json) -> Bool {
  match inner_type(s.schema_type) {
    StringType =>
      fn(json) {
        match json {
          String(s) => s.length() == n
          _ => false
        }
      }
    ArrayType(_) =>
      fn(json) {
        match json {
          Array(arr) => arr.length() == n
          _ => false
        }
      }
    _ => fn(_) { false }
  }
}

///|
fn schema_length_msg(s : Schema, n : Int) -> String {
  match inner_type(s.schema_type) {
    StringType => "String must contain exactly \{n} character(s)"
    ArrayType(_) => "Array must contain exactly \{n} item(s)"
    _ => abort("length() is only valid for string or array schemas")
  }
}

///|
/// Require the string to be non-empty (or array to be non-empty).
pub fn Schema::nonempty(self : Schema, msg? : String = "") -> Schema {
  match inner_type(self.schema_type) {
    StringType => ()
    ArrayType(_) => ()
    TupleType(_) => ()
    _ => abort("nonempty() is only valid for string, array, or tuple schemas")
  }
  let default_msg = match inner_type(self.schema_type) {
    StringType => "String must not be empty"
    _ => "Array must not be empty"
  }
  let message = if msg.is_empty() { default_msg } else { msg }
  append_rule(
    self,
    fn(json) {
      match json {
        String(s) => !s.is_empty()
        Array(arr) => !arr.is_empty()
        _ => false
      }
    },
    message,
  )
}

///|
/// Trim leading and trailing whitespace from the string.
pub fn Schema::trim(self : Schema) -> Schema {
  match inner_type(self.schema_type) {
    StringType => ()
    _ => abort("trim() is only valid for string schemas")
  }
  self.transform(fn(json) {
    match json {
      String(s) => Ok(Json::string(s.trim().to_owned()))
      _ => Err("Expected string")
    }
  })
}

///|
/// Convert the string to lowercase.
pub fn Schema::to_lower(self : Schema) -> Schema {
  match inner_type(self.schema_type) {
    StringType => ()
    _ => abort("to_lower() is only valid for string schemas")
  }
  self.transform(fn(json) {
    match json {
      String(s) => Ok(Json::string(s.to_lower()))
      _ => Err("Expected string")
    }
  })
}

///|
/// Convert the string to uppercase.
pub fn Schema::to_upper(self : Schema) -> Schema {
  match inner_type(self.schema_type) {
    StringType => ()
    _ => abort("to_upper() is only valid for string schemas")
  }
  self.transform(fn(json) {
    match json {
      String(s) => Ok(Json::string(s.to_upper()))
      _ => Err("Expected string")
    }
  })
}

///|
/// Require the string or array to have exactly `n` items.
pub fn Schema::length(self : Schema, n : Int, msg? : String = "") -> Schema {
  let check = schema_length_check(self, n)
  let message = if msg.is_empty() { schema_length_msg(self, n) } else { msg }
  // No annotation — avoids conflict with minLength/maxLength
  append_rule(self, check, message)
}

///|
fn is_crockford_base32(c : Char) -> Bool {
  (c >= '0' && c <= '9') ||
  (c >= 'A' && c <= 'H') ||
  (c >= 'J' && c <= 'N') ||
  (c >= 'P' && c <= 'Z')
}

///|
/// Require the string to be a valid ULID (26 chars, Crockford Base32).
pub fn Schema::ulid(self : Schema, msg? : String = "") -> Schema {
  match inner_type(self.schema_type) {
    StringType => ()
    _ => abort("ulid() is only valid for string schemas")
  }
  let message = if msg.is_empty() { "String must be a valid ULID" } else { msg }
  append_rule_with_annotation(
    self,
    fn(json) {
      match json {
        String(s) => {
          let chars = s.to_array()
          if chars.length() != 26 {
            return false
          }
          // First char must be 0-7 (timestamp high bits)
          if chars[0] < '0' || chars[0] > '7' {
            return false
          }
          for c in chars {
            if !is_crockford_base32(c) {
              return false
            }
          }
          true
        }
        _ => false
      }
    },
    message,
    Json::object({ "format": Json::string("ulid") }),
  )
}