///|
fn apply_edit_attrs_transform(
  node : @dom.Node,
  transform : TransformSpec,
) -> Bool {
  match transform.attrs_callback {
    Some(callback) =>
      match (callback.callback)(node) {
        Some(attrs) => {
          attrs_replace_with(node, attrs)
          true
        }
        None => false
      }
    None => false
  }
}

///|
fn apply_set_attrs_transform(
  node : @dom.Node,
  transform : TransformSpec,
) -> Bool {
  match transform.attr_patch {
    Some(attrs) => {
      let mut changed = false
      for name, value in attrs {
        if node.attrs.get(name) != Some(value) {
          changed = true
        }
        node.attrs[name] = value
      }
      changed
    }
    None => false
  }
}

///|
fn attrs_replace_with(node : @dom.Node, attrs : Map[String, String?]) -> Unit {
  node.attrs.clear()
  for name, value in attrs {
    node.attrs[name] = value
  }
}

///|
fn transform_glob_match(pattern : StringView, value : StringView) -> Bool {
  transform_glob_match_at(pattern, 0, value, 0)
}

///|
fn transform_glob_match_at(
  pattern : StringView,
  pattern_pos : Int,
  value : StringView,
  value_pos : Int,
) -> Bool {
  if pattern_pos >= pattern.length() {
    return value_pos >= value.length()
  }
  match pattern.get_char(pattern_pos).unwrap() {
    '*' => {
      let mut next_pattern_pos = pattern_pos + 1
      while next_pattern_pos < pattern.length() &&
            pattern.get_char(next_pattern_pos) is Some('*') {
        next_pattern_pos += 1
      }
      if next_pattern_pos >= pattern.length() {
        return true
      }
      let mut scan = value_pos
      while scan <= value.length() {
        if transform_glob_match_at(pattern, next_pattern_pos, value, scan) {
          return true
        }
        if scan >= value.length() {
          break
        }
        scan += value.get_char(scan).unwrap().utf16_len()
      }
      false
    }
    pattern_ch =>
      match value.get_char(value_pos) {
        Some(value_ch) if value_ch == pattern_ch =>
          transform_glob_match_at(
            pattern,
            pattern_pos + pattern_ch.utf16_len(),
            value,
            value_pos + value_ch.utf16_len(),
          )
        _ => false
      }
  }
}

///|
fn attr_matches_any_pattern(
  name : StringView,
  patterns : Array[String],
) -> Bool {
  for pattern in patterns {
    if transform_glob_match(pattern, name) {
      return true
    }
  }
  false
}

///|
fn string_view_contains_char(value : StringView, target : Char) -> Bool {
  for ch in value {
    if ch == target {
      return true
    }
  }
  false
}

///|
fn drop_attrs_report_pattern(
  name : StringView,
  patterns : Array[String],
  hot_path : Bool,
) -> String {
  if hot_path {
    if name == "srcdoc" {
      "srcdoc"
    } else if string_view_contains_char(name, ':') {
      "*:*"
    } else {
      "on*"
    }
  } else {
    for pattern in patterns {
      if transform_glob_match(pattern, name) {
        return pattern
      }
    }
    "?"
  }
}

///|
fn report_dropped_attr_pattern(
  node : @dom.Node,
  transform : TransformSpec,
  name : StringView,
  pattern : String,
) -> Unit {
  transform_report(
    transform,
    "Unsafe attribute '" +
    name.to_owned() +
    "' (matched forbidden pattern '" +
    pattern +
    "')",
    Some(node),
  )
}

///|
fn transform_drop_attrs_hot_path(patterns : Array[String]) -> Bool {
  patterns.length() == 3 &&
  patterns.contains("*:*") &&
  patterns.contains("on*") &&
  patterns.contains("srcdoc")
}

///|
fn apply_drop_attrs_transform(
  node : @dom.Node,
  transform : TransformSpec,
) -> Bool {
  if transform.attr_patterns.is_empty() || node.attrs.length() == 0 {
    return false
  }
  let preserve_safe_key_case = transform_drop_attrs_hot_path(
    transform.attr_patterns,
  )
  let mut found = false
  for raw_name, _ in node.attrs {
    let name = @syn.lower_ascii(raw_name[:].trim())
    if name != "" && attr_matches_any_pattern(name, transform.attr_patterns) {
      found = true
      break
    }
  }
  if !found {
    return false
  }
  let out : Map[String, String?] = {}
  for raw_name, value in node.attrs {
    let name = @syn.lower_ascii(raw_name[:].trim())
    if name != "" && attr_matches_any_pattern(name, transform.attr_patterns) {
      report_dropped_attr_pattern(
        node,
        transform,
        name,
        drop_attrs_report_pattern(
          name,
          transform.attr_patterns,
          preserve_safe_key_case,
        ),
      )
    } else if name != "" {
      if preserve_safe_key_case {
        out[raw_name] = value
      } else {
        out[name] = value
      }
    }
  }
  transform_call_hook(node, transform)
  attrs_replace_with(node, out)
  true
}

///|
fn allowed_attrs_for_node(
  node : @dom.Node,
  allowed : Map[String, Array[String]],
) -> Array[String] {
  let tag = @syn.lower_ascii(node.name)
  let global = allowed.get("*").unwrap_or([])
  match allowed.get(tag) {
    Some(tag_attrs) => {
      let out = global.copy()
      for attr in tag_attrs {
        if !out.contains(attr) {
          out.push(attr)
        }
      }
      out
    }
    None => global.copy()
  }
}

///|
fn apply_allowlist_attrs_transform(
  node : @dom.Node,
  transform : TransformSpec,
) -> Bool {
  guard transform.allowed_attrs is Some(allowed) else { return false }
  if node.attrs.length() == 0 {
    return false
  }
  let allowed_for_node = allowed_attrs_for_node(node, allowed)
  let out : Map[String, String?] = {}
  let mut changed = false
  for raw_name, value in node.attrs {
    let trimmed = raw_name[:].trim()
    if trimmed.is_empty() {
      changed = true
      continue
    }
    if allowed_for_node.contains(raw_name) {
      out[raw_name] = value
    } else {
      let normalized = @syn.lower_ascii(trimmed)
      if allowed_for_node.contains(normalized) {
        out[normalized] = value
        if normalized != raw_name {
          changed = true
        }
      } else {
        changed = true
        transform_report(
          transform,
          "Unsafe attribute '" + normalized + "' (not allowed)",
          Some(node),
        )
      }
    }
  }
  if changed {
    transform_call_hook(node, transform)
    attrs_replace_with(node, out)
  }
  changed
}

///|
fn apply_merge_attrs_transform(
  node : @dom.Node,
  transform : TransformSpec,
) -> Bool {
  if transform.merge_attr == "" || transform.merge_tokens.is_empty() {
    return false
  }
  let matched_keys : Array[String] = []
  let existing : Array[String] = []
  for raw_name, value in node.attrs {
    if @syn.lower_ascii(raw_name[:].trim()) == transform.merge_attr {
      matched_keys.push(raw_name)
      match value {
        Some(raw_value) => @san.push_html_whitespace_tokens(existing, raw_value)
        None => ()
      }
    }
  }
  let had_exact_key = matched_keys.length() == 1 &&
    matched_keys[0] == transform.merge_attr
  let original_value = node.attrs.get(transform.merge_attr)
  for token in transform.merge_tokens {
    if !existing.contains(token) {
      existing.push(token)
    }
  }
  let normalized = existing.join(" ")
  if !had_exact_key || original_value != Some(Some(normalized)) {
    for key in matched_keys {
      node.attrs.remove(key)
    }
    node.attrs[transform.merge_attr] = Some(normalized)
    true
  } else {
    false
  }
}