///|
/// UI snapshot: a serializable description of a laid-out UI tree.
///
/// This is the introspection surface the verification tooling consumes.
/// A canvas-rendered UI has no DOM, so nothing outside the engine can see
/// where the nodes are; publishing this snapshot alongside a captured frame
/// is what lets the deterministic gates (text overflow, clipping, offscreen,
/// overlap, hit-box drift) and vlmkit's `diff png --elements-json` reason
/// about individual UI nodes instead of one opaque canvas.
///
/// Consumers:
/// - `scripts/ui-snapshot-to-vlmkit-elements.mjs` (vlmkit elements-json)
/// - `scripts/ui-integrity-gate.mjs` (deterministic UI defect gate)

///|
pub(all) struct UISnapshotRect {
  left : Double
  top : Double
  width : Double
  height : Double
} derive(Debug)

///|
pub impl Show for UISnapshotRect with fn output(self, logger) {
  logger.write_object(Repr(self))
}

///|
pub(all) struct UISafeArea {
  top : Double
  right : Double
  bottom : Double
  left : Double
} derive(Debug)

///|
pub impl Show for UISafeArea with fn output(self, logger) {
  logger.write_object(Repr(self))
}

///|
/// Measured extent of a node's text, as reported by `@text.TextRenderer::measure`.
/// Compared against the node rect to detect overflow the renderer will clip.
pub(all) struct UITextMetrics {
  width : Double
  height : Double
} derive(Debug)

///|
pub impl Show for UITextMetrics with fn output(self, logger) {
  logger.write_object(Repr(self))
}

///|
/// One node of a UI snapshot.
///
/// `path` is a stable structural address (`hud[0]>bar[1]`); `id` and `classes`
/// are the author-supplied names the tooling reports defects against. `clip` is
/// the effective scissor rect at draw time, `hit` the rect the pointer is
/// actually tested against (they diverge when a widget's hit box drifts from
/// what is drawn).
pub(all) struct UISnapshotNode {
  path : String
  id : String
  role : String
  classes : String
  left : Double
  top : Double
  width : Double
  height : Double
  z : Int
  visible : Bool
  clip : UISnapshotRect?
  text : String?
  text_measured : UITextMetrics?
  focusable : Bool
  focus_index : Int
  focused : Bool
  hit : UISnapshotRect?
} derive(Debug)

///|
pub impl Show for UISnapshotNode with fn output(self, logger) {
  logger.write_object(Repr(self))
}

///|
/// A node with every optional field cleared. Fill in with struct update syntax:
/// `{ ..default_ui_snapshot_node(), path: "hud[0]", id: "hud", width: 640.0 }`
pub fn default_ui_snapshot_node() -> UISnapshotNode {
  {
    path: "",
    id: "",
    role: "node",
    classes: "",
    left: 0.0,
    top: 0.0,
    width: 0.0,
    height: 0.0,
    z: 0,
    visible: true,
    clip: None,
    text: None,
    text_measured: None,
    focusable: false,
    focus_index: -1,
    focused: false,
    hit: None,
  }
}

///|
pub(all) struct UISnapshot {
  screen_width : Double
  screen_height : Double
  dpr : Double
  safe_area : UISafeArea
  frame : Int
  state : String
  nodes : Array[UISnapshotNode]
  focus_order : Array[String]
} derive(Debug)

///|
pub impl Show for UISnapshot with fn output(self, logger) {
  logger.write_object(Repr(self))
}

///|
pub fn default_ui_snapshot(
  screen_width~ : Double,
  screen_height~ : Double,
) -> UISnapshot {
  {
    screen_width,
    screen_height,
    dpr: 1.0,
    safe_area: { top: 0.0, right: 0.0, bottom: 0.0, left: 0.0, },
    frame: 0,
    state: "",
    nodes: [],
    focus_order: [],
  }
}

///|
/// Derive snapshot nodes straight from layout results.
///
/// This is the zero-effort path: it carries geometry only, which is enough for
/// offscreen / zero-size / overlap detection and for `--elements-json`
/// attribution. Text and focus metadata have to come from the caller, which is
/// the only place that knows a node's role.
pub fn ui_snapshot_nodes_from_layouts(
  layouts : Array[LayoutResult],
) -> Array[UISnapshotNode] {
  let nodes : Array[UISnapshotNode] = []
  for i, layout in layouts {
    let r = layout.rect
    nodes.push({
      ..default_ui_snapshot_node(),
      path: "node[" + i.to_string() + "]",
      id: "node_" + layout.node_id.value.to_string(),
      left: r.x,
      top: r.y,
      width: r.width,
      height: r.height,
      z: i,
    })
  }
  nodes
}

///|
/// Serialize a Double as JSON. NaN / infinity are not representable in JSON and
/// would produce a document no consumer can parse, so they collapse to 0.
fn json_double(value : Double) -> String {
  if value.is_nan() || value.is_inf() {
    return "0"
  }
  value.to_string()
}

///|
/// Write `"key": "value"`.
///
/// The value goes through `Json::string(..).stringify()` rather than a
/// hand-rolled escape: that also quotes the string and covers control
/// characters and escapes beyond the obvious five, so a label carrying one
/// cannot produce a document the gate then fails to parse.
fn append_json_string(
  buf : StringBuilder,
  key : String,
  value : String,
) -> Unit {
  buf.write_string("\"")
  buf.write_string(key)
  buf.write_string("\":")
  buf.write_string(Json::string(value).stringify())
}

///|
fn append_json_double(
  buf : StringBuilder,
  key : String,
  value : Double,
) -> Unit {
  buf.write_string("\"")
  buf.write_string(key)
  buf.write_string("\":")
  buf.write_string(json_double(value))
}

///|
fn append_rect_fields(buf : StringBuilder, rect : UISnapshotRect) -> Unit {
  append_json_double(buf, "left", rect.left)
  buf.write_string(",")
  append_json_double(buf, "top", rect.top)
  buf.write_string(",")
  append_json_double(buf, "width", rect.width)
  buf.write_string(",")
  append_json_double(buf, "height", rect.height)
}

///|
fn append_node_json(buf : StringBuilder, node : UISnapshotNode) -> Unit {
  buf.write_string("{")
  append_json_string(buf, "path", node.path)
  buf.write_string(",")
  append_json_string(buf, "id", node.id)
  buf.write_string(",")
  append_json_string(buf, "tag", node.role)
  buf.write_string(",")
  append_json_string(buf, "role", node.role)
  buf.write_string(",")
  append_json_string(buf, "classes", node.classes)
  buf.write_string(",")
  append_json_double(buf, "left", node.left)
  buf.write_string(",")
  append_json_double(buf, "top", node.top)
  buf.write_string(",")
  append_json_double(buf, "width", node.width)
  buf.write_string(",")
  append_json_double(buf, "height", node.height)
  buf.write_string(",\"z\":")
  buf.write_string(node.z.to_string())
  buf.write_string(",\"visible\":")
  buf.write_string(if node.visible { "true" } else { "false" })
  match node.clip {
    Some(clip) => {
      buf.write_string(",\"clip\":{")
      append_rect_fields(buf, clip)
      buf.write_string("}")
    }
    None => ()
  }
  match node.text {
    Some(text) => {
      buf.write_string(",")
      append_json_string(buf, "text", text)
    }
    None => ()
  }
  match node.text_measured {
    Some(metrics) => {
      buf.write_string(",\"text_measured\":{")
      append_json_double(buf, "width", metrics.width)
      buf.write_string(",")
      append_json_double(buf, "height", metrics.height)
      buf.write_string("}")
    }
    None => ()
  }
  buf.write_string(",\"focusable\":")
  buf.write_string(if node.focusable { "true" } else { "false" })
  buf.write_string(",\"focus_index\":")
  buf.write_string(node.focus_index.to_string())
  buf.write_string(",\"focused\":")
  buf.write_string(if node.focused { "true" } else { "false" })
  match node.hit {
    Some(hit) => {
      buf.write_string(",\"hit\":{")
      append_rect_fields(buf, hit)
      buf.write_string("}")
    }
    None => ()
  }
  buf.write_string("}")
}

///|
/// Serialize the snapshot to the introspection JSON.
///
/// Each node carries both `role` and a `tag` alias holding the same value, so
/// the document is directly consumable as a vlmkit `--elements-json` payload
/// (which requires `tag`) without a conversion step.
pub fn UISnapshot::to_json(self : UISnapshot) -> String {
  let buf = StringBuilder()
  buf.write_string("{\"screen\":{")
  append_json_double(buf, "width", self.screen_width)
  buf.write_string(",")
  append_json_double(buf, "height", self.screen_height)
  buf.write_string(",")
  append_json_double(buf, "dpr", self.dpr)
  buf.write_string(",\"safe_area\":{")
  append_json_double(buf, "top", self.safe_area.top)
  buf.write_string(",")
  append_json_double(buf, "right", self.safe_area.right)
  buf.write_string(",")
  append_json_double(buf, "bottom", self.safe_area.bottom)
  buf.write_string(",")
  append_json_double(buf, "left", self.safe_area.left)
  buf.write_string("}},\"frame\":")
  buf.write_string(self.frame.to_string())
  buf.write_string(",")
  append_json_string(buf, "state", self.state)
  buf.write_string(",\"nodes\":[")
  for i, node in self.nodes {
    if i > 0 {
      buf.write_string(",")
    }
    append_node_json(buf, node)
  }
  buf.write_string("],\"focus_order\":[")
  for i, id in self.focus_order {
    if i > 0 {
      buf.write_string(",")
    }
    buf.write_string(Json::string(id).stringify())
  }
  buf.write_string("]}")
  buf.to_string()
}