///|
/// The largest body this client will accept from an update endpoint.
///
/// A manifest and a signature are both small. A server that offers something
/// enormous in their place is either broken or hostile, and either way the
/// client should stop rather than fill memory on the way to a signature check
/// it was going to fail.
pub let maximum_metadata_bytes : Int32 = 1024 * 1024

///|
/// Opens the final response, closing each redirect before the next request.
/// Public fetch entry points require HTTPS before calling this helper.
async fn open_update_stream(
  url : String,
) -> (@http.Response, @http.Client) raise UpdateError {
  for current = url, redirects = 0 {
    let (response, client) = @http.get_stream(current) catch {
      error => raise FetchFailed(url=current, detail=@debug.render(Repr(error)))
    }
    if response.code == 200 {
      return (response, client)
    }
    client.close()
    guard response.code is (301 | 302 | 303 | 307 | 308) else {
      raise FetchFailed(url=current, detail="server responded \{response.code}")
    }
    guard redirects < 5 else {
      raise FetchFailed(
        url=current,
        detail="update redirect limit (5) exceeded",
      )
    }
    guard response.headers.get("location") is Some(location) else {
      raise FetchFailed(
        url=current,
        detail="redirect response has no Location header",
      )
    }
    continue update_redirect_url(current, location), redirects + 1
  }
}

///|
/// Resolve URI components without decoding or normalizing signed paths.
fn update_redirect_url(
  base_url : String,
  location : String,
) -> String raise UpdateError {
  let (base, target) = (@uri.Uri::parse(base_url), @uri.UriRef::parse(location)) catch {
    _ =>
      raise FetchFailed(
        url=base_url,
        detail="invalid redirect Location: \{location}",
      )
  }
  let scheme = target.scheme
    .map(s => s.as_str())
    .unwrap_or(base.scheme.as_str())
  guard scheme.to_lower() == "https" else {
    raise FetchFailed(url=base_url, detail="redirect target must use https")
  }
  let (authority, path, query) = if target.scheme is Some(_) ||
    target.authority is Some(_) {
    (target.authority, target.path.as_str(), target.query)
  } else if target.path.as_str().is_empty() {
    (
      base.authority,
      base.path.as_str(),
      match target.query {
        Some(_) => target.query
        None => base.query
      },
    )
  } else {
    let path = target.path.as_str()
    let merged = if path.has_prefix("/") {
      path
    } else {
      let base_path = base.path.as_str()
      match base_path.rev_find("/") {
        Some(index) => base_path[:index + 1].to_owned() + path
        None => "/" + path
      }
    }
    // RFC 3986 dot segments are literal '.' and '..'; %2E is path data.
    let segments : Array[String] = []
    let parts = merged.split("/").collect()
    for i, part in parts {
      match part {
        "." => ()
        ".." => if segments.length() > 1 { ignore(segments.pop()) }
        _ => segments.push(part.to_owned())
      }
      if i == parts.length() - 1 && (part == "." || part == "..") {
        segments.push("")
      }
    }
    (base.authority, segments.join("/"), target.query)
  }
  guard authority is Some(authority) && !authority.host.is_empty() else {
    raise FetchFailed(url=base_url, detail="redirect target must have a host")
  }
  // async/http splits authority from the request target at the first '/'.
  let path = if path.is_empty() { "/" } else { path }
  let query = match query {
    Some(query) => "?" + query.as_str()
    None => ""
  }
  "https://" + authority.as_str() + path + query
}

///|
/// Fetches update metadata over HTTPS.
///
/// Plain HTTP is refused. The signature is what makes a response trustworthy,
/// so TLS is not what protects this exchange — but there is no reason to
/// advertise which release a user is running to anyone on the path, and no
/// reason to accept a redirect into cleartext.
pub async fn http_fetch(url : String) -> Bytes raise UpdateError {
  guard url.has_prefix("https://") else {
    raise FetchFailed(url~, detail="only https URLs are fetched")
  }
  fetch_metadata_stream(url)
}

///|
async fn fetch_metadata_stream(url : String) -> Bytes raise UpdateError {
  let (_, client) = open_update_stream(url)
  defer client.close()
  let buffer = @buffer.Buffer()
  while (client.read_some(max_len=8192) catch {
          error => raise FetchFailed(url~, detail=@debug.render(Repr(error)))
        })
        is Some(chunk) {
    guard chunk.length() <= maximum_metadata_bytes - buffer.length() else {
      raise FetchFailed(
        url~,
        detail="body exceeds the \{maximum_metadata_bytes} byte limit",
      )
    }
    buffer.write_bytes(chunk)
  }
  buffer.to_bytes()
}

///|
/// Streams an artifact over HTTPS without retaining its body.
///
/// `expected_size` comes from the authenticated manifest. A `Content-Length`
/// mismatch can therefore be refused before reading the body, but the consumer
/// still counts actual chunks because the header may be absent after content
/// decoding or on a chunked response.
pub async fn http_fetch_artifact(
  url : String,
  expected_size : Int64,
  consume : async (Bytes) -> Unit raise UpdateError,
) -> Unit raise UpdateError {
  guard url.has_prefix("https://") else {
    raise FetchFailed(url~, detail="only https URLs are fetched")
  }
  let (response, client) = open_update_stream(url)
  defer client.close()
  match response.headers.get("content-length") {
    Some(text) => {
      let declared = @string.parse_int64(text[:]) catch {
        error => raise FetchFailed(url~, detail=@debug.render(Repr(error)))
      }
      guard declared == expected_size else {
        raise ArtifactSizeMismatch(expected=expected_size, actual=declared)
      }
    }
    None => ()
  }
  let mut received = 0L
  while (client.read_some() catch {
          error => raise FetchFailed(url~, detail=@debug.render(Repr(error)))
        })
        is Some(chunk) {
    let chunk_size = chunk.length().to_int64()
    let actual_size = artifact_size_after_chunk(
      expected_size, received, chunk_size,
    )
    consume(chunk)
    received = actual_size
    if received == expected_size {
      return
    }
  }
  guard received == expected_size else {
    raise ArtifactSizeMismatch(expected=expected_size, actual=received)
  }
}