///|
/// Parsed SASL DIGEST-MD5 challenge directives (RFC 2831 2.1.1).
pub struct DigestChallenge {
  realms : Array[String]
  nonce : String
  qop_options : Array[String]
  charset : String?
} derive(Eq, @debug.Debug)

///|
fn split_directives(s : String) -> Array[(String, String)] {
  let out : Array[(String, String)] = []
  let mut rest = s
  while !rest.is_empty() {
    // Take one directive up to the next top-level comma.
    let mut in_quotes = false
    let mut end = rest.length()
    for i, ch in rest {
      if ch == '"' {
        in_quotes = !in_quotes
      } else if ch == ',' && !in_quotes {
        end = i
        break
      }
    }
    let piece = take_chars(rest, end)
    match find_char(piece, '=') {
      Some(eq) =>
        out.push(
          (
            trim_spaces(take_chars(piece, eq)),
            trim_spaces(skip_chars(piece, eq + 1)),
          ),
        )
      None => ()
    }
    rest = skip_chars(rest, end + 1)
    // skip leading spaces of next directive
    while !rest.is_empty() && rest[0] == ' ' {
      rest = skip_chars(rest, 1)
    }
  }
  out
}

///|
fn trim_spaces(s : String) -> String {
  let start = find_first_not(s, ' ')
  let last = find_last_not(s, ' ')
  match (start, last) {
    (Some(a), Some(b)) => take_chars(skip_chars(s, a), b - a + 1)
    _ => ""
  }
}

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

///|
fn find_last_not(s : String, target : Char) -> Int? {
  let mut best : Int? = None
  for i, ch in s {
    if ch != target {
      best = Some(i)
    }
  }
  best
}

///|
fn unquote(v : String) -> String {
  if v.has_prefix("\"") && v.has_suffix("\"") && v.length() >= 2 {
    take_chars(skip_chars(v, 1), v.length() - 2)
  } else {
    v
  }
}

///|
/// Parse the BASE64-decoded challenge into directives. Fails when no nonce
/// is present (RFC 2831 requires it).
pub fn parse_digest_challenge(
  data : Bytes,
) -> Result[DigestChallenge, LdapError] {
  let text = @utf8.decode_lossy(data[:])
  let realms : Array[String] = []
  let mut nonce = ""
  let qops : Array[String] = []
  let mut charset : String? = None
  for directive in split_directives(text) {
    let (name, raw_value) = directive
    let value = unquote(raw_value)
    match name {
      "realm" => realms.push(value)
      "nonce" => nonce = value
      "qop" =>
        for opt in split_all(value, ',') {
          qops.push(trim_spaces(opt))
        }
      "charset" => charset = Some(value)
      _ => ()
    }
  }
  if nonce.is_empty() {
    return Err(LdapError::Decode("digest challenge has no nonce"))
  }
  Ok({ realms, nonce, qop_options: qops, charset })
}

///|
fn md5_of(s : String) -> String {
  md5_hex(@utf8.encode(s))
}

///|
/// Compute the DIGEST-MD5 `response` directive string (RFC 2831 2.1.2.1)
/// with qop=auth. The returned string is the credentials value sent back to
/// the server in the second bind.
pub fn compute_digest_md5_response(
  username~ : String,
  password~ : String,
  realm~ : String,
  nonce~ : String,
  cnonce~ : String,
  nc~ : String,
  qop~ : String,
  digest_uri~ : String,
) -> String {
  let ha1 = md5_of("\{username}:\{realm}:\{password}")
  let ha2 = md5_of("AUTHENTICATE:\{digest_uri}")
  let response = md5_hex(
    @utf8.encode("\{ha1}:\{nonce}:\{nc}:\{cnonce}:\{qop}:\{ha2}"),
  )
  let buf = StringBuilder()
  buf.write_string("username=\"\{username}\"")
  if !realm.is_empty() {
    buf.write_string(",realm=\"\{realm}\"")
  }
  buf.write_string(",nonce=\"\{nonce}\"")
  buf.write_string(",cnonce=\"\{cnonce}\"")
  buf.write_string(",nc=\{nc},qop=\{qop}")
  buf.write_string(",digest-uri=\"\{digest_uri}\"")
  buf.write_string(",response=\{response}")
  buf.write_string(",charset=utf-8")
  buf.to_string()
}

///|
/// Build an EXTERNAL SASL bind request (RFC 4422 appendix A / RFC 4616
/// style): the authzid travels as the SASL credentials; authentication is
/// derived from the transport layer.
pub fn sasl_external_bind_request(authzid : String) -> BindRequest {
  BindRequest::sasl(
    3,
    "",
    SaslCredentials::new("EXTERNAL", @utf8.encode(authzid)),
  )
}

///|
/// Perform the two-step DIGEST-MD5 bind exchange (RFC 2831): an initial
/// empty SASL bind, parsing the server challenge, then answering with the
/// computed response. `cnonce` can be supplied for deterministic tests;
/// when omitted it is derived from the nonce.
pub async fn[T : LdapTransport] Session::bind_digest_md5(
  self : Session[T],
  username : String,
  password : String,
  digest_uri? : String,
  cnonce? : String,
) -> Result[BindResponse, LdapError] {
  let initial = BindRequest::sasl(
    3,
    "",
    SaslCredentials::new("DIGEST-MD5", Bytes::new(0)),
  )
  let first = match self.bind_op(initial) {
    Ok(r) => r
    Err(e) => return Err(e)
  }
  if first.result.result_code == Success {
    return Ok(first)
  }
  if first.result.result_code != SaslBindInProgress {
    return Ok(first)
  }
  let challenge_b64 = match first.server_sasl_creds {
    Some(c) => c
    None => return Err(LdapError::Decode("digest-md5 challenge missing"))
  }
  let challenge = @base64.decode(@utf8.decode_lossy(challenge_b64[:])) catch {
    _ => return Err(LdapError::Decode("invalid base64 in digest challenge"))
  }
  let parsed = match parse_digest_challenge(challenge) {
    Ok(c) => c
    Err(e) => return Err(e)
  }
  let realm = if parsed.realms.is_empty() { "" } else { parsed.realms[0] }
  let uri = match digest_uri {
    Some(u) => u
    None => "ldap/\{self.config.host}"
  }
  let client_nonce = match cnonce {
    Some(c) => c
    None => md5_hex(@utf8.encode("\{username}:\{realm}:\{parsed.nonce}"))
  }
  let credentials = compute_digest_md5_response(
    username~,
    password~,
    realm~,
    nonce=parsed.nonce,
    cnonce=client_nonce,
    nc="00000001",
    qop="auth",
    digest_uri=uri,
  )
  let second = BindRequest::sasl(
    3,
    "",
    SaslCredentials::new("DIGEST-MD5", @utf8.encode(credentials)),
  )
  self.bind_op(second)
}