///|
/// Controls which request fields participate in cassette matching.
pub(all) enum BodyMatchMode {
  Exact
  Ignore
} derive(Eq, @debug.Debug)

///|
/// Header and body matching policy for a session.
pub(all) struct MatchConfig {
  include_headers : Array[String]
  ignore_headers : Array[String]
  body_mode : BodyMatchMode
} derive(Eq, @debug.Debug)

///|
/// Return the conservative default matching policy.
pub fn MatchConfig::default() -> MatchConfig {
  { include_headers: [], ignore_headers: [], body_mode: Exact, }
}

///|
fn normalized_header_name(name : String) -> String {
  name.trim().to_owned().to_lower()
}

///|
fn normalized_header_value(value : String) -> String {
  value.trim().to_owned()
}

///|
fn header_name_is_listed(name : String, names : Array[String]) -> Bool {
  let normalized = normalized_header_name(name)
  for candidate in names {
    if normalized == normalized_header_name(candidate) {
      return true
    }
  }
  false
}

///|
fn should_include_header(name : String, config : MatchConfig) -> Bool {
  if header_name_is_listed(name, config.ignore_headers) {
    return false
  }
  if config.include_headers.length() == 0 {
    return true
  }
  header_name_is_listed(name, config.include_headers)
}

///|
fn normalize_query(url : String) -> String {
  let without_fragment = match url.split_once("#") {
    Some((before, _)) => before.to_owned()
    None => url
  }
  match without_fragment.split_once("?") {
    None => without_fragment
    Some((base, raw_query)) => {
      let parts : Array[(String, String, Int)] = []
      for raw_part in raw_query.split("&") {
        let part = raw_part.to_owned()
        if part.length() == 0 {
          continue
        }
        let (name, normalized) = match part.split_once("=") {
          Some((key, value)) =>
            (key.to_owned(), key.to_owned() + "=" + value.to_owned())
          None => (part, part + "=")
        }
        // Encoded spellings of one name still share an ordered value group.
        parts.push(
          (
            percent_decode_query_name(name, plus_as_space=true),
            normalized,
            parts.length(),
          ),
        )
      }
      if parts.length() == 0 {
        base.to_owned()
      } else {
        parts.sort_by((left, right) => {
          let (left_name, _, left_index) = left
          let (right_name, _, right_index) = right
          let order = left_name.compare(right_name)
          if order == 0 {
            left_index.compare(right_index)
          } else {
            order
          }
        })
        base.to_owned() +
        "?" +
        parts
        .map(part => {
          let (_, value, _) = part
          value
        })
        .join("&")
      }
    }
  }
}

///|
/// Remove fragments and order different query names without changing the
/// relative order of repeated names or trimming query values.
pub fn normalize_url(url : String) -> String {
  normalize_query(url)
}

///|
fn normalize_headers(headers : Array[Header], config : MatchConfig) -> String {
  let parts : Array[(String, String, Int)] = []
  for header in headers {
    if should_include_header(header.name, config) {
      let name = normalized_header_name(header.name)
      parts.push(
        (
          name,
          length_prefix(name) +
          "=" +
          length_prefix(normalized_header_value(header.value)),
          parts.length(),
        ),
      )
    }
  }
  parts.sort_by((left, right) => {
    let (left_name, _, left_index) = left
    let (right_name, _, right_index) = right
    let order = left_name.compare(right_name)
    if order == 0 {
      left_index.compare(right_index)
    } else {
      order
    }
  })
  parts
  .map(part => {
    let (_, value, _) = part
    value
  })
  .join("&")
}

///|
fn normalize_body(body : Body, mode : BodyMatchMode) -> String {
  match mode {
    Ignore => ""
    Exact =>
      match body {
        Empty => "empty"
        Text(value) => "text:" + length_prefix(value)
        Base64(value) => "base64:" + length_prefix(value)
      }
  }
}

///|
/// Prefix a value with its length so delimiters inside the value cannot make
/// two different request structures share the same matching key.
fn length_prefix(value : String) -> String {
  value.length().to_string() + ":" + value
}

///|
/// Describe a request without copying URL, header, query, or body values.
/// This is suitable for public candidate errors.
pub fn request_safe_summary(request : Request) -> String {
  let method = safe_method_description(request.method)
  let body = match request.body {
    Empty => "empty"
    Text(value) => "text(len=" + value.length().to_string() + ")"
    Base64(value) => "base64(len=" + value.length().to_string() + ")"
  }
  "method=" +
  method +
  ",url-len=" +
  request.url.length().to_string() +
  ",headers=" +
  request.headers.length().to_string() +
  ",body=" +
  body
}

///|
fn safe_method_description(method : String) -> String {
  let normalized = method.trim().to_owned().to_upper()
  match normalized {
    "GET"
    | "HEAD"
    | "POST"
    | "PUT"
    | "DELETE"
    | "CONNECT"
    | "OPTIONS"
    | "TRACE"
    | "PATCH" => normalized
    _ => ""
  }
}

///|
fn safe_name_description(kind : String, name : String) -> String {
  "<" +
  kind +
  " len=" +
  name.length().to_string() +
  ",fp=" +
  safe_fingerprint(name) +
  ">"
}

///|
/// Build a deterministic, transport-independent key for a request.
pub fn normalize_request(request : Request, config : MatchConfig) -> String {
  let method = request.method.trim().to_owned().to_upper()
  let headers = normalize_headers(request.headers, config)
  let body = normalize_body(request.body, config.body_mode)
  "method=" +
  length_prefix(method) +
  "\nurl=" +
  length_prefix(normalize_url(request.url)) +
  "\nheaders=" +
  headers +
  "\nbody=" +
  body
}