///|
priv struct CommandErrorPolicy {
  expose_details : Bool
}

///|
fn CommandErrorPolicy::new(expose_details : Bool) -> CommandErrorPolicy {
  CommandErrorPolicy::{ expose_details, }
}

///|
fn CommandErrorPolicy::response_message(
  self : CommandErrorPolicy,
  error : Error,
  safe_message : String,
) -> String {
  let diagnostic = @debug.render(Repr(error))
  if self.expose_details {
    diagnostic
  } else {
    println("[proton] command dispatch failed: " + diagnostic)
    safe_message
  }
}

///|
/// Command host intended to run in the MBT process.
///
/// The host is independent from the browser window, so application code can use
/// normal MoonBit syntax, state, and `async` handlers without living in the GUI
/// process.
struct MbtProcessHost {
  registry : OpRegistry
  op_names : Array[String]
  error_policy : CommandErrorPolicy
  mut registration_sealed : Bool
  mut closed : Bool
}

///|
/// Creates an empty MBT-side command host.
///
/// Handler diagnostics remain in backend logs by default. Set
/// `expose_error_details` only for a trusted development frontend.
pub fn MbtProcessHost::new(
  expose_error_details? : Bool = false,
) -> MbtProcessHost {
  MbtProcessHost::{
    registry: OpRegistry::new(),
    op_names: [],
    error_policy: CommandErrorPolicy::new(expose_error_details),
    registration_sealed: false,
    closed: false,
  }
}

///|
/// Registers a synchronous MBT-side op.
pub fn[Payload : @json.FromJson, Reply : ToJson] MbtProcessHost::op(
  self : MbtProcessHost,
  name : String,
  callback : (Payload) -> Reply raise?,
) -> Unit raise OpRegistrationError {
  self.ensure_open_for_register()
  self.registry.handle(name, fn(payload) raise { callback(payload) })
  self.op_names.push(name)
}

///|
/// Registers an async MBT-side op.
pub fn[Payload : @json.FromJson, Reply : ToJson] MbtProcessHost::op_async(
  self : MbtProcessHost,
  name : String,
  callback : async (Payload) -> Reply,
) -> Unit raise OpRegistrationError {
  self.ensure_open_for_register()
  self.registry.handle_async(name, callback)
  self.op_names.push(name)
}

///|
/// Registers an async MBT-side op that receives request-scoped context.
pub fn[Payload : @json.FromJson, Reply : ToJson] MbtProcessHost::op_async_with_context(
  self : MbtProcessHost,
  name : String,
  callback : async (AppCommandRequestContext, Payload) -> Reply,
) -> Unit raise OpRegistrationError {
  self.ensure_open_for_register()
  self.registry.handle_async_with_context(name, callback)
  self.op_names.push(name)
}

///|
/// Returns registered op names in registration order.
pub fn MbtProcessHost::registered_ops(self : MbtProcessHost) -> Array[String] {
  copy_strings(self.op_names)
}

///|
/// Dispatches one decoded IPC request on the current async loop.
///
/// User command hosts use this path so async ops run inside the user process
/// event loop instead of creating a nested event loop elsewhere.
pub async fn MbtProcessHost::dispatch_async(
  self : MbtProcessHost,
  request : @ipc.IpcOpRequest,
) -> @ipc.IpcOpResponse {
  let fallback_id = ipc_request_fallback_response_id(request)
  let request = request.validate() catch {
    error =>
      return @ipc.IpcOpResponse::err(
        fallback_id,
        self.error_policy.response_message(error, "invalid command request"),
      )
  }
  guard !self.closed else {
    return @ipc.IpcOpResponse::err(
      request.id,
      OpDispatchError::HostClosed.message(),
    )
  }
  let body = if self.registry.has_async(request.name) {
    self.registry.call_async_direct(request.name, request.payload) catch {
      error =>
        return @ipc.IpcOpResponse::err(
          request.id,
          self.dispatch_error_message(error),
        )
    }
  } else {
    self.registry.call(request.name, request.payload) catch {
      error =>
        return @ipc.IpcOpResponse::err(
          request.id,
          self.dispatch_error_message(error),
        )
    }
  }
  @ipc.IpcOpResponse::ok(request.id, body)
}

///|
/// Dispatches one decoded IPC request with request-scoped context.
pub async fn MbtProcessHost::dispatch_async_with_context(
  self : MbtProcessHost,
  context : AppCommandRequestContext,
  request : @ipc.IpcOpRequest,
) -> @ipc.IpcOpResponse {
  let fallback_id = ipc_request_fallback_response_id(request)
  let request = request.validate() catch {
    error =>
      return @ipc.IpcOpResponse::err(
        fallback_id,
        self.error_policy.response_message(error, "invalid command request"),
      )
  }
  guard !self.closed else {
    return @ipc.IpcOpResponse::err(
      request.id,
      OpDispatchError::HostClosed.message(),
    )
  }
  let body = self.registry.call_async_direct_with_context(
    context,
    request.name,
    request.payload,
  ) catch {
    error =>
      return @ipc.IpcOpResponse::err(
        request.id,
        self.dispatch_error_message(error),
      )
  }
  @ipc.IpcOpResponse::ok(request.id, body)
}

///|
fn MbtProcessHost::dispatch_error_message(
  self : MbtProcessHost,
  error : Error,
) -> String {
  let safe_message = match error {
    InvalidPayload(name~, ..) => "invalid payload for op " + name
    HandlerFailed(name~, ..) => "op " + name + " failed"
    UnknownOp(name~) => OpDispatchError::UnknownOp(name~).message()
    OpDispatchError::HostClosed => OpDispatchError::HostClosed.message()
    AsyncHandlerRequiresAsync(name~) =>
      OpDispatchError::AsyncHandlerRequiresAsync(name~).message()
    _ => "command dispatch failed"
  }
  self.error_policy.response_message(error, safe_message)
}

///|
/// Prevents later registrations while keeping existing ops dispatchable.
fn MbtProcessHost::seal_registrations(self : MbtProcessHost) -> Unit {
  guard !self.closed else { return }
  self.registration_sealed = true
}

///|
/// Closes this host and rejects future dispatches.
pub fn MbtProcessHost::close(self : MbtProcessHost) -> Unit {
  guard !self.closed else { return }
  self.closed = true
}

///|
fn MbtProcessHost::ensure_open_for_register(
  self : MbtProcessHost,
) -> Unit raise OpRegistrationError {
  guard !self.closed else { raise OpRegistrationError::HostClosed }
  guard !self.registration_sealed else { raise RegistrationSealed }
}

///|
fn ipc_request_fallback_response_id(request : @ipc.IpcOpRequest) -> String {
  if request.id.trim().to_owned() == "" {
    "invalid"
  } else {
    request.id
  }
}

///|
fn copy_strings(values : Array[String]) -> Array[String] {
  values.map(fn(value) { value })
}