///|
fn last_seen_id_to_json(id : Int64?) -> Json {
  match id {
    Some(n) => Json::number(n.to_double(), repr=n.to_string())
    None => Json::null()
  }
}

///|
fn id_to_json(id : Int64) -> Json {
  Json::number(id.to_double(), repr=id.to_string())
}

///|
fn parse_optional_int64(
  m : Map[String, Json],
  key : String,
  path : @json.JsonPath,
) -> Int64? raise @json.JsonDecodeError {
  match m.get(key) {
    None | Some(Null) => None
    Some(Number(n, ..)) => Some(n.to_int64())
    Some(String(s)) =>
      Some(
        @string.parse_int64(s) catch {
          _ => raise JsonDecodeError((path, "invalid int64 for '\{key}'"))
        },
      )
    _ => raise JsonDecodeError((path, "invalid type for '\{key}'"))
  }
}

///|
fn parse_optional_bool(
  m : Map[String, Json],
  key : String,
  path : @json.JsonPath,
) -> Bool? raise @json.JsonDecodeError {
  match m.get(key) {
    None | Some(Null) => None
    Some(True) => Some(true)
    Some(False) => Some(false)
    _ => raise JsonDecodeError((path, "invalid type for '\{key}'"))
  }
}

///|
fn parse_optional_string(
  m : Map[String, Json],
  key : String,
  path : @json.JsonPath,
) -> String? raise @json.JsonDecodeError {
  match m.get(key) {
    None | Some(Null) => None
    Some(String(s)) => Some(s)
    _ => raise JsonDecodeError((path, "invalid type for '\{key}'"))
  }
}

///|
/// Parse an optional integer field, returning `default` when the key
/// is absent or explicitly null. A peer built before the field
/// existed decodes to the default — this is what makes Register /
/// Connect `proto_version` fully backwards-compatible with v0 peers.
fn parse_int_or_default(
  m : Map[String, Json],
  key : String,
  default~ : Int,
  path : @json.JsonPath,
) -> Int raise @json.JsonDecodeError {
  match m.get(key) {
    None | Some(Null) => default
    Some(Number(n, ..)) => n.to_int()
    Some(String(s)) =>
      @string.parse_int(s) catch {
        _ => raise JsonDecodeError((path, "invalid int for '\{key}'"))
      }
    _ => raise JsonDecodeError((path, "invalid type for '\{key}'"))
  }
}

///|
fn string_array_to_json(values : Array[String]) -> Json {
  values.map(fn(value) { Json::string(value) }).to_json()
}

///|
fn parse_string_array_or_empty(
  m : Map[String, Json],
  key : String,
  path : @json.JsonPath,
) -> Array[String] raise @json.JsonDecodeError {
  match m.get(key) {
    None | Some(Null) => []
    Some(Array(items)) => {
      let out : Array[String] = []
      for item in items {
        match item {
          String(value) => out.push(value)
          _ => raise JsonDecodeError((path, "invalid entry in '\{key}'"))
        }
      }
      out
    }
    _ => raise JsonDecodeError((path, "invalid type for '\{key}'"))
  }
}

///|
fn parse_capability_set_or_empty(
  m : Map[String, Json],
  key : String,
  path : @json.JsonPath,
) -> CapabilitySet raise @json.JsonDecodeError {
  match m.get(key) {
    None | Some(Null) => CapabilitySet::empty()
    Some(json) => @json.FromJson::from_json(json, path.add_key(key))
  }
}

///|
fn parse_string_or_default(
  m : Map[String, Json],
  key : String,
  default? : String = "",
) -> String {
  match m.get(key) {
    Some(String(s)) => s
    _ => default
  }
}

///|
fn parse_bool_or_default(
  m : Map[String, Json],
  key : String,
  default~ : Bool,
  path : @json.JsonPath,
) -> Bool raise @json.JsonDecodeError {
  match m.get(key) {
    None | Some(Null) => default
    Some(True) => true
    Some(False) => false
    _ => raise JsonDecodeError((path, "invalid type for '\{key}'"))
  }
}

///|
pub impl ToJson for SysMessage with fn to_json(self) {
  match self {
    Register(task_uuid~, hostname~, cwd~, proto_version~, server_capabilities~) =>
      {
        "ch": "sys",
        "type": "register",
        "task_uuid": task_uuid.to_string(),
        "hostname": hostname,
        "cwd": cwd,
        "proto_version": proto_version,
        "server_capabilities": server_capabilities.to_json(),
      }
    Registered(task_uuid~) =>
      { "ch": "sys", "type": "registered", "task_uuid": task_uuid.to_string() }
    RegisterRejected(reason~) => {
      // `min_version` rides alongside the reason string so the client
      // can tell the operator which version to upgrade to. Only the
      // ProtoVersionTooOld variant carries it.
      let base : Map[String, Json] = {
        "ch": "sys",
        "type": "register_rejected",
        "reason": reason.to_wire(),
      }
      match reason {
        ProtoVersionTooOld(min_version~) =>
          base["min_version"] = Json::number(min_version.to_double())
        _ => ()
      }
      Json::object(base)
    }
    TaskExited(exit_code~) =>
      { "ch": "sys", "type": "task_exited", "exit_code": exit_code }
    Title(title~) => {
      let obj : Map[String, Json] = { "ch": "sys", "type": "title" }
      if title is Some(value) {
        obj["title"] = Json::string(value)
      }
      Json::object(obj)
    }
    Connect(task_uuid~, last_seen_id~, proto_version~, client_capabilities~) =>
      {
        "ch": "sys",
        "type": "connect",
        "task_uuid": task_uuid.to_string(),
        "last_seen_id": last_seen_id_to_json(last_seen_id),
        "proto_version": proto_version,
        "client_capabilities": client_capabilities.to_json(),
      }
    Connected(server_proto_version~, server_capabilities~) =>
      {
        "ch": "sys",
        "type": "connected",
        "server_proto_version": server_proto_version,
        "server_capabilities": server_capabilities.to_json(),
      }
    TaskOnline(server_proto_version~, server_capabilities~) =>
      {
        "ch": "sys",
        "type": "task_online",
        "server_proto_version": server_proto_version,
        "server_capabilities": server_capabilities.to_json(),
      }
    NewClient(
      client_id~,
      last_seen_id~,
      client_proto_version~,
      client_capabilities~
    ) =>
      {
        "ch": "sys",
        "type": "new_client",
        "client_id": client_id,
        "last_seen_id": last_seen_id_to_json(last_seen_id),
        "client_proto_version": client_proto_version,
        "client_capabilities": client_capabilities.to_json(),
      }
    ClientDisconnected(client_id~) =>
      { "ch": "sys", "type": "client_disconnected", "client_id": client_id }
    Error(code) => {
      let base : Map[String, Json] = {
        "ch": "sys",
        "type": "error",
        "code": code.to_wire(),
      }
      match code {
        ProtoVersionTooOld(min_version~) =>
          base["min_version"] = Json::number(min_version.to_double())
        _ => ()
      }
      Json::object(base)
    }
    AuthError(message~) =>
      { "ch": "sys", "type": "auth_error", "message": message }
  }
}

///|
pub impl @json.FromJson for SysMessage with fn from_json(json, path) {
  guard json is Object(fields) else {
    raise JsonDecodeError((path, "expected JSON object"))
  }
  let t = match fields.get("type") {
    Some(String(s)) => s
    _ => raise JsonDecodeError((path, "missing field 'type'"))
  }
  match t {
    "register" => {
      guard fields.get("task_uuid") is Some(String(uuid_str)) else {
        raise JsonDecodeError((path, "missing field 'task_uuid'"))
      }
      let task_uuid = @uuid.from_hex(uuid_str) catch {
        _ => raise JsonDecodeError((path, "invalid task_uuid: \{uuid_str}"))
      }
      let hostname = match fields.get("hostname") {
        Some(String(s)) => s
        _ => ""
      }
      let cwd = match fields.get("cwd") {
        Some(String(s)) => s
        _ => ""
      }
      // Missing field = v0 (pre-versioning peer).
      let proto_version = parse_int_or_default(
        fields,
        "proto_version",
        default=0,
        path,
      )
      let server_capabilities = parse_capability_set_or_empty(
        fields, "server_capabilities", path,
      )
      Register(
        task_uuid~,
        hostname~,
        cwd~,
        proto_version~,
        server_capabilities~,
      )
    }
    "registered" => {
      guard fields.get("task_uuid") is Some(String(uuid_str)) else {
        raise JsonDecodeError((path, "missing field 'task_uuid'"))
      }
      let task_uuid = @uuid.from_hex(uuid_str) catch {
        _ => raise JsonDecodeError((path, "invalid task_uuid: \{uuid_str}"))
      }
      Registered(task_uuid~)
    }
    "register_rejected" => {
      guard fields.get("reason") is Some(String(reason_str)) else {
        raise JsonDecodeError((path, "missing field 'reason'"))
      }
      let reason : RegisterRejectReason = match reason_str {
        "uuid_already_claimed_this_session" => UuidAlreadyClaimedThisSession
        "invalid_uuid" => InvalidUuid
        "proto_version_too_old" => {
          let min_version = parse_int_or_default(
            fields,
            "min_version",
            default=0,
            path,
          )
          ProtoVersionTooOld(min_version~)
        }
        _ =>
          raise JsonDecodeError(
            (path, "unknown register_rejected reason: \{reason_str}"),
          )
      }
      RegisterRejected(reason~)
    }
    "task_exited" => {
      guard fields.get("exit_code") is Some(Number(n, ..)) else {
        raise JsonDecodeError((path, "missing field 'exit_code'"))
      }
      TaskExited(exit_code=n.to_int())
    }
    "title" => Title(title=parse_optional_string(fields, "title", path))
    "connect" => {
      guard fields.get("task_uuid") is Some(String(uuid_str)) else {
        raise JsonDecodeError((path, "missing field 'task_uuid'"))
      }
      let task_uuid = @uuid.from_hex(uuid_str) catch {
        _ => raise JsonDecodeError((path, "invalid task_uuid: \{uuid_str}"))
      }
      let last_seen_id = parse_optional_int64(fields, "last_seen_id", path)
      let proto_version = parse_int_or_default(
        fields,
        "proto_version",
        default=0,
        path,
      )
      let client_capabilities = parse_capability_set_or_empty(
        fields, "client_capabilities", path,
      )
      Connect(task_uuid~, last_seen_id~, proto_version~, client_capabilities~)
    }
    "connected" => {
      let server_proto_version = parse_int_or_default(
        fields,
        "server_proto_version",
        default=0,
        path,
      )
      let server_capabilities = parse_capability_set_or_empty(
        fields, "server_capabilities", path,
      )
      Connected(server_proto_version~, server_capabilities~)
    }
    "task_online" => {
      let server_proto_version = parse_int_or_default(
        fields,
        "server_proto_version",
        default=0,
        path,
      )
      let server_capabilities = parse_capability_set_or_empty(
        fields, "server_capabilities", path,
      )
      TaskOnline(server_proto_version~, server_capabilities~)
    }
    "new_client" => {
      guard fields.get("client_id") is Some(String(id)) else {
        raise JsonDecodeError((path, "missing field 'client_id'"))
      }
      let last_seen_id = parse_optional_int64(fields, "last_seen_id", path)
      let client_proto_version = parse_int_or_default(
        fields,
        "client_proto_version",
        default=0,
        path,
      )
      let client_capabilities = parse_capability_set_or_empty(
        fields, "client_capabilities", path,
      )
      NewClient(
        client_id=id,
        last_seen_id~,
        client_proto_version~,
        client_capabilities~,
      )
    }
    "client_disconnected" => {
      guard fields.get("client_id") is Some(String(id)) else {
        raise JsonDecodeError((path, "missing field 'client_id'"))
      }
      ClientDisconnected(client_id=id)
    }
    "error" => {
      guard fields.get("code") is Some(String(code_str)) else {
        raise JsonDecodeError((path, "missing field 'code'"))
      }
      let code : ErrorCode = match code_str {
        "server_not_found" => TaskNotFound
        "server_offline" => TaskOffline
        "internal_error" => InternalError
        "proto_version_too_old" => {
          let min_version = parse_int_or_default(
            fields,
            "min_version",
            default=0,
            path,
          )
          ProtoVersionTooOld(min_version~)
        }
        _ => raise JsonDecodeError((path, "unknown error code: \{code_str}"))
      }
      Error(code)
    }
    "auth_error" => {
      guard fields.get("message") is Some(String(msg)) else {
        raise JsonDecodeError((path, "missing field 'message'"))
      }
      AuthError(message=msg)
    }
    _ => raise JsonDecodeError((path, "unknown sys message type: \{t}"))
  }
}

///|
pub impl ToJson for TermMessage with fn to_json(self) {
  match self {
    ClientReplayCursor(client_id~, last_seen_id~) => {
      let obj : Map[String, Json] = {
        "ch": "t",
        "type": "client_replay_cursor",
        "client_id": client_id,
      }
      obj["last_seen_id"] = last_seen_id_to_json(last_seen_id)
      Json::object(obj)
    }
    Viewport(client_id~, cols~, rows~, active~) =>
      {
        "ch": "t",
        "type": "viewport",
        "client_id": client_id,
        "cols": cols,
        "rows": rows,
        "active": active,
      }
    RenderFrame(
      client_id~,
      seq~,
      cols~,
      rows~,
      scroll_offset~,
      scrollback_rows~,
      title~,
      mouse~,
      data~
    ) => {
      let obj : Map[String, Json] = {
        "ch": "t",
        "type": "render_frame",
        "client_id": client_id,
        "seq": id_to_json(seq),
        "cols": cols,
        "rows": rows,
        "scroll_offset": id_to_json(scroll_offset),
        "scrollback_rows": id_to_json(scrollback_rows),
        "data": data,
      }
      if title is Some(value) {
        obj["title"] = Json::string(value)
      }
      if mouse is Some(value) {
        obj["mouse"] = value.to_json()
      }
      Json::object(obj)
    }
    RenderState(
      client_id~,
      seq~,
      cols~,
      rows~,
      scroll_offset~,
      scrollback_rows~,
      title~,
      mouse~,
      frame~
    ) => {
      let obj : Map[String, Json] = {
        "ch": "t",
        "type": "render_state",
        "client_id": client_id,
        "seq": id_to_json(seq),
        "cols": cols,
        "rows": rows,
        "scroll_offset": id_to_json(scroll_offset),
        "scrollback_rows": id_to_json(scrollback_rows),
      }
      if title is Some(value) {
        obj["title"] = Json::string(value)
      }
      if mouse is Some(value) {
        obj["mouse"] = value.to_json()
      }
      if frame.full {
        obj["full"] = frame.full.to_json()
      }
      if frame.styles.length() != 0 {
        obj["styles"] = frame.styles.to_json()
      }
      obj["lines"] = frame.lines.to_json()
      if frame.cursor is Some(cursor) {
        obj["cursor"] = cursor.to_json()
      }
      Json::object(obj)
    }
    InputText(client_id~, text~) =>
      { "ch": "t", "type": "input_text", "client_id": client_id, "text": text }
    InputKey(client_id~, key~, mods~, text~) => {
      let obj : Map[String, Json] = {
        "ch": "t",
        "type": "input_key",
        "client_id": client_id,
        "key": key,
        "mods": string_array_to_json(mods),
      }
      if text is Some(value) {
        obj["text"] = Json::string(value)
      }
      Json::object(obj)
    }
    InputPaste(client_id~, text~) =>
      { "ch": "t", "type": "input_paste", "client_id": client_id, "text": text }
    InputMouse(client_id~, x~, y~, button~, action~, mods~) => {
      let obj : Map[String, Json] = {
        "ch": "t",
        "type": "input_mouse",
        "client_id": client_id,
        "x": x,
        "y": y,
        "action": action,
        "mods": string_array_to_json(mods),
      }
      if button is Some(button) {
        obj["button"] = Json::string(button)
      }
      Json::object(obj)
    }
    InputScroll(client_id~, x~, y~, delta_rows~, mods~) =>
      {
        "ch": "t",
        "type": "input_scroll",
        "client_id": client_id,
        "x": x,
        "y": y,
        "delta_rows": delta_rows,
        "mods": string_array_to_json(mods),
      }
    ScrollToLive(client_id~) =>
      { "ch": "t", "type": "scroll_to_live", "client_id": client_id }
    Theme(client_id~, background~, foreground~) =>
      {
        "ch": "t",
        "type": "theme",
        "client_id": client_id,
        "background": background,
        "foreground": foreground,
      }
    Data(client_id~, id~, created~, replay~, data~) => {
      let obj : Map[String, Json] = {
        "ch": "t",
        "type": "data",
        "client_id": client_id,
        "id": id_to_json(id),
        "data": data,
      }
      // Omit when absent so old decoders still see a compact frame and
      // only new decoders observe the field.
      if created is Some(ts) {
        obj["created"] = id_to_json(ts)
      }
      if replay is Some(b) {
        obj["replay"] = b.to_json()
      }
      Json::object(obj)
    }
    Exited(client_id~, id~) =>
      {
        "ch": "t",
        "type": "exited",
        "client_id": client_id,
        "id": id_to_json(id),
      }
  }
}

///|
pub impl @json.FromJson for TermMessage with fn from_json(json, path) {
  guard json is Object(fields) else {
    raise JsonDecodeError((path, "expected JSON object"))
  }
  let t = match fields.get("type") {
    Some(String(s)) => s
    _ => raise JsonDecodeError((path, "missing field 'type'"))
  }
  match t {
    "client_replay_cursor" => {
      let client_id = match fields.get("client_id") {
        Some(String(s)) => s
        _ => ""
      }
      let last_seen_id = parse_optional_int64(fields, "last_seen_id", path)
      ClientReplayCursor(client_id~, last_seen_id~)
    }
    "viewport" => {
      let client_id = parse_string_or_default(fields, "client_id")
      let cols = parse_int_or_default(fields, "cols", default=0, path)
      let rows = parse_int_or_default(fields, "rows", default=0, path)
      let active = parse_bool_or_default(fields, "active", default=false, path)
      Viewport(client_id~, cols~, rows~, active~)
    }
    "render_frame" => {
      let client_id = parse_string_or_default(fields, "client_id")
      let seq = parse_optional_int64(fields, "seq", path).unwrap_or(0L)
      let cols = parse_int_or_default(fields, "cols", default=0, path)
      let rows = parse_int_or_default(fields, "rows", default=0, path)
      let scroll_offset = parse_optional_int64(fields, "scroll_offset", path).unwrap_or(
        0L,
      )
      let scrollback_rows = parse_optional_int64(
        fields, "scrollback_rows", path,
      ).unwrap_or(0L)
      let title = parse_optional_string(fields, "title", path)
      let mouse = parse_optional_bool(fields, "mouse", path)
      guard fields.get("data") is Some(String(data)) else {
        raise JsonDecodeError((path, "missing field 'data'"))
      }
      RenderFrame(
        client_id~,
        seq~,
        cols~,
        rows~,
        scroll_offset~,
        scrollback_rows~,
        title~,
        mouse~,
        data~,
      )
    }
    "render_state" => {
      let client_id = parse_string_or_default(fields, "client_id")
      let seq = parse_optional_int64(fields, "seq", path).unwrap_or(0L)
      let cols = parse_int_or_default(fields, "cols", default=0, path)
      let rows = parse_int_or_default(fields, "rows", default=0, path)
      let scroll_offset = parse_optional_int64(fields, "scroll_offset", path).unwrap_or(
        0L,
      )
      let scrollback_rows = parse_optional_int64(
        fields, "scrollback_rows", path,
      ).unwrap_or(0L)
      let title = parse_optional_string(fields, "title", path)
      let mouse = parse_optional_bool(fields, "mouse", path)
      let full = parse_bool_or_default(fields, "full", default=false, path)
      let styles : Array[RunStyle] = match fields.get("styles") {
        Some(styles_json) => @json.from_json(styles_json, path~)
        _ => []
      }
      let lines : Array[Line] = match fields.get("lines") {
        Some(lines_json) => @json.from_json(lines_json, path~)
        _ => []
      }
      let cursor : Cursor? = match fields.get("cursor") {
        Some(cursor_json) => Some(@json.from_json(cursor_json, path~))
        _ => None
      }
      RenderState(
        client_id~,
        seq~,
        cols~,
        rows~,
        scroll_offset~,
        scrollback_rows~,
        title~,
        mouse~,
        frame={ full, styles, lines, cursor },
      )
    }
    "input_text" => {
      let client_id = parse_string_or_default(fields, "client_id")
      let text = parse_string_or_default(fields, "text")
      InputText(client_id~, text~)
    }
    "input_key" => {
      let client_id = parse_string_or_default(fields, "client_id")
      let key = parse_string_or_default(fields, "key")
      let mods = parse_string_array_or_empty(fields, "mods", path)
      let text = parse_optional_string(fields, "text", path)
      InputKey(client_id~, key~, mods~, text~)
    }
    "input_paste" => {
      let client_id = parse_string_or_default(fields, "client_id")
      let text = parse_string_or_default(fields, "text")
      InputPaste(client_id~, text~)
    }
    "input_mouse" => {
      let client_id = parse_string_or_default(fields, "client_id")
      let x = parse_int_or_default(fields, "x", default=0, path)
      let y = parse_int_or_default(fields, "y", default=0, path)
      // Legacy senders spelled "no button" as an empty string; normalize it
      // so the typed message never carries the sentinel.
      let button = parse_optional_string(fields, "button", path).filter(value => {
        value != ""
      })
      let action = parse_string_or_default(fields, "action")
      let mods = parse_string_array_or_empty(fields, "mods", path)
      InputMouse(client_id~, x~, y~, button~, action~, mods~)
    }
    "input_scroll" => {
      let client_id = parse_string_or_default(fields, "client_id")
      let x = parse_int_or_default(fields, "x", default=0, path)
      let y = parse_int_or_default(fields, "y", default=0, path)
      let delta_rows = parse_int_or_default(
        fields,
        "delta_rows",
        default=0,
        path,
      )
      let mods = parse_string_array_or_empty(fields, "mods", path)
      InputScroll(client_id~, x~, y~, delta_rows~, mods~)
    }
    "scroll_to_live" => {
      let client_id = parse_string_or_default(fields, "client_id")
      ScrollToLive(client_id~)
    }
    "theme" => {
      let client_id = parse_string_or_default(fields, "client_id")
      let background = parse_string_or_default(fields, "background")
      let foreground = parse_string_or_default(fields, "foreground")
      Theme(client_id~, background~, foreground~)
    }
    "data" => {
      let client_id = match fields.get("client_id") {
        Some(String(s)) => s
        _ => ""
      }
      let id = parse_optional_int64(fields, "id", path).unwrap_or(0L)
      let created = parse_optional_int64(fields, "created", path)
      let replay = parse_optional_bool(fields, "replay", path)
      guard fields.get("data") is Some(String(data)) else {
        raise JsonDecodeError((path, "missing field 'data'"))
      }
      Data(client_id~, id~, created~, replay~, data~)
    }
    "exited" => {
      let client_id = match fields.get("client_id") {
        Some(String(s)) => s
        _ => ""
      }
      let id = parse_optional_int64(fields, "id", path).unwrap_or(0L)
      Exited(client_id~, id~)
    }
    _ => raise JsonDecodeError((path, "unknown term message type: \{t}"))
  }
}

///|
pub impl ToJson for Message with fn to_json(self) {
  match self {
    Sys(msg) => msg.to_json()
    Fs(msg) => msg.to_json()
    Ide(msg) => msg.to_json()
    Term(msg) => msg.to_json()
    Codex(client_id~, data~) =>
      { "ch": "codex", "client_id": client_id, "data": data }
  }
}

///|
pub impl @json.FromJson for Message with fn from_json(json, path) {
  guard json is Object(fields) else {
    raise JsonDecodeError((path, "expected JSON object"))
  }
  let ch = match fields.get("ch") {
    Some(String(c)) => c
    _ => "sys"
  }
  match ch {
    "sys" => Sys(@json.from_json(json, path~))
    "t" => Term(@json.from_json(json, path~))
    "fs" => Fs(@json.from_json(json, path~))
    "ide" => Ide(@json.from_json(json, path~))
    "codex" => {
      let client_id = match fields.get("client_id") {
        Some(String(s)) => s
        _ => ""
      }
      guard fields.get("data") is Some(String(data)) else {
        raise JsonDecodeError((path, "missing field 'data'"))
      }
      Codex(client_id~, data~)
    }
    _ => raise JsonDecodeError((path, "unknown channel: \{ch}"))
  }
}