///|
/// An LDAP URL (RFC 4516): scheme://host:port/base?attrs?scope?filter?exts.
pub struct LdapUrl {
  secure : Bool
  host : String
  port : Int
  base_dn : String
  attributes : Array[String]
  scope : SearchScope
  filter : String
} derive(Eq, @debug.Debug)

///|
pub fn LdapUrl::is_secure(self : LdapUrl) -> Bool {
  self.secure
}

///|
fn percent_decode(s : String) -> String raise LdapError {
  let bytes = @utf8.encode(s)
  let out : Array[Byte] = []
  let n = bytes.length()
  let mut i = 0
  while i < n {
    let b = bytes[i]
    if b == b'%' {
      if i + 2 >= n + 1 || i + 2 > n - 1 + 1 {
        // need two hex digits after '%'
        if i + 2 >= n + 1 {
          raise LdapError::Decode("truncated percent escape in URL")
        }
      }
      if i + 2 > n - 1 {
        raise LdapError::Decode("truncated percent escape in URL")
      }
      let hi = hex_digit(bytes[i + 1])
      let lo = hex_digit(bytes[i + 2])
      match (hi, lo) {
        (Some(h), Some(l)) => {
          out.push((h * 16 + l).to_byte())
          i = i + 3
        }
        _ => raise LdapError::Decode("invalid percent escape in URL")
      }
    } else {
      out.push(b)
      i = i + 1
    }
  }
  @utf8.decode_lossy(Bytes::from_array(out)[:])
}

///|
fn hex_digit(b : Byte) -> Int? {
  let c = b.to_char()
  if c >= '0' && c <= '9' {
    Some(c.to_int() - '0'.to_int())
  } else if c >= 'a' && c <= 'f' {
    Some(c.to_int() - 'a'.to_int() + 10)
  } else if c >= 'A' && c <= 'F' {
    Some(c.to_int() - 'A'.to_int() + 10)
  } else {
    None
  }
}

///|
fn find_char(s : String, target : Char) -> Int? {
  for i, ch in s {
    if ch == target {
      return Some(i)
    }
  }
  None
}

///|
fn earliest_char(s : String, targets : Array[Char]) -> Int? {
  let mut best : Int? = None
  for t in targets {
    match find_char(s, t) {
      Some(i) =>
        match best {
          Some(b) => if i < b { best = Some(i) }
          None => best = Some(i)
        }
      None => ()
    }
  }
  best
}

///|
fn take_chars(s : String, n : Int) -> String {
  let buf = StringBuilder()
  for i, ch in s {
    if i >= n {
      break
    }
    buf.write_char(ch)
  }
  buf.to_string()
}

///|
fn skip_chars(s : String, n : Int) -> String {
  let buf = StringBuilder()
  for i, ch in s {
    if i < n {
      continue
    }
    buf.write_char(ch)
  }
  buf.to_string()
}

///|
fn split_all(s : String, sep : Char) -> Array[String] {
  let out : Array[String] = []
  let mut rest = s
  while true {
    match find_char(rest, sep) {
      Some(i) => {
        out.push(take_chars(rest, i))
        rest = skip_chars(rest, i + 1)
      }
      None => {
        out.push(rest)
        break
      }
    }
  }
  out
}

///|
/// Parse an LDAP URL string per RFC 4516.
pub fn parse_ldap_url(url : String) -> Result[LdapUrl, LdapError] {
  result_of_ldap(fn() raise LdapError {
    let mut rest = url
    let mut secure = false
    if rest.has_prefix("ldap://") {
      rest = skip_chars(rest, 7)
    } else if rest.has_prefix("ldaps://") {
      rest = skip_chars(rest, 8)
      secure = true
    } else {
      raise LdapError::Decode("URL must start with ldap:// or ldaps://")
    }
    // authority ends at the first '/' or '?' (RFC 4516).
    let stop = earliest_char(rest, ['/', '?'])
    let authority = match stop {
      Some(i) => take_chars(rest, i)
      None => rest
    }
    let path = match stop {
      Some(i) => skip_chars(rest, i + 1)
      None => ""
    }
    if authority.is_empty() {
      raise LdapError::Decode("LDAP URL has no host")
    }
    // host[:port], with optional [ipv6] brackets.
    let mut host = authority
    let mut port_str = ""
    if authority.has_prefix("[") {
      let close = match find_char(authority, ']') {
        Some(i) => i
        None => raise LdapError::Decode("unclosed IPv6 bracket in URL")
      }
      host = take_chars(authority, close + 1)
      let tail = skip_chars(authority, close + 1)
      if !tail.is_empty() {
        if !tail.has_prefix(":") {
          raise LdapError::Decode("invalid characters after IPv6 address")
        }
        port_str = skip_chars(tail, 1)
      }
    } else {
      match find_char(authority, ':') {
        Some(i) => {
          host = take_chars(authority, i)
          port_str = skip_chars(authority, i + 1)
        }
        None => ()
      }
    }
    let default_port = if secure { 636 } else { 389 }
    let port = if port_str.is_empty() {
      default_port
    } else {
      let port_value = @string.parse_int(port_str) catch {
        _ => raise LdapError::Decode("invalid port in URL")
      }
      if port_value > 0 && port_value < 65536 {
        port_value
      } else {
        raise LdapError::Decode("port out of range in URL")
      }
    }
    // path: base?attrs?scope?filter?exts
    let parts = split_all(path, '?')
    let base_dn = if parts[0].is_empty() {
      ""
    } else {
      percent_decode(parts[0])
    }
    let attributes : Array[String] = []
    if parts.length() > 1 && !parts[1].is_empty() {
      let attrs_raw = percent_decode(parts[1])
      for attr in split_all(attrs_raw, ',') {
        if !attr.is_empty() {
          attributes.push(attr)
        }
      }
    }
    let scope = if parts.length() > 2 && !parts[2].is_empty() {
      match parts[2] {
        "base" => Base
        "one" => OneLevel
        "sub" => Subtree
        _ => raise LdapError::Decode("invalid scope in LDAP URL")
      }
    } else {
      Base
    }
    let filter = if parts.length() > 3 && !parts[3].is_empty() {
      percent_decode(parts[3])
    } else {
      "(objectClass=*)"
    }
    { secure, host, port, base_dn, attributes, scope, filter }
  })
}

///|
/// Serialize back to a canonical LDAP URL string.
pub fn LdapUrl::to_string(self : LdapUrl) -> String {
  let scheme = if self.secure { "ldaps" } else { "ldap" }
  let buf = StringBuilder()
  buf.write_string("\{scheme}://\{self.host}")
  let default_port = if self.secure { 636 } else { 389 }
  if self.port != default_port {
    buf.write_string(":\{self.port}")
  }
  if !self.base_dn.is_empty() {
    buf.write_string("/\{self.base_dn}")
  }
  if self.attributes.length() > 0 ||
    self.scope != Base ||
    self.filter != "(objectClass=*)" {
    buf.write_string("?")
    let mut first = true
    for attr in self.attributes {
      if !first {
        buf.write_string(",")
      }
      buf.write_string(attr)
      first = false
    }
    buf.write_string("?\{scope_to_str(self.scope)}")
    buf.write_string("?\{self.filter}")
  }
  buf.to_string()
}

///|
fn scope_to_str(scope : SearchScope) -> String {
  match scope {
    Base => "base"
    OneLevel => "one"
    Subtree => "sub"
  }
}