///|
pub fn[T, G] patch_tree(
  tree : RespoNode[T, G],
  old_tree : RespoNode[T, G],
  mount_target : @dom_ffi.Node,
  changes : Array[DomChange[T, G]],
  handle_event : (RespoEventMark) -> Unit raise RespoCommonError,
) -> Unit raise RespoCommonError {
  // let el = mount_target.dyn_ref::().expect("to element");

  // @dom_ffi.log("changes:(\{changes.length()}) " + changes.join(","))
  if mount_target.child_nodes().length() != 1 {
    raise RespoCommonError(
      "expected a single node under mount target, got: " +
      mount_target.child_nodes().length().to_string(),
    )
  }

  // handle BeforeUpdate before DOM changes
  for op in changes {
    match op {
      Effect(coord~, effect_type~, skip_indexes~, ..) =>
        if effect_type == BeforeUpdate {
          if mount_target.first_child() is Some(child) {
            let target = find_coord_dom_target(
              child,
              op.get_dom_path().to_array(),
            )
            let target_tree = if effect_type == BeforeUnmount {
              load_coord_target_tree(old_tree, coord.to_array())
            } else {
              load_coord_target_tree(tree, coord.to_array())
            }
            match target_tree {
              Component(left) => {
                let { effects, .. } = left
                for idx, effect in effects.iter2() {
                  if !skip_indexes.contains(idx) {
                    let f = effect.handler
                    f(effect_type, target)
                    // TODO
                  }
                }
              }
              _ =>
                @dom_ffi.warn_log(
                  "expected component for effects, got: \{target_tree}",
                )
            }
          } else {
            @dom_ffi.warn_log("target not found")
          }
        }
      // handle effect before update
      _ => ()
    }
  }
  for op in changes {
    // @dom_ffi.log("op: " + op.to_string())
    if mount_target.first_child() is Some(child) {
      let target = find_coord_dom_target(child, op.get_dom_path().to_array())
      match op {
        ModifyAttrs(set~, unset~, ..) => {
          let el = target.reinterpret_as_element()
          for pair in set {
            let (k, v) = pair
            if k == "innerText" {
              el.set_inner_text(v)
            } else if k == "innerHTML" {
              el.set_inner_html(v)
            } else if k == "htmlFor" {
              el.set_html_for(v)
            } else if k == "value" {
              match el.tag_name() {
                "INPUT" => {
                  let input_el = el.reinterpret_as_html_input_element()
                  let prev_value = input_el.value()
                  if prev_value != v {
                    input_el.set_value(v)
                  }
                }
                "TEXTAREA" => {
                  let textarea_el = el.reinterpret_as_html_textarea_element()
                  let prev_value = textarea_el.value()
                  if prev_value != v {
                    textarea_el.set_value(v)
                  }
                }
                name => raise RespoCommonError("unsupported value for:" + name)
              }
            } else if k.has_prefix("data-") {
              el.set_data_attribute(k[5:].to_owned(), v)
            } else {
              el.set_attribute(k, v)
            }
          }
          for k in unset {
            if k == "innerText" {
              el.set_inner_text("")
            } else if k == "innerHTML" {
              el.set_inner_html("")
            } else if k == "value" {
              let input_el = el.reinterpret_as_html_input_element()
              let prev_value = input_el.value()
              if !prev_value.is_empty() {
                input_el.set_value("")
              }
            } else if k.has_prefix("data-") {
              el.remove_data_attribute(k[5:].to_owned())
            } else {
              el.remove_attribute(k)
            }
          }
        }
        ModifyStyle(set~, unset~, ..) => {
          let style = target.style()
          for s in unset {
            // future solution could be `style.removeProperty`
            style.set_property(kabab_to_camel_case(s), "")
          }
          for pair in set {
            let (k, v) = pair
            style.set_property(kabab_to_camel_case(k), v)
          }
        }
        ModifyEvent(add~, remove~, coord~, ..) => {
          let el = target.reinterpret_as_element()
          for k in add.iter() {
            attach_event(el, k, coord, handle_event)
          }
          for k in remove {
            match k {
              Click => el.unset_onclick()
              Input => el.reinterpret_as_html_input_element().unset_oninput()
              DragStart => el.unset_ondragstart()
              DragEnd => el.unset_ondragend()
              DragOver => el.unset_ondragover()
              DragEnter => el.unset_ondragenter()
              DragLeave => el.unset_ondragleave()
              Drop => el.unset_ondrop()
              _ => @dom_ffi.warn_log("TODO event: " + k.to_string())
            }
          }
        }
        ReplaceElement(node~, coord~, ..) => {
          let parent = target.parent_element()
          let new_element = build_dom_tree(node, coord, handle_event)
          parent.reinterpret_as_node().insert_before(new_element, Some(target))
          target.remove()
        }
        ModifyChildren(operations~, coord~, ..) => {
          let base_tree = load_coord_target_tree(tree, coord.to_array())
          let old_base_tree = load_coord_target_tree(old_tree, coord.to_array())
          for op in operations {
            match op {
              Append(k, node) => {
                let next_coord = coord.push(Key(k))
                let new_element = build_dom_tree(node, next_coord, handle_event)
                target.append_child(new_element)
              }
              Prepend(k, node) => {
                let next_coord = coord.push(Key(k))
                let new_element = build_dom_tree(node, next_coord, handle_event)
                if target.child_nodes().is_empty() {
                  target.append_child(new_element)
                } else if target.first_child() is Some(base) {
                  target.insert_before(new_element, Some(base))
                } else {
                  target.append_child(new_element)
                }
              }
              RemoveAt(idx) => {
                let child = target
                  .children()
                  .get(idx.reinterpret_as_int())
                  .unwrap()
                target.remove_child(child)
              }
              InsertAfter(idx, k, node) => {
                let children = target.children()
                if idx >= children.length().reinterpret_as_uint() {
                  raise RespoCommonError(
                    "child to insert not found at {}" + idx.to_string(),
                  )
                } else {
                  let handler = handle_event
                  let next_coord = coord.push(Key(k))
                  let new_element = build_dom_tree(node, next_coord, handler)
                  match
                    (idx + 1).compare(children.length().reinterpret_as_uint()) {
                    -1 => {
                      let child = children
                        .get(idx.reinterpret_as_int() + 1)
                        .unwrap()
                      target.insert_before(new_element, Some(child))
                    }
                    0 => target.append_child(new_element)
                    1 =>
                      raise RespoCommonError(
                        "out of bounds: " +
                        idx.to_string() +
                        " of " +
                        children.length().to_string() +
                        " at coord " +
                        coord_path_to_cirru(coord).to_string(),
                      )
                    _ => () // compare returns -1, 0, 1
                  }
                }
              }
              NestedEffect(
                nested_coord~,
                nested_dom_path=nesteed_dom_path,
                effect_type~,
                skip_indexes~
              ) => {
                let target_tree = if effect_type == BeforeUnmount {
                  load_coord_target_tree(
                    old_base_tree,
                    nested_coord.to_array()[:],
                  )
                } else {
                  load_coord_target_tree(base_tree, nested_coord.to_array()[:])
                }
                let nested_el = find_coord_dom_target(
                  target,
                  nesteed_dom_path.to_array(),
                )
                match target_tree {
                  Component({ effects, .. }) =>
                    for idx, effect in effects {
                      if !skip_indexes.contains(idx) {
                        let f = effect.handler
                        f(effect_type, nested_el)
                      }
                    }
                  _ =>
                    @dom_ffi.warn_log(
                      "expected component for effects, got: \{target_tree}",
                    )
                }
              }
            }
          }
        }
        Effect(coord~, effect_type~, skip_indexes~, ..) => {
          if effect_type == BeforeUpdate {
            // should be handled before current pass
            continue
          }
          let target_tree = if effect_type == BeforeUnmount {
            load_coord_target_tree(old_tree, coord.to_array()[:])
          } else {
            load_coord_target_tree(tree, coord.to_array()[:])
          }
          match target_tree {
            Component({ effects, .. }) =>
              for idx, effect in effects {
                if !skip_indexes.contains(idx) {
                  let f = effect.handler
                  f(effect_type, target)
                }
              }
            _ =>
              @dom_ffi.warn_log(
                "expected component for effects, got: \{target_tree}",
              )
          }
        }
      }
    } else {
      @dom_ffi.warn_log("target not found")
    }
  }
}

///|
fn find_coord_dom_target(
  mount_target : @dom_ffi.Node,
  coord : Array[UInt],
) -> @dom_ffi.Node raise RespoCommonError {
  let mut target = mount_target
  for idx in coord {
    let child = target.child_nodes().get(idx.reinterpret_as_int())
    if child is None {
      raise RespoCommonError("no child at index " + idx.to_string())
    }
    target = child.unwrap_or_error(
      RespoCommonError("does not find child at index: " + idx.to_string()),
    )
  }
  target
}

///|
pub fn attach_event(
  element : @dom_ffi.Element,
  key : RespoEventType,
  coord : @immut/vector.Vector[RespoCoord],
  handle_event : (RespoEventMark) -> Unit raise RespoCommonError,
) -> Unit raise RespoCommonError {
  // @dom_ffi.log("attach event: " + key.to_string())
  match key {
    Click => {
      let handler = fn(e : @dom_ffi.MouseEvent) -> Unit {
        let wrap_event = RespoEvent::Click(
          client_x=e.client_x(),
          client_y=e.client_y(),
          original_event=e,
        )
        handle_event({ coord, name: Click, event_info: wrap_event }) catch {
          e => @dom_ffi.error_log("handle click event: " + e.to_string())
        }
      }
      element.set_onclick(handler)
    }
    DblClick => {
      let handler = fn(e : @dom_ffi.MouseEvent) {
        let wrap_event = RespoEvent::Click(
          client_x=e.client_x(),
          client_y=e.client_y(),
          original_event=e,
        )
        handle_event({ coord, name: DblClick, event_info: wrap_event }) catch {
          e => @dom_ffi.error_log("handle dblclick event: " + e.to_string())
        }
      }
      element.set_ondblclick(handler)
    }
    Input => {
      let handler = fn(e : @dom_ffi.InputEvent) {
        let target = e.target()
        let el : @dom_ffi.Element = target
        let value = match el.tag_name() {
          "INPUT" => el.reinterpret_as_html_input_element().value()
          "TEXTAREA" => el.reinterpret_as_html_textarea_element().value()
          _ =>
            // TODO Error
            return
        }
        let wrap_event = RespoEvent::Input(value~, original_event=e)
        handle_event({ coord, name: Input, event_info: wrap_event }) catch {
          e => @dom_ffi.error_log("handle input event: " + e.to_string())
        }
      }
      match element.tag_name() {
        "INPUT" =>
          element.reinterpret_as_html_input_element().set_oninput(handler)
        "TEXTAREA" =>
          element
          .reinterpret_as_html_textarea_element()
          .set_oninput(Some(handler))
        _ =>
          raise RespoCommonError(
            "unsupported input event: " + element.tag_name(),
          )
      }
    }
    Change => {
      let handler = fn(e : @dom_ffi.InputEvent) {
        let wrap_event = RespoEvent::Input(
          value=e.target().reinterpret_as_html_input_element().value(),
          original_event=e,
        )
        handle_event({ coord, name: Change, event_info: wrap_event }) catch {
          e => @dom_ffi.error_log("handle change event: " + e.to_string())
        }
      }
      match element.tag_name() {
        "INPUT" =>
          element
          .reinterpret_as_html_input_element()
          .set_onchange(Some(handler))
        "TEXTAREA" =>
          element
          .reinterpret_as_html_textarea_element()
          .set_onchange(Some(handler))
        _ =>
          @dom_ffi.warn_log(
            "not handled change event for element: " + element.tag_name(),
          )
      }
    }
    Keydown => {
      let handler = fn(e : @dom_ffi.KeyboardEvent) {
        // crate::util::log!("calling handler");
        let wrap_event = RespoEvent::Keyboard(
          key=e.key(),
          key_code=e.key_code(),
          shift_key=e.shift_key(),
          ctrl_key=e.ctrl_key(),
          alt_key=e.alt_key(),
          meta_key=e.meta_key(),
          repeat=e.repeat(),
          original_event=e,
        )
        handle_event({ coord, name: Keydown, event_info: wrap_event }) catch {
          e => @dom_ffi.error_log("handle keydown event: " + e.to_string())
        }
      }
      match element.tag_name() {
        "INPUT" =>
          element
          .reinterpret_as_html_input_element()
          .set_onkeydown(Some(handler))
        "TEXTAREA" =>
          element
          .reinterpret_as_html_textarea_element()
          .set_onkeydown(Some(handler))
        _ =>
          @dom_ffi.warn_log(
            "not handled keydown event for element: " + element.tag_name(),
          )
      }
    }
    Keyup => {
      let handler = fn(e : @dom_ffi.KeyboardEvent) {
        let wrap_event = RespoEvent::Keyboard(
          key=e.key(),
          key_code=e.key_code(),
          shift_key=e.shift_key(),
          ctrl_key=e.ctrl_key(),
          alt_key=e.alt_key(),
          meta_key=e.meta_key(),
          repeat=e.repeat(),
          original_event=e,
        )
        handle_event({ coord, name: Keyup, event_info: wrap_event }) catch {
          e => @dom_ffi.error_log("handle keyup event: " + e.to_string())
        }
      }
      match element.tag_name() {
        "INPUT" =>
          element.reinterpret_as_html_input_element().set_onkeyup(Some(handler))
        "TEXTAREA" =>
          element
          .reinterpret_as_html_textarea_element()
          .set_onkeyup(Some(handler))
        _ =>
          raise RespoCommonError(
            "not handled keyup event for element: " + element.tag_name(),
          )
      }
    }
    Keypress => {
      let handler = fn(e : @dom_ffi.KeyboardEvent) {
        let wrap_event = RespoEvent::Keyboard(
          key=e.key(),
          key_code=e.key_code(),
          shift_key=e.shift_key(),
          ctrl_key=e.ctrl_key(),
          alt_key=e.alt_key(),
          meta_key=e.meta_key(),
          repeat=e.repeat(),
          original_event=e,
        )
        handle_event({ coord, name: Keypress, event_info: wrap_event }) catch {
          e => @dom_ffi.error_log("handle keypress event: " + e.to_string())
        }
      }
      match element.tag_name() {
        "INPUT" =>
          element
          .reinterpret_as_html_input_element()
          .set_onkeypress(Some(handler))
        "TEXTAREA" =>
          element
          .reinterpret_as_html_textarea_element()
          .set_onkeypress(Some(handler))
        _ =>
          raise RespoCommonError(
            "not handled keypress event for element: " + element.tag_name(),
          )
      }
    }
    Focus => {
      let handler = fn(e : @dom_ffi.FocusEvent) {
        let wrap_event : RespoEvent = Focus(e)
        handle_event({ coord, name: Focus, event_info: wrap_event }) catch {
          e => @dom_ffi.error_log("handle focus event: " + e.to_string())
        }
      }
      element.reinterpret_as_html_input_element().set_onfocus(Some(handler))
    }
    Blur => {
      let handler = fn(e : @dom_ffi.BlurEvent) -> Unit {
        let wrap_event = RespoEvent::Blur(e)
        handle_event({ coord, name: Blur, event_info: wrap_event }) catch {
          e => @dom_ffi.error_log("handle blur event: " + e.to_string())
        }
      }
      element.reinterpret_as_html_input_element().set_onblur(Some(handler))
    }
    DragStart => {
      let handler = fn(e : @dom_ffi.DragEvent) {
        let wrap_event = RespoEvent::Drag(
          client_x=e.client_x(),
          client_y=e.client_y(),
          data_transfer=e.data_transfer(),
          original_event=e,
        )
        handle_event({ coord, name: DragStart, event_info: wrap_event }) catch {
          e => @dom_ffi.error_log("handle dragstart event: " + e.to_string())
        }
      }
      element.set_ondragstart(handler)
    }
    DragEnd => {
      let handler = fn(e : @dom_ffi.DragEvent) {
        let wrap_event = RespoEvent::Drag(
          client_x=e.client_x(),
          client_y=e.client_y(),
          data_transfer=e.data_transfer(),
          original_event=e,
        )
        handle_event({ coord, name: DragEnd, event_info: wrap_event }) catch {
          e => @dom_ffi.error_log("handle dragend event: " + e.to_string())
        }
      }
      element.set_ondragend(handler)
    }
    DragOver => {
      let handler = fn(e : @dom_ffi.DragEvent) {
        let wrap_event = RespoEvent::Drag(
          client_x=e.client_x(),
          client_y=e.client_y(),
          data_transfer=e.data_transfer(),
          original_event=e,
        )
        handle_event({ coord, name: DragOver, event_info: wrap_event }) catch {
          e => @dom_ffi.error_log("handle dragover event: " + e.to_string())
        }
      }
      element.set_ondragover(handler)
    }
    DragEnter => {
      let handler = fn(e : @dom_ffi.DragEvent) {
        let wrap_event = RespoEvent::Drag(
          client_x=e.client_x(),
          client_y=e.client_y(),
          data_transfer=e.data_transfer(),
          original_event=e,
        )
        handle_event({ coord, name: DragEnter, event_info: wrap_event }) catch {
          e => @dom_ffi.error_log("handle dragenter event: " + e.to_string())
        }
      }
      element.set_ondragenter(handler)
    }
    DragLeave => {
      let handler = fn(e : @dom_ffi.DragEvent) {
        let wrap_event = RespoEvent::Drag(
          client_x=e.client_x(),
          client_y=e.client_y(),
          data_transfer=e.data_transfer(),
          original_event=e,
        )
        handle_event({ coord, name: DragLeave, event_info: wrap_event }) catch {
          e => @dom_ffi.error_log("handle dragleave event: " + e.to_string())
        }
      }
      element.set_ondragleave(handler)
    }
    Drop => {
      let handler = fn(e : @dom_ffi.DragEvent) {
        let wrap_event = RespoEvent::Drag(
          client_x=e.client_x(),
          client_y=e.client_y(),
          data_transfer=e.data_transfer(),
          original_event=e,
        )
        handle_event({ coord, name: Drop, event_info: wrap_event }) catch {
          e => @dom_ffi.error_log("handle drop event: " + e.to_string())
        }
      }
      element.set_ondrop(handler)
    }
  }
}

///|
pub fn kabab_to_camel_case(s : String) -> String {
  let mut ret = ""
  let mut upper = false
  for c in s {
    if c == '-' {
      upper = true
    } else if upper {
      ret = ret + c.to_string().to_upper()
      upper = false
    } else {
      ret = ret + c.to_string()
    }
  }
  ret
}