///|
/// The fixed marker written in place of a value that must not enter a cassette.
pub const REDACTION_MARKER : String = "[REDACTED]"

///|
/// Additional redaction rules supplied by the caller.
///
/// Header and query names are compared case-insensitively. json_paths accepts
/// JSON Pointer paths such as /credentials/token and a dot shorthand such as
/// credentials.token. Array indexes are supported by JSON Pointer segments.
pub(all) struct RedactionConfig {
  headers : Array[String]
  query_parameters : Array[String]
  json_paths : Array[String]
} derive(Eq, @debug.Debug)

///|
/// Conservative defaults for credentials commonly sent by HTTP clients.
pub fn RedactionConfig::default() -> RedactionConfig {
  {
    headers: [
      "authorization", "proxy-authorization", "cookie", "set-cookie", "x-api-key",
      "x-auth-token", "x-access-token",
    ],
    query_parameters: [
      "api_key", "apikey", "access_token", "refresh_token", "client_secret", "token",
      "signature", "sig", "key", "secret",
    ],
    json_paths: [],
  }
}

///|
/// Errors raised when a configured value cannot be removed safely.
pub(all) enum RedactionError {
  InvalidJson(String)
  InvalidPath(String)
  MissingPath(String)
} derive(Eq, @debug.Debug)

///|
fn sensitive_header(name : String, config : RedactionConfig) -> Bool {
  header_name_is_listed(name, RedactionConfig::default().headers) ||
  header_name_is_listed(name, config.headers)
}

///|
fn sensitive_query_parameter(name : String, config : RedactionConfig) -> Bool {
  let normalized = name.trim().to_owned().to_lower()
  for candidate in RedactionConfig::default().query_parameters {
    if normalized == candidate.trim().to_owned().to_lower() {
      return true
    }
  }
  for candidate in config.query_parameters {
    if normalized == candidate.trim().to_owned().to_lower() {
      return true
    }
  }
  false
}

///|
fn redact_headers(
  headers : Array[Header],
  config : RedactionConfig,
) -> Array[Header] {
  headers.map(header => {
    if sensitive_header(header.name, config) {
      { name: header.name, value: REDACTION_MARKER, }
    } else {
      header
    }
  })
}

///|
/// Replace sensitive query values while preserving the URL's other spelling.
fn redact_url(url : String, config : RedactionConfig) -> String {
  let (without_fragment, fragment) = match url.split_once("#") {
    Some((before, after)) => (before.to_owned(), Some(after.to_owned()))
    None => (url, None)
  }
  match without_fragment.split_once("?") {
    None =>
      match fragment {
        Some(value) => without_fragment + "#" + value
        None => without_fragment
      }
    Some((base, raw_query)) => {
      let parts : Array[String] = []
      let mut changed = false
      for raw_view in raw_query.split("&") {
        let raw = raw_view.to_owned()
        match raw.split_once("=") {
          Some((key, value)) =>
            if sensitive_query_parameter(key.to_owned(), config) {
              parts.push(
                key.to_owned().trim().to_owned() + "=" + REDACTION_MARKER,
              )
              changed = true
            } else {
              parts.push(key.to_owned() + "=" + value.to_owned())
            }
          None =>
            if sensitive_query_parameter(raw, config) {
              parts.push(raw.trim().to_owned() + "=" + REDACTION_MARKER)
              changed = true
            } else {
              parts.push(raw)
            }
        }
      }
      let rebuilt = base.to_owned() + "?" + parts.join("&")
      let rebuilt = match fragment {
        Some(value) => rebuilt + "#" + value
        None => rebuilt
      }
      if !changed {
        return rebuilt
      }
      rebuilt
    }
  }
}

///|
fn decode_pointer_token(
  raw : StringView,
  path : String,
) -> Result[String, RedactionError] {
  let output = StringBuilder(size_hint=raw.length())
  let mut index = 0
  while index < raw.length() {
    let character = raw.unsafe_get(index)
    if character == '~' {
      if index + 1 >= raw.length() {
        return Err(InvalidPath(path + ""))
      }
      let escaped = raw.unsafe_get(index + 1)
      match escaped {
        '0' => output.write_char('~')
        '1' => output.write_char('/')
        _ => return Err(InvalidPath(path + ""))
      }
      index += 2
    } else {
      output.write_char(character.unsafe_to_char())
      index += 1
    }
  }
  Ok(output.to_string())
}

///|
/// Parse JSON Pointer or the documented dot shorthand into object-key tokens.
fn parse_redaction_path(path : String) -> Result[Array[String], RedactionError] {
  let trimmed = path.trim().to_owned()
  if trimmed.length() == 0 || trimmed == "$" {
    return Err(InvalidPath(path + ""))
  }
  if trimmed.has_prefix("/") {
    let tokens : Array[String] = []
    let tail = trimmed[1:].to_owned()
    for raw in tail.split("/") {
      match decode_pointer_token(raw, path + "") {
        Ok(token) => tokens.push(token)
        Err(error) => return Err(error)
      }
    }
    return Ok(tokens)
  }
  let shorthand = if trimmed.has_prefix("$.") {
    trimmed[2:].to_owned()
  } else if trimmed.has_prefix("$") {
    return Err(InvalidPath(path + ""))
  } else {
    trimmed
  }
  let tokens : Array[String] = []
  for raw in shorthand.split(".") {
    let token = raw.to_owned().trim().to_owned()
    if token.length() == 0 || token.contains_char('/') {
      return Err(InvalidPath(path + ""))
    }
    tokens.push(token)
  }
  Ok(tokens)
}

///|
fn parse_array_index(token : String) -> Int? {
  if token.length() == 0 {
    return None
  }
  let mut result = 0
  for index in 0.. nine {
      return None
    }
    let digit = code - zero
    if result > (2147483647 - digit) / 10 {
      return None
    }
    result = result * 10 + digit
  }
  Some(result)
}

///|
fn redact_json_at(
  value : Json,
  tokens : Array[String],
  depth : Int,
  path : String,
) -> Result[Json, RedactionError] {
  if depth >= tokens.length() {
    return Ok(Json::string(REDACTION_MARKER))
  }
  let token = tokens[depth] + ""
  match value {
    Object(members) =>
      match members.get(token + "") {
        None => Err(MissingPath(path + ""))
        Some(child) =>
          match redact_json_at(child, tokens, depth + 1, path) {
            Ok(updated_child) => {
              let updated = members.copy()
              updated[token] = updated_child
              Ok(Json::object(updated))
            }
            Err(error) => Err(error)
          }
      }
    Array(items) =>
      match parse_array_index(token) {
        None => Err(InvalidPath(path + ""))
        Some(index) =>
          match items.get(index) {
            None => Err(MissingPath(path + ""))
            Some(child) =>
              match redact_json_at(child, tokens, depth + 1, path) {
                Ok(updated_child) => {
                  let updated = items.copy()
                  updated[index] = updated_child
                  Ok(Json::array(updated))
                }
                Err(error) => Err(error)
              }
          }
      }
    _ => Err(MissingPath(path + ""))
  }
}

///|
fn redact_json_text(
  text : String,
  config : RedactionConfig,
) -> Result[String, RedactionError] {
  let mut json = @json.parse(text) catch {
    _ => return Err(InvalidJson("body is not valid JSON text"))
  }
  for path in config.json_paths {
    let tokens = match parse_redaction_path(path + "") {
      Ok(tokens) => tokens
      Err(error) => return Err(error)
    }
    json = match redact_json_at(json, tokens, 0, path + "") {
      Ok(updated) => updated
      Err(error) => return Err(error)
    }
  }
  Ok(json.stringify())
}

///|
fn redact_body(
  body : Body,
  config : RedactionConfig,
) -> Result[Body, RedactionError] {
  match body {
    Text(text) if config.json_paths.length() > 0 =>
      match redact_json_text(text, config) {
        Ok(value) => Ok(Text(value))
        Err(error) => Err(error)
      }
    other => Ok(other)
  }
}

///|
/// Return a request safe to store in a cassette.
pub fn redact_request(
  request : Request,
  config : RedactionConfig,
) -> Result[Request, RedactionError] {
  let { method, url, headers, body, } = request
  match redact_body(body, config) {
    Ok(safe_body) =>
      Ok({
        method,
        url: redact_url(url, config),
        headers: redact_headers(headers, config),
        body: safe_body,
      })
    Err(error) => Err(error)
  }
}

///|
/// Return a response safe to store in a cassette.
pub fn redact_response(
  response : Response,
  config : RedactionConfig,
) -> Result[Response, RedactionError] {
  let { status, headers, body, } = response
  match redact_body(body, config) {
    Ok(safe_body) =>
      Ok({ status, headers: redact_headers(headers, config), body: safe_body, })
    Err(error) => Err(error)
  }
}

///|
/// Redact both sides of an interaction before it is persisted.
pub fn redact_interaction(
  interaction : Interaction,
  config : RedactionConfig,
) -> Result[Interaction, RedactionError] {
  let { request, response, } = interaction
  match redact_request(request, config) {
    Err(error) => Err(error)
    Ok(safe_request) =>
      match redact_response(response, config) {
        Err(error) => Err(error)
        Ok(safe_response) =>
          Ok({ request: safe_request, response: safe_response, })
      }
  }
}