///|
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 @json.JsonDecodeError((path, "invalid int64 for '\{key}'"))
        },
      )
    _ => raise @json.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 @json.JsonDecodeError((path, "invalid int for '\{key}'"))
      }
    _ => raise @json.JsonDecodeError((path, "invalid type for '\{key}'"))
  }
}

///|
pub impl ToJson for SysMessage with to_json(self) {
  match self {
    Register(task_uuid~, hostname~, cwd~, proto_version~) =>
      {
        "ch": "sys",
        "type": "register",
        "task_uuid": task_uuid.to_string(),
        "hostname": hostname,
        "cwd": cwd,
        "proto_version": proto_version,
      }
    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 }
    Connect(task_uuid~, last_seen_id~, proto_version~) =>
      {
        "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,
      }
    Connected => { "ch": "sys", "type": "connected" }
    TaskOnline => { "ch": "sys", "type": "task_online" }
    NewClient(client_id~, last_seen_id~) =>
      {
        "ch": "sys",
        "type": "new_client",
        "client_id": client_id,
        "last_seen_id": last_seen_id_to_json(last_seen_id),
      }
    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 from_json(json, path) {
  guard json is Object(fields) else {
    raise @json.JsonDecodeError((path, "expected JSON object"))
  }
  let t = match fields.get("type") {
    Some(String(s)) => s
    _ => raise @json.JsonDecodeError((path, "missing field 'type'"))
  }
  match t {
    "register" => {
      guard fields.get("task_uuid") is Some(String(uuid_str)) else {
        raise @json.JsonDecodeError((path, "missing field 'task_uuid'"))
      }
      let task_uuid = @uuid.from_hex(uuid_str) catch {
        _ =>
          raise @json.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,
      )
      Register(task_uuid~, hostname~, cwd~, proto_version~)
    }
    "registered" => {
      guard fields.get("task_uuid") is Some(String(uuid_str)) else {
        raise @json.JsonDecodeError((path, "missing field 'task_uuid'"))
      }
      let task_uuid = @uuid.from_hex(uuid_str) catch {
        _ =>
          raise @json.JsonDecodeError((path, "invalid task_uuid: \{uuid_str}"))
      }
      Registered(task_uuid~)
    }
    "register_rejected" => {
      guard fields.get("reason") is Some(String(reason_str)) else {
        raise @json.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 @json.JsonDecodeError(
            (path, "unknown register_rejected reason: \{reason_str}"),
          )
      }
      RegisterRejected(reason~)
    }
    "task_exited" => {
      guard fields.get("exit_code") is Some(Number(n, ..)) else {
        raise @json.JsonDecodeError((path, "missing field 'exit_code'"))
      }
      TaskExited(exit_code=n.to_int())
    }
    "connect" => {
      guard fields.get("task_uuid") is Some(String(uuid_str)) else {
        raise @json.JsonDecodeError((path, "missing field 'task_uuid'"))
      }
      let task_uuid = @uuid.from_hex(uuid_str) catch {
        _ =>
          raise @json.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,
      )
      Connect(task_uuid~, last_seen_id~, proto_version~)
    }
    "connected" => Connected
    "task_online" => TaskOnline
    "new_client" => {
      guard fields.get("client_id") is Some(String(id)) else {
        raise @json.JsonDecodeError((path, "missing field 'client_id'"))
      }
      let last_seen_id = parse_optional_int64(fields, "last_seen_id", path)
      NewClient(client_id=id, last_seen_id~)
    }
    "client_disconnected" => {
      guard fields.get("client_id") is Some(String(id)) else {
        raise @json.JsonDecodeError((path, "missing field 'client_id'"))
      }
      ClientDisconnected(client_id=id)
    }
    "error" => {
      guard fields.get("code") is Some(String(code_str)) else {
        raise @json.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 @json.JsonDecodeError((path, "unknown error code: \{code_str}"))
      }
      Error(code)
    }
    "auth_error" => {
      guard fields.get("message") is Some(String(msg)) else {
        raise @json.JsonDecodeError((path, "missing field 'message'"))
      }
      AuthError(message=msg)
    }
    _ => raise @json.JsonDecodeError((path, "unknown sys message type: \{t}"))
  }
}

///|
pub impl ToJson for TermMessage with to_json(self) {
  match self {
    Data(client_id~, id~, created~, 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)
      }
      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 from_json(json, path) {
  guard json is Object(fields) else {
    raise @json.JsonDecodeError((path, "expected JSON object"))
  }
  let t = match fields.get("type") {
    Some(String(s)) => s
    _ => raise @json.JsonDecodeError((path, "missing field 'type'"))
  }
  match t {
    "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)
      guard fields.get("data") is Some(String(data)) else {
        raise @json.JsonDecodeError((path, "missing field 'data'"))
      }
      Data(client_id~, id~, created~, 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 @json.JsonDecodeError((path, "unknown term message type: \{t}"))
  }
}

///|
pub impl ToJson for Message with to_json(self) {
  match self {
    Sys(msg) => msg.to_json()
    Fs(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 from_json(json, path) {
  guard json is Object(fields) else {
    raise @json.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~))
    "codex" => {
      let client_id = match fields.get("client_id") {
        Some(String(s)) => s
        _ => ""
      }
      guard fields.get("data") is Some(String(data)) else {
        raise @json.JsonDecodeError((path, "missing field 'data'"))
      }
      Codex(client_id~, data~)
    }
    _ => raise @json.JsonDecodeError((path, "unknown channel: \{ch}"))
  }
}