///|
/// Opaque handle to a browser-native `WebSocket` instance.
#external
pub type BrowserWebSocket

///|
pub extern "js" fn ws_log(text : String) -> Unit =
  #| (text) => console.log("[cumulo] " + text)

///|
pub extern "js" fn ws_warn(text : String) -> Unit =
  #| (text) => console.warn("[cumulo] " + text)

///|
pub extern "js" fn ws_error(text : String) -> Unit =
  #| (text) => console.error("[cumulo] " + text)

///|
/// Initiate a clean close handshake on an existing WebSocket.
pub extern "js" fn ws_close(socket : BrowserWebSocket) -> Unit =
  #| (socket) => socket.close()

///|
/// Build a WebSocket URL that works for both dev and production.
///
/// `dev_ports` — comma-separated Vite (or other dev-server) ports,
///   e.g. `"5173,5174,5175,5176"`.
/// `backend_port` — the port the WebSocket backend listens on.
/// `ws_path` — the path, e.g. `"/ws"`.
///
/// When the current page is served from one of `dev_ports`, the WS request
/// is routed to `backend_port` on the same hostname (bypasses the dev proxy).
/// In production the request uses same-origin with `ws_path`.
///
/// No ports or paths are hardcoded — fully caller-configurable.
pub extern "js" fn make_ws_url(
  dev_ports : String,
  backend_port : Int,
  ws_path : String,
) -> String =
  #| (devPorts, backendPort, wsPath) => {
  #|   const protocol = window.location.protocol === "https:" ? "wss" : "ws";
  #|   const devPortSet = new Set(devPorts.split(","));
  #|   if (devPortSet.has(window.location.port)) {
  #|     return `${protocol}://${window.location.hostname}:${backendPort}${wsPath}`;
  #|   }
  #|   return `${protocol}://${window.location.host}${wsPath}`;
  #| }

///|
extern "js" fn _connect_ws(
  url : String,
  on_open : () -> Unit,
  on_message : (String) -> Unit,
  on_close : () -> Unit,
) -> BrowserWebSocket =
  #| (url, onOpen, onMessage, onClose) => {
  #|   const socket = new WebSocket(url);
  #|   socket.addEventListener("open", () => onOpen());
  #|   socket.addEventListener("message", (e) => onMessage(String(e.data ?? "")));
  #|   socket.addEventListener("close", () => onClose());
  #|   return socket;
  #| }

///|
extern "js" fn _ws_send(socket : BrowserWebSocket, text : String) -> Unit =
  #| (socket, text) => socket.send(text)

///|
/// Connect to a WebSocket server with fully typed JSON messages.
///
/// - `on_open()` — connection established
/// - `on_message(msg)` — called with the already-parsed `In` value;
///   JSON parse errors are logged via `ws_error` and dropped
/// - `on_close()` — connection closed or errored
///
/// Returns `(socket, send_fn)`:
/// - `socket` — live handle; pass to `ws_close` when needed
/// - `send_fn` — typed `(Out) -> Unit`; serializes to JSON internally;
///   **safe to call immediately** — no-ops until the connection is open
///   (guards against CONNECTING-state race conditions)
///
/// ## Example
///
/// ```moonbit nocheck
/// let url = make_ws_url("5173,5174", 5022, "/ws")
/// let (socket, send) = connect_json(
///   url,
///   fn() { store.update(Connected) },
///   fn(msg : ServerEvent) { store.update(Received(msg)) },
///   fn() { store.update(Disconnected) },
/// )
/// // later: send(ClientOp::Login(...))
/// ```
pub fn[In : @json.FromJson, Out : ToJson] connect_json(
  url : String,
  on_open : () -> Unit,
  on_message : (In) -> Unit,
  on_close : () -> Unit,
) -> (BrowserWebSocket, (Out) -> Unit) {
  // `ready` guards sends against the CONNECTING state.
  // The flag is set inside the open callback (async), so `send_fn` returned
  // synchronously is safe to store and call without checking connection state.
  let ready : Ref[Bool] = Ref::new(false)
  let socket = _connect_ws(
    url,
    fn() {
      ready.val = true
      on_open()
    },
    fn(text) {
      try {
        let msg : In = @json.from_json(@json.parse(text))
        on_message(msg)
      } catch {
        _ => ws_error("ws parse error: \{text[:120]}")
      }
    },
    fn() {
      ready.val = false
      on_close()
    },
  )
  let send_fn = fn(msg : Out) {
    if ready.val {
      _ws_send(socket, msg.to_json().stringify())
    }
  }
  (socket, send_fn)
}