///|
/// AWS Signature Version 4 request signing.
///
/// Pure: it takes a clock reading rather than reading one, so the whole
/// signer is testable against the published AWS vectors.

///|
/// Connection details for an S3-compatible endpoint.
pub(all) struct S3Config {
  /// Scheme and host, no trailing slash, e.g. `https://s3.us-east-1.amazonaws.com`
  /// or `https://.r2.cloudflarestorage.com`.
  endpoint : String
  region : String
  bucket : String
  access_key_id : String
  secret_access_key : String
  /// Set for temporary credentials (STS).
  session_token : String?
  /// Address objects as `//` rather than as a
  /// virtual host. R2 and MinIO require this.
  path_style : Bool
}

///|
/// A request ready to hand to an HTTP client.
pub(all) struct SignedRequest {
  verb : String
  url : String
  headers : Map[String, String]
  body : Bytes
}

///|
/// The service name in the SigV4 credential scope. R2 and MinIO both present
/// themselves as `s3`.
const S3_SERVICE : String = "s3"

///|
/// RFC 3986 percent-encoding, as SigV4 requires it.
///
/// Only `A-Z a-z 0-9 - . _ ~` survive unencoded. `/` survives too when
/// `encode_slash` is false, which is how the canonical URI keeps its path
/// separators while a query value does not.
pub fn uri_encode(value : String, encode_slash : Bool) -> String {
  let digits = "0123456789ABCDEF"
  let out = StringBuilder()
  for byte in @utf8.encode(value) {
    let b = byte.to_int()
    let is_unreserved = (b >= 0x41 && b <= 0x5a) ||
      (b >= 0x61 && b <= 0x7a) ||
      (b >= 0x30 && b <= 0x39) ||
      b == 0x2d ||
      b == 0x2e ||
      b == 0x5f ||
      b == 0x7e
    if is_unreserved {
      out.write_char(b.unsafe_to_char())
    } else if b == 0x2f && !encode_slash {
      out.write_char('/')
    } else {
      out.write_char('%')
      out.write_char(digits[(b >> 4) & 0xf].to_int().unsafe_to_char())
      out.write_char(digits[b & 0xf].to_int().unsafe_to_char())
    }
  }
  out.to_string()
}

///|
/// Lowercase hex SHA-256 of a payload.
pub fn hex_sha256(data : Bytes) -> String {
  @hash.hex_encode(@hash.sha256_raw(data))
}

///|
/// Host (with port, when present) of an endpoint URL.
pub fn endpoint_host(endpoint : String) -> String raise @bit.GitError {
  let rest = if endpoint.has_prefix("https://") {
    String::unsafe_substring(endpoint, start=8, end=endpoint.length())
  } else if endpoint.has_prefix("http://") {
    String::unsafe_substring(endpoint, start=7, end=endpoint.length())
  } else {
    raise @bit.GitError::IoError(
      "endpoint must start with http:// or https://: \{endpoint}",
    )
  }
  let host = match rest.find("/") {
    Some(i) => String::unsafe_substring(rest, start=0, end=i)
    None => rest
  }
  if host.length() == 0 {
    raise @bit.GitError::IoError("endpoint has no host: \{endpoint}")
  }
  host
}

///|
fn lowercase(s : String) -> String {
  let out = StringBuilder()
  for c in s {
    let code = c.to_int()
    if code >= 0x41 && code <= 0x5a {
      out.write_char((code + 32).unsafe_to_char())
    } else {
      out.write_char(c)
    }
  }
  out.to_string()
}

///|
/// Strip the leading and trailing whitespace SigV4 requires trimmed from
/// header values before they are signed.
fn trim_header_value(s : String) -> String {
  let mut start = 0
  let mut end = s.length()
  while start < end && (s[start] == ' ' || s[start] == '\t') {
    start += 1
  }
  while end > start && (s[end - 1] == ' ' || s[end - 1] == '\t') {
    end -= 1
  }
  String::unsafe_substring(s, start~, end~)
}

///|
/// Canonical query string: percent-encoded pairs sorted by name.
fn canonical_query(query : Array[(String, String)]) -> String {
  let pairs : Array[(String, String)] = []
  for pair in query {
    pairs.push((uri_encode(pair.0, true), uri_encode(pair.1, true)))
  }
  pairs.sort_by((a, b) => {
    if a.0 == b.0 {
      lex_compare(a.1, b.1)
    } else {
      lex_compare(a.0, b.0)
    }
  })
  let out = StringBuilder()
  for i in 0.. 0 {
      out.write_char('&')
    }
    out.write_string(pairs[i].0)
    out.write_char('=')
    out.write_string(pairs[i].1)
  }
  out.to_string()
}

///|
/// The header set that will be signed: the caller's headers lowercased and
/// trimmed, plus the three (or four) SigV4 always adds.
fn signed_header_set(
  config : S3Config,
  headers : Map[String, String],
  payload_hash : String,
  host : String,
  amz_date : String,
) -> Map[String, String] {
  let signed : Map[String, String] = Map([])
  for name, value in headers {
    signed[lowercase(name)] = trim_header_value(value)
  }
  signed["host"] = host
  signed["x-amz-date"] = amz_date
  signed["x-amz-content-sha256"] = payload_hash
  match config.session_token {
    Some(token) => signed["x-amz-security-token"] = token
    None => ()
  }
  signed
}

///|
/// Canonical request plus the `SignedHeaders` list that goes with it. Both
/// fall out of the same sorted header walk, so they are produced together.
fn canonical_request_of(
  signed : Map[String, String],
  verb : String,
  path : String,
  query : Array[(String, String)],
  payload_hash : String,
) -> (String, String) {
  let names : Array[String] = []
  for name, _ in signed {
    names.push(name)
  }
  names.sort_by((a, b) => lex_compare(a, b))
  let canonical_headers = StringBuilder()
  let signed_headers = StringBuilder()
  for i in 0.. 0 {
      signed_headers.write_char(';')
    }
    signed_headers.write_string(names[i])
    canonical_headers.write_string(names[i])
    canonical_headers.write_char(':')
    canonical_headers.write_string(signed[names[i]])
    canonical_headers.write_char('\n')
  }
  let signed_headers_str = signed_headers.to_string()
  let canonical = "\{verb}\n" +
    "\{uri_encode(path, false)}\n" +
    "\{canonical_query(query)}\n" +
    "\{canonical_headers.to_string()}\n" +
    "\{signed_headers_str}\n" +
    payload_hash
  (canonical, signed_headers_str)
}

///|
/// The canonical request string, exposed for tests and for diagnosing the
/// `SignatureDoesNotMatch` responses that are otherwise opaque.
pub fn s3_canonical_request(
  config : S3Config,
  verb : String,
  path : String,
  query : Array[(String, String)],
  headers : Map[String, String],
  body : Bytes,
  amz_date : String,
) -> String raise @bit.GitError {
  let payload_hash = hex_sha256(body)
  let signed = signed_header_set(
    config,
    headers,
    payload_hash,
    endpoint_host(config.endpoint),
    amz_date,
  )
  let (canonical, _) = canonical_request_of(
    signed, verb, path, query, payload_hash,
  )
  canonical
}

///|
/// Sign a request for an S3-compatible endpoint.
///
/// `amz_date` is `YYYYMMDDTHHMMSSZ`; the credential scope's datestamp is its
/// first eight characters. Taking it as an argument rather than reading a
/// clock is what makes this function testable.
pub fn sign_s3_request(
  config : S3Config,
  verb : String,
  path : String,
  query : Array[(String, String)],
  headers : Map[String, String],
  body : Bytes,
  amz_date : String,
) -> SignedRequest raise @bit.GitError {
  if amz_date.length() < 8 {
    raise @bit.GitError::IoError("malformed x-amz-date: \{amz_date}")
  }
  let datestamp = String::unsafe_substring(amz_date, start=0, end=8)
  let payload_hash = hex_sha256(body)
  let signed = signed_header_set(
    config,
    headers,
    payload_hash,
    endpoint_host(config.endpoint),
    amz_date,
  )
  let (canonical_request, signed_headers_str) = canonical_request_of(
    signed, verb, path, query, payload_hash,
  )
  let scope = "\{datestamp}/\{config.region}/\{S3_SERVICE}/aws4_request"
  let string_to_sign = "AWS4-HMAC-SHA256\n" +
    "\{amz_date}\n" +
    "\{scope}\n" +
    hex_sha256(@utf8.encode(canonical_request))

  // kSigning = HMAC(HMAC(HMAC(HMAC("AWS4"+secret, date), region), service), "aws4_request")
  let k_date = @hash.hmac_sha256_bytes(
    @utf8.encode("AWS4" + config.secret_access_key),
    @utf8.encode(datestamp),
  )
  let k_region = @hash.hmac_sha256_bytes(k_date, @utf8.encode(config.region))
  let k_service = @hash.hmac_sha256_bytes(k_region, @utf8.encode(S3_SERVICE))
  let k_signing = @hash.hmac_sha256_bytes(
    k_service,
    @utf8.encode("aws4_request"),
  )
  let signature = @hash.hex_encode(
    @hash.hmac_sha256_raw(k_signing, @utf8.encode(string_to_sign)),
  )
  let out_headers : Map[String, String] = Map([])
  for name, value in signed {
    out_headers[name] = value
  }
  out_headers["Authorization"] = "AWS4-HMAC-SHA256 " +
    "Credential=\{config.access_key_id}/\{scope}, " +
    "SignedHeaders=\{signed_headers_str}, " +
    "Signature=\{signature}"
  let query_str = canonical_query(query)
  // The wire URL must carry the same encoding that was signed, or the
  // server recomputes a different canonical request and rejects it.
  let encoded_path = uri_encode(path, false)
  let url = if query_str.length() > 0 {
    "\{config.endpoint}\{encoded_path}?\{query_str}"
  } else {
    "\{config.endpoint}\{encoded_path}"
  }
  { verb, url, headers: out_headers, body }
}