///|
priv struct GraphMessage(Cmd)

///|
struct PageRuntime(
  @renderer.PageRuntime,
  @val.Graph,
  Ref[Bool],
  Ref[Bool],
  Map[String, String],
  PageLayoutState
)

///|
struct PageContext {
  graph : @val.Graph
  layout_value : Val[PageLayout]
}

///|
pub(all) enum PageLifecycleHook {
  Load
  Show
  Hide
  Unload
  PullDownRefresh
  ReachBottom
} derive(Eq)

///|
fn PageLifecycleHook::host_name(self : PageLifecycleHook) -> String {
  match self {
    Load => "onLoad"
    Show => "onShow"
    Hide => "onHide"
    Unload => "onUnload"
    PullDownRefresh => "onPullDownRefresh"
    ReachBottom => "onReachBottom"
  }
}

///|
pub fn PageContext::every(
  self : PageContext,
  key~ : String,
  interval_ms~ : Int,
  command~ : Cmd,
) -> Sub {
  let _ = self
  Sub(@val.every(key, interval_ms, command.0))
}

///|
pub fn PageContext::lifecycle(
  self : PageContext,
  hook : PageLifecycleHook,
  decode : (Json) -> Result[Cmd, DecodeError],
) -> Sub {
  let _ = self
  Sub(
    @val.lifecycle(hook.host_name(), payload => {
      match decode(payload) {
        Ok(command) => Ok(command.0)
        Err(error) => Err(@val.decode_error(error.message()))
      }
    }),
  )
}

///|
pub fn PageContext::on_load(self : PageContext, command : Cmd) -> Sub {
  self.lifecycle(Load, _ => Ok(command))
}

///|
pub fn PageContext::on_show(self : PageContext, command : Cmd) -> Sub {
  self.lifecycle(Show, _ => Ok(command))
}

///|
pub fn PageContext::on_hide(self : PageContext, command : Cmd) -> Sub {
  self.lifecycle(Hide, _ => Ok(command))
}

///|
pub fn PageContext::on_unload(self : PageContext, command : Cmd) -> Sub {
  self.lifecycle(Unload, _ => Ok(command))
}

///|
pub fn PageContext::on_pull_down_refresh(
  self : PageContext,
  command : Cmd,
) -> Sub {
  self.lifecycle(PullDownRefresh, _ => Ok(command))
}

///|
pub fn PageContext::on_reach_bottom(self : PageContext, command : Cmd) -> Sub {
  self.lifecycle(ReachBottom, _ => Ok(command))
}

///|
fn lifecycle_subscription_command(
  subscription : @val.Sub,
  hook : String,
  payload : Json,
) -> Result[Cmd, DecodeError] {
  match subscription {
    @val.SubNone | @val.SubEvery(_, _, _) => Ok(none)
    @val.SubBatch(subscriptions) => {
      let commands : Array[Cmd] = []
      for subscription in subscriptions {
        match lifecycle_subscription_command(subscription, hook, payload) {
          Ok(command) => commands.push(command)
          Err(error) => return Err(error)
        }
      }
      Ok(batch(commands))
    }
    @val.SubLifecycle(expected, decode) =>
      if expected == hook {
        match decode(payload) {
          Ok(command) => Ok(Cmd(command))
          Err(error) => Err(decode_error(error.message()))
        }
      } else {
        Ok(none)
      }
  }
}

///|
fn collect_intervals(
  subscription : @val.Sub,
  entries : Array[@renderer.HostSubscription[GraphMessage]],
) -> Unit {
  match subscription {
    @val.SubNone | @val.SubLifecycle(_, _) => ()
    @val.SubBatch(subscriptions) =>
      for subscription in subscriptions {
        collect_intervals(subscription, entries)
      }
    @val.SubEvery(key, interval_ms, command) =>
      entries.push(
        @renderer.every(key~, interval_ms~, msg=GraphMessage(command)),
      )
  }
}

///|
fn graph_subscriptions(
  graph : @val.Graph,
) -> @renderer.Subscriptions[GraphMessage] {
  let entries : Array[@renderer.HostSubscription[GraphMessage]] = []
  graph.run(fn() { collect_intervals(graph.current_subscriptions(), entries) })
  @renderer.subscriptions(entries)
}

///|
fn collect_runtime_commands(
  command : Cmd,
  output : Array[@runtime.Cmd[GraphMessage]],
) -> Unit {
  match command.0 {
    @val.CmdNone => ()
    @val.CmdMessage(run) => collect_runtime_commands(Cmd(run()), output)
    @val.CmdBatch(commands) =>
      for command in commands {
        collect_runtime_commands(Cmd(command), output)
      }
    @val.CmdEffect(start) =>
      output.push(
        @runtime.local_effect((emit, finish) => {
          start(command => emit(GraphMessage(Cmd(command))), finish)
        }),
      )
    @val.CmdHostEffect(capability, payload, resolve) =>
      output.push(
        @runtime.host_effect(
          @runtime.host_effect_request(capability, payload),
          outcome => {
            let converted = match outcome {
              @runtime.HostEffectOk(payload) => @val.HostOk(payload)
              @runtime.HostEffectErr(phase, message, payload) =>
                @val.HostErr(phase, message, payload)
            }
            GraphMessage(Cmd(resolve(converted)))
          },
        ),
      )
    @val.CmdNavigateTo(url) => output.push(@runtime.navigate_to_cmd(url))
    @val.CmdRedirectTo(url) => output.push(@runtime.redirect_to_cmd(url))
    @val.CmdSwitchTab(url) => output.push(@runtime.switch_tab_cmd(url))
    @val.CmdNavigateBack(delta) =>
      output.push(@runtime.navigate_back_cmd(delta))
    @val.CmdNavigateBackOrRedirect(delta, fallback) =>
      output.push(@runtime.navigate_back_or_redirect_cmd(delta, fallback))
  }
}

///|
fn graph_command_has_runtime_work(command : @val.Cmd) -> Bool {
  match command {
    @val.CmdNone => false
    @val.CmdBatch(commands) => {
      for command in commands {
        if graph_command_has_runtime_work(command) {
          return true
        }
      }
      false
    }
    _ => true
  }
}

///|
fn graph_update(
  graph : @val.Graph,
  message : GraphMessage,
  epoch : Int,
  initialized : Ref[Bool],
) -> (Int, @runtime.Cmd[GraphMessage]) {
  graph.begin()
  let commands : Array[@runtime.Cmd[GraphMessage]] = []
  graph.run(fn() { collect_runtime_commands(message.0, commands) })
  let revision = graph.candidate_revision()
  if revision == epoch {
    graph.commit()
  }
  // A dynamic branch may register state init commands while projection pulls
  // the graph. Drain them only after that candidate commits, in a new update.
  if initialized.val {
    commands.push(
      @runtime.local_effect((emit, finish) => {
        let pending = graph.take_initial_command()
        if graph_command_has_runtime_work(pending) {
          emit(GraphMessage(Cmd(pending)))
        }
        finish()
        fn() {  }
      }),
    )
  }
  (revision, @runtime.cmd_batch(commands))
}

///|
fn input_fields(payload : Json) -> Result[Map[String, String], DecodeError] {
  match payload {
    Object(fields) => {
      let output : Map[String, String] = Map([])
      for name, value in fields {
        output[name] = match value {
          String(text) => text
          value => value.stringify()
        }
      }
      Ok(output)
    }
    _ => Err(decode_error("page input payload must be an object"))
  }
}

///|
struct Page {
  id : String
  route : String
  title : String
  make_preview : (PageLayout) -> (
    @renderer.PageProgram[Int, GraphMessage],
    @val.Graph,
    PageLayoutState,
  )
  make_program : (Map[String, String], PageLayout) -> Result[
    (@renderer.PageProgram[Int, GraphMessage], @val.Graph, PageLayoutState),
    DecodeError,
  ]
}

///|
pub fn Page::id(self : Page) -> String {
  self.id
}

///|
pub fn Page::route(self : Page) -> String {
  self.route
}

///|
pub fn Page::title(self : Page) -> String {
  self.title
}

///|
pub fn Page::initial_tree(
  self : Page,
  layout? : PageLayout = PageLayout::unavailable(),
) -> Json {
  let (program, graph, _) = (self.make_preview)(layout)
  let value = program.initial_tree()
  graph.dispose()
  value
}

///|
pub fn Page::contract(self : Page) -> String {
  let (program, graph, _) = (self.make_preview)(PageLayout::unavailable())
  let value = program.contract()
  graph.dispose()
  value
}

///|
pub fn Page::contract_json(self : Page) -> Json {
  let (program, graph, _) = (self.make_preview)(PageLayout::unavailable())
  let value = program.contract_json()
  graph.dispose()
  value
}

///|
pub fn Page::wxml(self : Page) -> String {
  let (program, graph, _) = (self.make_preview)(PageLayout::unavailable())
  let value = program.wxml()
  graph.dispose()
  value
}

///|
pub fn Page::create_runtime(
  self : Page,
  input? : Map[String, String] = Map([]),
  layout? : PageLayout = PageLayout::unavailable(),
) -> Result[PageRuntime, DecodeError] {
  let snapshot = input.copy()
  let (program, graph, layout_state) = match
    (self.make_program)(snapshot.copy(), layout) {
    Ok(value) => value
    Err(error) => return Err(error)
  }
  let runtime = program.create_runtime(boot=true)
  let disposed = Ref(false)
  graph.attach_shared_runtime(capture_shared_wake(), fn() {
    disposed.val = true
    ignore(runtime.dispose())
  })
  Ok(PageRuntime(runtime, graph, Ref(false), disposed, snapshot, layout_state))
}

///|
fn PageRuntime::entry_error(self : PageRuntime) -> String? {
  if self.3.val {
    Some("[]")
  } else if !self.2.val {
    Some(
      self.diagnostic(
        "page_not_loaded", "page must receive onLoad before interaction",
      ),
    )
  } else {
    None
  }
}

///|
fn PageRuntime::diagnostic(
  self : PageRuntime,
  code : String,
  message : String,
) -> String {
  @runtime.runtime_commands_json([
    @runtime.runtime_error(self.page_id(), code, message),
  ])
}

///|
pub fn PageRuntime::page_id(self : PageRuntime) -> String {
  self.0.page_id()
}

///|
pub fn PageRuntime::mount(self : PageRuntime) -> String {
  if self.entry_error() is Some(error) {
    return error
  }
  self.0.mount()
}

///|
pub fn PageRuntime::dispatch(
  self : PageRuntime,
  event_key : String,
  payload_json : String,
) -> String {
  if self.entry_error() is Some(error) {
    return error
  }
  self.0.dispatch(event_key, payload_json)
}

///|
pub fn PageRuntime::dispatch_batch(
  self : PageRuntime,
  events_json : String,
) -> String {
  if self.entry_error() is Some(error) {
    return error
  }
  self.0.dispatch_batch(events_json)
}

///|
pub fn PageRuntime::lifecycle(
  self : PageRuntime,
  hook : String,
  payload_json : String,
) -> String {
  if self.3.val {
    return "[]"
  }
  if hook == "$minimoon:layout-show" {
    if self.entry_error() is Some(error) {
      return error
    }
    let payload = @json.parse(payload_json) catch { _ => Json::null() }
    self.queue_layout(PageLayout::from_json(payload))
    return self.lifecycle("onShow", "{}")
  }
  if hook == "onLoad" {
    if self.2.val {
      return self.diagnostic(
        "duplicate_page_load", "page onLoad may only run once",
      )
    }
    let payload = @json.parse(
      if payload_json.trim() == "" {
        "{}"
      } else {
        payload_json
      },
    ) catch {
      _ =>
        return self.diagnostic(
          "invalid_host_payload", "page input must be valid JSON",
        )
    }
    let fields = match input_fields(payload) {
      Ok(fields) => fields
      Err(error) =>
        return self.diagnostic("page_input_decode_failed", error.message())
    }
    if fields != self.4 {
      return self.diagnostic(
        "page_input_mismatch", "onLoad input differs from the runtime input snapshot",
      )
    }
    self.2.val = true
  } else if self.entry_error() is Some(error) {
    return error
  }
  if hook == "onHide" {
    self.5.visible.val = false
    self.1.set_shared_visible(false)
  }
  if hook == "onShow" {
    self.5.visible.val = true
    self.1.set_shared_visible(true)
  }
  if hook == "$minimoon:shared" {
    return self.0.refresh()
  }
  self.0.lifecycle(hook, payload_json)
}

///|
pub fn PageRuntime::resolve_effect(
  self : PageRuntime,
  request_id : String,
  phase : String,
  payload_json : String,
) -> String {
  if self.entry_error() is Some(error) {
    return error
  }
  self.0.resolve_effect(request_id, phase, payload_json)
}

///|
pub fn PageRuntime::subscription(
  self : PageRuntime,
  key : String,
  payload_json : String,
) -> String {
  if self.entry_error() is Some(error) {
    return error
  }
  self.0.subscription(key, payload_json)
}

///|
pub fn PageRuntime::snapshot(self : PageRuntime) -> String {
  self.0.snapshot()
}

///|
pub fn PageRuntime::dispose(self : PageRuntime) -> String {
  if self.3.val {
    return "[]"
  }
  self.3.val = true
  self.0.dispose()
}

///|
/// Drain ready local completions and synchronize committed application values.
pub fn PageRuntime::flush(self : PageRuntime) -> String {
  if self.entry_error() is Some(error) {
    return error
  }
  self.0.flush()
}

///|
/// Pending host requests and future subscription ticks are not ready work.
pub fn PageRuntime::has_ready_work(self : PageRuntime) -> Bool {
  !self.3.val &&
  self.2.val &&
  (self.0.has_ready_work() || self.1.has_shared_changes())
}

///|
pub fn runtime_api_version() -> Int {
  @renderer.runtime_api_version()
}

///|
fn[Input] make_page(
  id : String,
  route : Route,
  title : String,
  capabilities : Array[Capability],
  preview_input : () -> Input,
  decode_input : (Map[String, String]) -> Result[Input, DecodeError],
  build : (PageContext, Input) -> Val[Node],
) -> Page {
  guard !route.has_query() else {
    abort("Page route must not contain query parameters")
  }
  let route_path = route.path()
  let make_program = fn(input : Input, layout : PageLayout) {
    let graph = @val.Graph::new()
    let initialized = Ref(false)
    let (root, layout_state) = graph.build(fn() {
      let layout_state = PageLayoutState::new(
        graph,
        merge_layout(layout, PageLayout::unavailable()),
      )
      let root = build({ graph, layout_value: layout_state.value, }, input)
      (root, layout_state)
    })
    let lifecycle_events = [
      Load,
      Show,
      Hide,
      Unload,
      PullDownRefresh,
      ReachBottom,
    ].map(hook => {
      let core_hook = @renderer.page_lifecycle_hook(hook.host_name())
      @renderer.lifecycle_event(core_hook, payload => {
        let lifecycle_message = @val.CmdMessage(fn() {
          match
            lifecycle_subscription_command(
              graph.current_subscriptions(),
              hook.host_name(),
              payload,
            ) {
            Ok(command) => command.0
            Err(_) => none.0
          }
        })
        let command = if hook == Show {
          batch([
            layout_state.command(),
            Cmd(@val.CmdMessage(fn() { graph.refresh_shared() })),
            lifecycle_message,
          ])
        } else {
          lifecycle_message
        }
        Ok(GraphMessage(command))
      })
    })
    let initial_command = graph.take_initial_command()
    let program = @renderer.page_program(
      id~,
      route=route_path,
      title~,
      model=0,
      update=(epoch, message) => {
        graph_update(graph, message, epoch, initialized)
      },
      projection_guard=(previous, next) => previous != next,
      view=(events, _) => {
        graph.run(fn() {
          events.on_candidate_commit(fn() { graph.commit() })
          events.on_candidate_rollback(fn() { graph.rollback() })
          events.on_scope_cleanup("$minimoon:graph", fn() { graph.dispose() })
          materialize(root.0.read(), events)
        })
      },
      capabilities=capabilities.map(capability => capability.to_core()),
      lifecycle=lifecycle_events,
      init=GraphMessage(
        Cmd(
          @val.CmdMessage(fn() {
            initialized.val = true
            @val.batch([initial_command, graph.take_initial_command()])
          }),
        ),
      ),
      subscriptions=_ => graph_subscriptions(graph),
      refresh=GraphMessage(
        batch([
          layout_state.command(),
          Cmd(@val.CmdMessage(fn() { graph.refresh_shared() })),
        ]),
      ),
    )
    (program, graph, layout_state)
  }
  {
    id,
    route: route_path,
    title,
    make_preview: fn(layout) { make_program(preview_input(), layout) },
    make_program: (fields, layout) => {
      match decode_input(fields) {
        Ok(input) => Ok(make_program(input, layout))
        Err(error) => Err(error)
      }
    },
  }
}

///|
pub fn page(
  id~ : String,
  route~ : Route,
  title~ : String,
  capabilities? : Array[Capability] = [],
  build~ : (PageContext) -> Val[Node],
) -> Page {
  make_page(id, route, title, capabilities, () => (), _ => Ok(()), (context, _) => {
    build(context)
  })
}

///|
/// Build the common Elm-style page shape directly. The application-facing
/// `view` remains model-first, while subscriptions can use the page-owned
/// lifecycle context without an extra wrapper function.
pub fn[Model : Eq, Msg] elmish_page(
  id~ : String,
  route~ : Route,
  title~ : String,
  model~ : Model,
  update~ : (Model, Msg, Emit[Msg]) -> (Model, Cmd),
  view~ : (Model, Emit[Msg]) -> Node,
  capabilities? : Array[Capability] = [],
  init? : (Emit[Msg]) -> Cmd,
  subscriptions? : (PageContext, Model, Emit[Msg]) -> Sub,
) -> Page {
  page(id~, route~, title~, capabilities~, build=context => {
    let page_subscriptions = subscriptions.map(build => {
      (current : Model, emit : Emit[Msg]) => build(context, current, emit)
    })
    let (value, emit) = create_state_with_init(
      init=emit => (model, init.map(build => build(emit)).unwrap_or(none)),
      update~,
      subscriptions?=page_subscriptions,
    )
    value.view(current => view(current, emit))
  })
}

///|
pub fn[Input] page_with_input(
  id~ : String,
  route~ : Route,
  title~ : String,
  capabilities? : Array[Capability] = [],
  preview_input~ : () -> Input,
  decode_input~ : (Map[String, String]) -> Result[Input, DecodeError],
  build~ : (PageContext, Input) -> Val[Node],
) -> Page {
  make_page(id, route, title, capabilities, preview_input, decode_input, build)
}