///|
/// Drain all pending messages from the queue by running update + cmd for each.
/// Re-entrant messages (dispatched by cmd.run) are processed in the same pass
/// because the for-loop re-evaluates msg_queue.length() each iteration.
fn[Model, Msg] drain_messages(
  msg_queue : Array[Msg],
  state : Ref[Model],
  update : (Model, Msg) -> (Model, Cmd[Msg]),
  dispatch : (Msg) -> Unit,
) -> Unit {
  while msg_queue.length() > 0 {
    for cursor = 0; cursor < msg_queue.length(); cursor = cursor + 1 {
      let (new_model, cmd) = update(state.val, msg_queue[cursor])
      state.val = new_model
      cmd.run(dispatch)
    }
    msg_queue.clear()
  }
}

///|
/// Active subscription with its cleanup function.
priv struct ActiveSub {
  identity : String
  cleanup : () -> Unit
}

///|
/// Start a TEA application
pub fn[Model, Msg] start(
  init~ : () -> (Model, Cmd[Msg]),
  update~ : (Model, Msg) -> (Model, Cmd[Msg]),
  view~ : (Model) -> VNode[Msg],
  subscriptions~ : (Model) -> Sub[Msg],
  selector~ : String,
) -> Unit {
  let root_el = @webapi.document().query_selector_opt(selector)
  guard root_el is Some(el) else {
    abort("chai: no element matches selector \"" + selector + "\"")
  }
  let root_node : @webapi.Node = @webapi.TJsValue::to_js(el).unsafe_into()
  let runtime = new_component_runtime()
  let active_subs : @hashmap.HashMap[String, ActiveSub] = @hashmap.new()
  let (init_model, init_cmd) = init()
  let state : Ref[Model] = Ref::new(init_model)
  let current_vdom : Ref[VNode[Msg]] = Ref::new(Text(""))
  let msg_queue : Array[Msg] = []
  let is_updating : Ref[Bool] = Ref::new(false)
  let needs_render : Ref[Bool] = Ref::new(false)
  let dispatch_ref : Ref[(Msg) -> Unit] = Ref::new(fn(_msg) {  })
  let raf_scheduled : Ref[Bool] = Ref::new(false)
  fn dispatch(msg : Msg) -> Unit {
    (dispatch_ref.val)(msg)
  }

  fn do_render() -> Unit {
    begin_render_pass_for(runtime)
    let new_vdom = with_component_runtime(runtime, fn() { view(state.val) })
    guard node_first_child(root_node) is Some(child) else { return }
    diff(runtime, root_node, child, current_vdom.val, new_vdom, dispatch, "")
    current_vdom.val = new_vdom
    remove_unused_component_slots_for(runtime)
    drain_pending_init_cmds_for(runtime)
    update_subs(subscriptions(state.val), active_subs, dispatch)
  }

  fn process_queue() -> Unit {
    if is_updating.val {
      return
    }
    is_updating.val = true
    while msg_queue.length() > 0 || needs_render.val {
      drain_messages(msg_queue, state, update, dispatch)
      needs_render.val = false
      do_render()
    }
    is_updating.val = false
    raf_scheduled.val = false
  }

  fn schedule_process_queue() -> Unit {
    if raf_scheduled.val {
      return
    }
    raf_scheduled.val = true
    @webapi.window().request_animation_frame(fn(_timestamp) { process_queue() })
    |> ignore
  }

  dispatch_ref.val = fn(msg) {
    msg_queue.push(msg)
    schedule_process_queue()
  }
  runtime.trigger_render.val = fn() {
    needs_render.val = true
    schedule_process_queue()
  }
  reset_component_state_for(runtime)
  let initial_vdom = with_component_runtime(runtime, fn() { view(init_model) })
  replace_children(runtime, root_node, [initial_vdom], dispatch, "")
  current_vdom.val = initial_vdom
  drain_pending_init_cmds_for(runtime)
  init_cmd.run(dispatch)
  update_subs(subscriptions(init_model), active_subs, dispatch)
}

///|
/// Update active subscriptions based on new subscription definitions
fn[Msg] update_subs(
  new_sub : Sub[Msg],
  active_subs : @hashmap.HashMap[String, ActiveSub],
  dispatch : (Msg) -> Unit,
) -> Unit {
  if new_sub.subs.length() == 0 && active_subs.length() == 0 {
    return
  }
  let new_keys : @hashset.HashSet[String] = @hashset.new()
  for sd in new_sub.subs {
    new_keys.add(sd.key)
    match active_subs.get(sd.key) {
      Some(active) if active.identity == sd.identity => ()
      Some(active) => {
        (active.cleanup)()
        active_subs[sd.key] = {
          identity: sd.identity,
          cleanup: (sd.start)(dispatch),
        }
      }
      None =>
        active_subs[sd.key] = {
          identity: sd.identity,
          cleanup: (sd.start)(dispatch),
        }
    }
  }
  active_subs.retain(fn(k, sub) {
    if new_keys.contains(k) {
      true
    } else {
      (sub.cleanup)()
      false
    }
  })
}