///|
/// Reject CSS whose braces do not balance, so a directive cannot close the
/// rule it is placed in.
pub fn sanitize_css(css : String) -> String {
  let mut opened = 0
  let mut closed = 0
  for c in css {
    if opened < closed {
      return "{ /* ERROR: Unbalanced CSS */ }"
    }
    if c == '{' {
      opened += 1
    } else if c == '}' {
      closed += 1
    }
  }
  if opened != closed {
    "{ /* ERROR: Unbalanced CSS */ }"
  } else {
    css
  }
}

///|
fn is_unsafe_key(key : String) -> Bool {
  key.has_prefix("__") || key.contains("proto") || key.contains("constr")
}

///|
/// Whether `value` consists only of characters allowed in a theme variable:
/// `[\d "#%(),.;A-Za-z]+`.
fn is_plain_theme_value(value : String) -> Bool {
  value != "" &&
  value
  .iter()
  .all(c => {
    c
    is ('0'..<='9'
    | 'A'..<='Z'
    | 'a'..<='z'
    | ' '
    | '"'
    | '#'
    | '%'
    | '('
    | ')'
    | ','
    | '.'
    | ';')
  })
}

///|
/// Whether `value` is acceptable for the dictionary option `key`
/// (`nodeColors`, `filenameIcons`, `extensionIcons`), or `None` when `key`
/// is not such an option.
fn dictionary_value_ok(key : String, value : String) -> Bool? {
  let word = (c : Char) => {
    c is ('0'..<='9' | 'A'..<='Z' | 'a'..<='z' | '_' | '-')
  }
  match key {
    "nodeColors" => {
      let lower = value.to_lower()
      let hex = lower.strip_prefix("#") is Some(digits) &&
        digits.length() >= 3 &&
        digits.length() <= 8 &&
        digits.iter().all(c => c is ('0'..<='9' | 'a'..<='f'))
      let function = (name : String) => {
        lower.strip_prefix(name) is Some(rest) &&
        rest.strip_suffix(")") is Some(arguments) &&
        arguments.length() > 0 &&
        arguments
        .iter()
        .all(c => c is ('0'..<='9' | ' ' | '\t' | '\n' | '%' | ',' | '.'))
      }
      let keyword = lower != "" && lower.iter().all(c => c is ('a'..<='z'))
      Some(hex || function("rgb(") || function("hsl(") || keyword)
    }
    "filenameIcons" | "extensionIcons" => {
      let parts = value.split(":").collect()
      Some(
        parts.length() is (1 | 2) &&
        parts.iter().all(part => part.length() > 0 && part.iter().all(word)),
      )
    }
    _ => None
  }
}

///|
/// Strip everything from a directive (`%%{init: ...}%%` or frontmatter
/// `config`) that is not a known configuration key or looks unsafe.
pub fn sanitize_directive(directive : Json) -> Json {
  match directive {
    Array(items) => Json::array(items.map(sanitize_directive))
    Object(fields) => {
      let clean : Map[String, Json] = Map([])
      for key, value in fields {
        if is_unsafe_key(key) || !config_keys.contains(key) || value is Null {
          continue
        }
        clean[key] = match value {
          Object(entries) if dictionary_value_ok(key, "") is Some(_) => {
            let kept : Map[String, Json] = Map([])
            for name, entry in entries {
              if !is_unsafe_key(name) &&
                entry is String(text) &&
                dictionary_value_ok(key, text) is Some(true) {
                kept[name] = entry
              }
            }
            Json::object(kept)
          }
          Object(_) | Array(_) => sanitize_directive(value)
          String(text) if ["themeCSS", "fontFamily", "altFontFamily"]
            .iter()
            .any(css_key => key.contains(css_key)) =>
            Json::string(sanitize_css(text))
          _ => value
        }
      }
      if clean.get("themeVariables") is Some(Object(variables)) {
        let checked : Map[String, Json] = Map([])
        for name, value in variables {
          checked[name] = match value {
            String(text) if !is_plain_theme_value(text) => Json::string("")
            _ => value
          }
        }
        clean["themeVariables"] = Json::object(checked)
      }
      Json::object(clean)
    }
    other => other
  }
}

///|
/// Remove keys a diagram may never change — `secure` itself and the keys it
/// lists — along with dunder keys and strings that could carry markup.
fn sanitize(options : Json, secure : Array[String]) -> Json {
  guard options is Object(fields) else { return options }
  let clean : Map[String, Json] = Map([])
  for key, value in fields {
    if key == "secure" || secure.contains(key) || key.has_prefix("__") {
      continue
    }
    match value {
      String(text) if text.contains("<") ||
        text.contains(">") ||
        text.contains("url(data:") => continue
      Object(_) | Array(_) => clean[key] = sanitize(value, secure)
      _ => clean[key] = value
    }
  }
  Json::object(clean)
}