///|
/// A viewport-relative rectangle in logical pixels. A missing node yields None.
pub struct NodeRect {
  id : String
  left : Double
  top : Double
  right : Double
  bottom : Double
  width : Double
  height : Double
} derive(Eq, Debug)

///|
fn valid_measure_id(id : String) -> Bool {
  if id == "" {
    return false
  }
  for character in id.iter() {
    if !((character >= 'a' && character <= 'z') ||
      (character >= 'A' && character <= 'Z') ||
      (character >= '0' && character <= '9') ||
      character == '_' ||
      character == '-') {
      return false
    }
  }
  true
}

///|
fn decode_node_rect(value : Json) -> Result[NodeRect?, HostError] {
  if value is Null {
    return Ok(None)
  }
  let fields = match object_result(MeasureNodes, value) {
    Ok(fields) => fields
    Err(error) => return Err(error)
  }
  let id = match string_field(fields, "id") {
    Some(id) if valid_measure_id(id) => id
    _ =>
      return Err(
        invalid_payload(MeasureNodes, "measurement id is invalid", value),
      )
  }
  let numbers : Array[Double] = []
  for key in ["left", "top", "right", "bottom", "width", "height"] {
    match number_field(fields, key) {
      Some(number) if finite_number(number) => numbers.push(number)
      _ =>
        return Err(
          invalid_payload(
            MeasureNodes,
            "measurement coordinate is invalid: " + key,
            value,
          ),
        )
    }
  }
  if numbers[4] < 0.0 || numbers[5] < 0.0 {
    return Err(
      invalid_payload(
        MeasureNodes,
        "measurement dimensions are negative",
        value,
      ),
    )
  }
  Ok(
    Some({
      id,
      left: numbers[0],
      top: numbers[1],
      right: numbers[2],
      bottom: numbers[3],
      width: numbers[4],
      height: numbers[5],
    }),
  )
}

///|
fn decode_measurements(
  ids : Array[String],
  value : Json,
) -> Result[Array[NodeRect?], HostError] {
  let fields = match object_result(MeasureNodes, value) {
    Ok(fields) => fields
    Err(error) => return Err(error)
  }
  guard fields.get("nodes") is Some(Array(nodes)) &&
    nodes.length() == ids.length() else {
    return Err(
      invalid_payload(
        MeasureNodes,
        "measurement result must preserve the requested node count",
        value,
      ),
    )
  }
  let output : Array[NodeRect?] = []
  for index, node in nodes {
    match decode_node_rect(node) {
      Ok(rect) => {
        if rect is Some(rect) && rect.id != ids[index] {
          return Err(
            invalid_payload(
              MeasureNodes,
              "measurement result id does not match the request",
              value,
            ),
          )
        }
        output.push(rect)
      }
      Err(error) => return Err(error)
    }
  }
  Ok(output)
}

///|
/// Measure at most 128 page-local IDs after the pending render has been acknowledged.
/// IDs are identifiers, never CSS selectors. Declare `MeasureNodes` on the page.
pub fn measure_nodes(
  ids : Array[String],
  resolve : Emit[Result[Array[NodeRect?], HostError]],
) -> Cmd {
  guard ids.length() <= 128 else {
    abort("measure_nodes accepts at most 128 IDs")
  }
  let seen : Set[String] = Set([])
  let requested : Array[String] = []
  for id in ids {
    guard valid_measure_id(id) && !seen.contains(id) else {
      abort(
        "measure_nodes requires unique nonempty alphanumeric, underscore or hyphen IDs",
      )
    }
    seen.add(id)
    requested.push(id)
  }
  host_effect(
    MeasureNodes,
    Json::object({ "ids": Json::array(requested.map(Json::string)) }),
    value => decode_measurements(requested, value),
    resolve,
  )
}