///|
priv struct AppDriver {
  owner : @val.Application
  running : @runtime.RunningComponent[Int, GraphMessage]
  output : Ref[Array[@runtime.RuntimeCommand]]
  launched : Ref[Bool]
  visible : Ref[Bool]
  intervals : Map[String, Int]
  subscription_messages : Map[String, Cmd]
  through : Ref[Int]
}

///|
#cfg(target="js")
extern "js" fn capture_app_wake() -> () -> Unit =
  #|() => typeof globalThis.__minimoonAppWakeV12 === "function" ? globalThis.__minimoonAppWakeV12 : () => {}

///|
#cfg(not(target="js"))
fn capture_app_wake() -> () -> Unit {
  fn() { () }
}

///|
fn AppDriver::new(
  owner : @val.Application,
  capabilities : Array[Capability],
) -> AppDriver {
  let output = Ref([])
  let through = Ref(0)
  let wake = capture_app_wake()
  owner.set_wake(wake)
  let queue = @runtime.RuntimeCommandQueue::new(commands => {
    for command in commands {
      output.val.push(command)
    }
  })
  let component = @runtime.checked_component(
    model=0,
    update=(epoch, message : GraphMessage) => {
      let graph = owner.graph()
      graph.begin()
      let commands : Array[@runtime.Cmd[GraphMessage]] = []
      graph.run(fn() {
        collect_app_commands(message.0.0, capabilities, output, commands)
      })
      graph.commit()
      owner.publish()
      (epoch + 1, @runtime.cmd_batch(commands))
    },
    view=(_, _) => @ui.text(""),
    project=_ => Ok("{}"),
    projection_guard=(_, _) => false,
  )
  let running = component.run_host(component_id="$app", queue~, wake_async=sequence => {
    through.val = sequence
    wake()
  })
  {
    owner,
    running,
    output,
    launched: Ref(false),
    visible: Ref(false),
    intervals: Map([]),
    subscription_messages: Map([]),
    through,
  }
}

///|
fn collect_app_commands(
  command : @val.Cmd,
  capabilities : Array[Capability],
  output : Ref[Array[@runtime.RuntimeCommand]],
  commands : Array[@runtime.Cmd[GraphMessage]],
) -> Unit {
  match command {
    @val.CmdNone => ()
    @val.CmdMessage(run) =>
      collect_app_commands(run(), capabilities, output, commands)
    @val.CmdBatch(batch) =>
      for command in batch {
        collect_app_commands(command, capabilities, output, commands)
      }
    @val.CmdHostEffect(capability, _, resolve) if capability !=
      "minimoon.timeout" &&
      !capabilities.any(allowed => allowed.name() == capability) =>
      collect_app_commands(
        resolve(
          @val.HostErr(
            "failure", "undeclared or unsupported application capability", "{}",
          ),
        ),
        capabilities,
        output,
        commands,
      )
    @val.CmdNavigateTo(_)
    | @val.CmdRedirectTo(_)
    | @val.CmdSwitchTab(_)
    | @val.CmdNavigateBack(_)
    | @val.CmdNavigateBackOrRedirect(_, _) =>
      output.val.push(
        @runtime.runtime_error(
          "$app", "invalid_app_command", "navigation requires a page owner",
        ),
      )
    _ => collect_runtime_commands(Cmd(command), commands)
  }
}

///|
fn AppDriver::drain(self : AppDriver) -> Unit {
  guard self.launched.val && !self.owner.is_disposed() else { return }
  let _ = self.running.drain_local_messages(through=self.through.val)
  for _index = 0; _index < 2048; _index = _index + 1 {
    match self.owner.next() {
      Some(command) => {
        let _ = self.running.dispatch(GraphMessage(Cmd(command)))
      }
      None => break
    }
  }
  self.sync_intervals()
  if self.owner.pending_count() > 0 {
    (self.owner.wake.val)()
  }
}

///|
/// Ready work excludes unresolved host effects and future subscription ticks.
pub fn[Deps] AppRuntime::has_ready_work(self : AppRuntime[Deps]) -> Bool {
  self.driver.launched.val &&
  !self.driver.owner.is_disposed() &&
  (
    self.driver.owner.pending_count() > 0 ||
    self.driver.running.pending_local_message_count() > 0
  )
}

///|
fn AppDriver::take_commands(self : AppDriver) -> String {
  let commands = self.output.val
  self.output.val = []
  @runtime.runtime_commands_json(commands)
}

///|
fn AppDriver::sync_intervals(self : AppDriver) -> Unit {
  let next : Map[String, (Int, Cmd)] = Map([])
  if self.visible.val && !self.owner.is_disposed() {
    let graph = self.owner.graph()
    graph.run(fn() {
      fn collect(sub : @val.Sub) {
        match sub {
          @val.SubBatch(subs) =>
            for sub in subs {
              collect(sub)
            }
          @val.SubEvery(key, interval, cmd) => {
            guard !next.contains(key) else {
              abort("duplicate application subscription key: " + key)
            }
            next[key] = (interval, Cmd(cmd))
          }
          _ => ()
        }
      }
      collect(graph.current_subscriptions())
    })
  }
  for key in self.intervals.keys().to_array() {
    if next.get(key).map(entry => entry.0) != self.intervals.get(key) {
      self.output.val.push(@runtime.stop_subscription_command("$app", key))
      self.intervals.remove(key)
      self.subscription_messages.remove(key)
    }
  }
  for key, entry in next {
    if !self.intervals.contains(key) {
      self.output.val.push(
        @runtime.start_subscription_command("$app", key, entry.0),
      )
      self.intervals[key] = entry.0
    }
    self.subscription_messages[key] = entry.1
  }
}

///|
pub fn[Deps] AppRuntime::flush(self : AppRuntime[Deps]) -> String {
  self.driver.drain()
  self.driver.take_commands()
}

///|
pub fn[Deps] AppRuntime::dispatch(
  self : AppRuntime[Deps],
  command : Cmd,
) -> String {
  self.driver.owner.enqueue(command.0)
  self.flush()
}

///|
pub fn[Deps] AppRuntime::lifecycle(
  self : AppRuntime[Deps],
  hook : String,
  payload_json : String,
) -> String {
  let driver = self.driver
  guard !driver.owner.is_disposed() else { return "[]" }
  let payload = @json.parse(payload_json) catch {
    _ =>
      return @runtime.runtime_commands_json([
        @runtime.runtime_error(
          "$app", "invalid_app_lifecycle", "invalid lifecycle JSON",
        ),
      ])
  }
  guard hook == "onLaunch" || hook == "onShow" || hook == "onHide" else {
    return @runtime.runtime_commands_json([
      @runtime.runtime_error(
        "$app", "invalid_app_lifecycle", "unknown application lifecycle",
      ),
    ])
  }
  if hook == "onLaunch" {
    guard !driver.launched.val else { return "[]" }
    driver.launched.val = true
    driver.owner.start()
    driver.drain()
  } else {
    guard driver.launched.val else { return "[]" }
    driver.visible.val = hook == "onShow"
  }
  let graph = driver.owner.graph()
  let command = graph.run(fn() {
    lifecycle_subscription_command(graph.current_subscriptions(), hook, payload)
  })
  match command {
    Ok(command) => driver.owner.enqueue(command.0)
    Err(error) =>
      driver.output.val.push(
        @runtime.runtime_error(
          "$app",
          "lifecycle_decode_failed",
          error.message(),
        ),
      )
  }
  self.flush()
}

///|
pub fn[Deps] AppRuntime::resolve_effect(
  self : AppRuntime[Deps],
  request_id : String,
  phase : String,
  payload_json : String,
) -> String {
  guard !self.driver.owner.is_disposed() && phase != "pending" else {
    return "[]"
  }
  let payload = @json.parse(payload_json) catch {
    _ =>
      return @runtime.runtime_commands_json([
        @runtime.runtime_error(
          "$app", "invalid_host_payload", "invalid effect JSON",
        ),
      ])
  }
  let outcome = if phase == "success" {
    @runtime.HostEffectOk(payload_json)
  } else {
    let message = match payload {
      Object(fields) =>
        match fields.get("error") {
          Some(String(message)) => message
          _ => "application host effect failed"
        }
      _ => "application host effect failed"
    }
    @runtime.HostEffectErr(phase, message, payload_json)
  }
  let _ = self.driver.running.resolve_host_effect(request_id, outcome)
  self.flush()
}

///|
pub fn[Deps] AppRuntime::subscription(
  self : AppRuntime[Deps],
  key : String,
) -> String {
  if self.driver.visible.val &&
    self.driver.subscription_messages.get(key) is Some(command) {
    self.driver.owner.enqueue(command.0)
  }
  self.flush()
}

///|
pub fn[Deps] AppRuntime::dispose(self : AppRuntime[Deps]) -> String {
  guard !self.driver.owner.is_disposed() else { return "[]" }
  self.driver.visible.val = false
  self.driver.sync_intervals()
  self.driver.owner.dispose()
  let _ = self.driver.running.dispose()
  self.driver.take_commands()
}